diff --git a/.gitignore b/.gitignore index 65d15c1..e746ad3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,8 @@ TestResults/ .complexity-log.md .codex/ docs/superpowers/plans/ +.mcp.json +.grok/ +.pi/analyzer-health/ +.claude/settings.local.json +.superpowers/ diff --git a/.grok/config.toml b/.grok/config.toml deleted file mode 100644 index cf8b0b3..0000000 --- a/.grok/config.toml +++ /dev/null @@ -1,42 +0,0 @@ -# Grok Build — ConfigContraband project MCP surface -# Note: repo is .slnx-only. csharp-ls workspace diagnostics can be flaky — -# prefer per-file dotnet_diagnostics + dotnet build. Roslyn MCP may still load the slnx. - -[mcp_servers.roslyn] -command = "/Users/georgewall/.dotnet/tools/dotnet-roslyn-mcp" -enabled = true -startup_timeout_sec = 60 -tool_timeout_sec = 180 - -[mcp_servers.roslyn.env] -DOTNET_ROOT = "/usr/local/share/dotnet" -DOTNET_HOST_PATH = "/opt/homebrew/bin/dotnet" -DOTNET_ROLL_FORWARD = "LatestMajor" -DOTNET_SOLUTION_PATH = "/Users/georgewall/fiveFive/ConfiContraband/ConfigContraband.slnx" -DOTNET_CLI_TELEMETRY_OPTOUT = "1" -PATH = "/opt/homebrew/bin:/usr/local/share/dotnet:/Users/georgewall/.dotnet/tools:/usr/bin:/bin:/usr/sbin:/sbin" - -[mcp_servers.swift-lsp] -command = "/Users/georgewall/.local/share/codex-tools/lsp-mcp/lsp_mcp.py" -args = ["--server-name", "swift-lsp", "--tool-prefix", "swift", "--language-id", "swift", "--root-marker", "*.xcodeproj", "--root-marker", "Package.swift", "--", "/usr/bin/sourcekit-lsp"] -enabled = false - -[mcp_servers.typescript-lsp] -command = "/Users/georgewall/.local/share/codex-tools/lsp-mcp/lsp_mcp.py" -args = ["--server-name", "typescript-lsp", "--tool-prefix", "typescript", "--language-id", "typescript", "--root-marker", "tsconfig.json", "--root-marker", "package.json", "--", "/Users/georgewall/.bun/bin/typescript-language-server", "--stdio"] -enabled = false - -[mcp_servers.pyright] -command = "/Users/georgewall/.local/share/codex-tools/lsp-mcp/lsp_mcp.py" -args = ["--server-name", "pyright", "--tool-prefix", "py", "--language-id", "python", "--root-marker", "pyproject.toml", "--root-marker", "requirements.txt", "--root-marker", "setup.py", "--", "/Users/georgewall/.bun/bin/pyright-langserver", "--stdio"] -enabled = false - -[mcp_servers.gopls] -command = "/Users/georgewall/.local/share/codex-tools/lsp-mcp/lsp_mcp.py" -args = ["--server-name", "gopls", "--tool-prefix", "go", "--language-id", "go", "--root-marker", "go.mod", "--", "/Users/georgewall/go/bin/gopls"] -enabled = false - -[mcp_servers.playwright] -command = "bunx" -args = ["@playwright/mcp@latest"] -enabled = false diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 0c4b809..0000000 --- a/.mcp.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "mcpServers": { - "roslyn": { - "command": "/Users/georgewall/.dotnet/tools/dotnet-roslyn-mcp", - "env": { - "DOTNET_SOLUTION_PATH": "/Users/georgewall/fiveFive/ConfiContraband/ConfigContraband.slnx", - "DOTNET_ROLL_FORWARD": "LatestMajor", - "DOTNET_ROOT": "/usr/local/share/dotnet", - "DOTNET_HOST_PATH": "/opt/homebrew/bin/dotnet", - "DOTNET_CLI_TELEMETRY_OPTOUT": "1", - "PATH": "/opt/homebrew/bin:/usr/local/share/dotnet:/Users/georgewall/.dotnet/tools:/usr/bin:/bin:/usr/sbin:/sbin" - } - } - } -} diff --git a/.pi/analyzer-health/audit-2026-06-30T14-26-59-992Z.md b/.pi/analyzer-health/audit-2026-06-30T14-26-59-992Z.md deleted file mode 100644 index ba00266..0000000 --- a/.pi/analyzer-health/audit-2026-06-30T14-26-59-992Z.md +++ /dev/null @@ -1,39 +0,0 @@ -# Analyzer Health Audit Evidence - -- cwd: `/Users/georgewall/fiveFive/ConfiContraband` -- health file: `/Users/georgewall/fiveFive/ConfiContraband/analyzer-health.md` -- last refreshed: 2026-06-30 -- package version in health: 0.5.1 -- base audited commit: 08666fb -- HEAD: 08666fb - -## Package Versions - -- src/ConfigContraband/ConfigContraband.csproj: `0.5.1` -- src/ConfigContraband.Tool/ConfigContraband.Tool.csproj: `0.5.1` - -## Commits Since Base - -- none - -## Changed Files - -- none - -## Verification (none) - -Status: **not-run** - -- not run - -## Score Math Issues - -- none - -## Score Recommendations - -- none - -## Recommended Actions - -- Run at least targeted verification before increasing Test Depth, Fix Safety, or Release Readiness scores. diff --git a/.pi/analyzer-health/audit-2026-06-30T14-27-29-048Z.md b/.pi/analyzer-health/audit-2026-06-30T14-27-29-048Z.md deleted file mode 100644 index ba00266..0000000 --- a/.pi/analyzer-health/audit-2026-06-30T14-27-29-048Z.md +++ /dev/null @@ -1,39 +0,0 @@ -# Analyzer Health Audit Evidence - -- cwd: `/Users/georgewall/fiveFive/ConfiContraband` -- health file: `/Users/georgewall/fiveFive/ConfiContraband/analyzer-health.md` -- last refreshed: 2026-06-30 -- package version in health: 0.5.1 -- base audited commit: 08666fb -- HEAD: 08666fb - -## Package Versions - -- src/ConfigContraband/ConfigContraband.csproj: `0.5.1` -- src/ConfigContraband.Tool/ConfigContraband.Tool.csproj: `0.5.1` - -## Commits Since Base - -- none - -## Changed Files - -- none - -## Verification (none) - -Status: **not-run** - -- not run - -## Score Math Issues - -- none - -## Score Recommendations - -- none - -## Recommended Actions - -- Run at least targeted verification before increasing Test Depth, Fix Safety, or Release Readiness scores. diff --git a/.pi/analyzer-health/audit-2026-06-30T14-40-39-846Z.md b/.pi/analyzer-health/audit-2026-06-30T14-40-39-846Z.md deleted file mode 100644 index eb78773..0000000 --- a/.pi/analyzer-health/audit-2026-06-30T14-40-39-846Z.md +++ /dev/null @@ -1,45 +0,0 @@ -# Analyzer Health Audit Evidence - -- cwd: `/Users/georgewall/fiveFive/ConfiContraband` -- health file: `/Users/georgewall/fiveFive/ConfiContraband/analyzer-health.md` -- last refreshed: 2026-06-30 -- package version in health: 0.5.1 -- base audited commit: 08666fb -- HEAD: 0d0a773 - -## Package Versions - -- src/ConfigContraband/ConfigContraband.csproj: `0.5.1` -- src/ConfigContraband.Tool/ConfigContraband.Tool.csproj: `0.5.1` - -## Commits Since Base - -- 0d0a773 Add analyzer health iteration workflow -- 3615d16 Update analyzer health audit evidence - -## Changed Files - -- docs: 4 -- other: 1 - -## Verification (full) - -Status: **pass** - -- `dotnet test ConfigContraband.slnx --configuration Release`: PASS (7s) -- `dotnet format ConfigContraband.slnx --verify-no-changes --no-restore --verbosity minimal`: PASS (5s) -- `git diff --check`: PASS (0s) - -## Score Math Issues - -- none - -## Score Recommendations - -- Release Readiness: 1 -> 5 — Requested verification passed; release-readiness blocker appears resolved. - -## Recommended Actions - -- Update Base audited commit from 08666fb to 0d0a773 if this audit becomes the new baseline. -- Add an Improvement Changelog row summarizing the audited delta since the prior base. -- Apply score changes only with the evidence/reason listed in Score Recommendations. diff --git a/.pi/extensions/analyzer-health/README.md b/.pi/extensions/analyzer-health/README.md index 980902e..02b10d6 100644 --- a/.pi/extensions/analyzer-health/README.md +++ b/.pi/extensions/analyzer-health/README.md @@ -10,11 +10,11 @@ Project-local pi extension for making `analyzer-health.md` refreshes repeatable - Loads the report into the editor so it can be used as the evidence pack for updating `analyzer-health.md`. - `/analyzer-health-validate` - Checks weighted score math and whether `Base audited commit` is an ancestor of current `HEAD`. -- `/analyzer-health-iterate [--dry-run] [--base=main] [--verification=full|analyzer|codefix|core|none] [--no-pr] [--no-release] [--auto-merge]` - - Selects the highest-priority work from `Current Shortlist` (falling back to the lowest-score highest-priority Health Baseline row). +- `/analyzer-health-iterate [--dry-run] [--base=main] [--verification=full|analyzer|codefix|core|none] [--no-pr] [--release] [--auto-merge]` + - Selects the highest-priority work from a numbered `Current Shortlist` item. If the shortlist has no numbered item, it refuses to start (monitor-only) instead of fabricating a target from the lowest-scored Health Baseline row — future work must be driven by a reproducible runtime or documentation mismatch. - Requires a clean git tree and authenticated `gh`, updates the base branch, creates an `analyzer-health/...` branch, then sends pi a guarded implementation workflow. - - The workflow requires evidence collection, targeted/full verification, analyzer-health updates, release metadata preparation when releases are enabled, commit, PR, self-review, merge, and release tagging. - - When release tagging is enabled, the generated prompt tells the agent to infer the next semantic version before PR creation, update package versions, `CHANGELOG.md`, package release notes, and analyzer-health package/version notes where present, then rerun full verification. + - The workflow requires evidence collection, targeted/full verification, analyzer-health updates, commit, PR, self-review, and merge. Release tagging and release-metadata preparation are opt-in via `--release`. + - When `--release` is supplied, the generated prompt tells the agent to infer the next semantic version before PR creation, update package versions, `CHANGELOG.md`, package release notes, and analyzer-health package/version notes where present, then rerun full verification. Without `--release`, no versions, changelog, or release tags are touched. - The generated prompt tells the agent to use body files or single-quoted heredocs for GitHub CLI markdown bodies so backticks are not executed by the shell. - By default it pauses for explicit user confirmation before merge/tag; use `--auto-merge` only when you want unattended merge/release after checks pass. diff --git a/.pi/extensions/analyzer-health/index.ts b/.pi/extensions/analyzer-health/index.ts index b42db4f..e870cc6 100644 --- a/.pi/extensions/analyzer-health/index.ts +++ b/.pi/extensions/analyzer-health/index.ts @@ -75,7 +75,7 @@ interface AuditReport { interface HealthWorkItem { rank: number; title: string; - source: "current-shortlist" | "health-baseline"; + source: "current-shortlist"; rules: string[]; } @@ -176,6 +176,14 @@ export default function analyzerHealthExtension(pi: ExtensionAPI) { const options = parseIterationArgs(args); const workItem = await selectHighestPriorityWork(ctx.cwd); + if (!workItem) { + ctx.ui.notify( + "Monitor-only: the Current Shortlist has no numbered work item, so there is nothing to iterate. Add a reproducible runtime or documentation mismatch to the shortlist first.", + "warning", + ); + return; + } + const branchName = createBranchName(workItem); const prompt = buildIterationPrompt(workItem, branchName, options); @@ -205,7 +213,7 @@ export default function analyzerHealthExtension(pi: ExtensionAPI) { }); } -async function selectHighestPriorityWork(cwd: string): Promise { +async function selectHighestPriorityWork(cwd: string): Promise { const text = await readFile(join(cwd, "analyzer-health.md"), "utf8"); const shortlist = extractSection(text, "Current Shortlist"); const first = shortlist.split(/\r?\n/).map((line) => /^\s*(\d+)\.\s+(.+)$/.exec(line)).find(Boolean); @@ -218,16 +226,10 @@ async function selectHighestPriorityWork(cwd: string): Promise { }; } - const health = await parseHealth(cwd, "analyzer-health.md"); - const priorityOrder: Record = { P1: 1, P2: 2, P3: 3 }; - const sorted = [...health.rules].sort((a, b) => { - const priority = (priorityOrder[a.priority] ?? 99) - (priorityOrder[b.priority] ?? 99); - if (priority !== 0) return priority; - return a.score - b.score; - }); - const top = sorted[0]; - if (!top) throw new Error("No Current Shortlist item or Health Baseline rows found in analyzer-health.md."); - return { rank: 1, title: `${top.rule} (${top.priority}, score ${top.score.toFixed(2)})`, source: "health-baseline", rules: [top.rule.split(" ")[0]] }; + // Monitor-only: when the Current Shortlist has no numbered work item there is no actionable + // hardening target. Do not fabricate one from the lowest-scored Health Baseline rule — future + // work must be driven by a reproducible runtime or documentation mismatch. + return undefined; } async function prepareIterationBranch(pi: ExtensionAPI, cwd: string, baseBranch: string, branchName: string): Promise { @@ -253,18 +255,19 @@ async function prepareIterationBranch(pi: ExtensionAPI, cwd: string, baseBranch: } function parseIterationArgs(args: string): IterationArgs { - const options: IterationArgs = { - dryRun: false, - baseBranch: "main", - verification: "full", - autoMerge: false, - tagRelease: true, - pushPr: true, - }; - for (const part of args.trim().split(/\s+/).filter(Boolean)) { - if (part === "--dry-run") options.dryRun = true; - else if (part === "--auto-merge") options.autoMerge = true; - else if (part === "--no-release") options.tagRelease = false; + const options: IterationArgs = { + dryRun: false, + baseBranch: "main", + verification: "full", + autoMerge: false, + tagRelease: false, + pushPr: true, + }; + for (const part of args.trim().split(/\s+/).filter(Boolean)) { + if (part === "--dry-run") options.dryRun = true; + else if (part === "--auto-merge") options.autoMerge = true; + else if (part === "--release") options.tagRelease = true; + else if (part === "--no-release") options.tagRelease = false; else if (part === "--no-pr") options.pushPr = false; else if (part.startsWith("--base=")) options.baseBranch = part.slice("--base=".length) || options.baseBranch; else if (part.startsWith("--verification=")) { @@ -278,10 +281,10 @@ function parseIterationArgs(args: string): IterationArgs { function buildIterationPrompt(workItem: HealthWorkItem, branchName: string, options: IterationArgs): string { const releaseStep = options.tagRelease ? "After merge, tag the GitHub release version prepared in the PR. Do not tag if verification, release metadata, or PR checks fail." - : "Do not create a release tag for this run because --no-release was supplied."; + : "Do not create a release tag for this run because release tagging was not requested (pass --release to opt in)."; const releasePrepPolicy = options.tagRelease ? "Before opening the PR, prepare release metadata for the next semantic version inferred from project metadata/CHANGELOG: update package versions, CHANGELOG.md, package release notes, and analyzer-health.md package/version notes when those files exist. Then rerun full verification." - : "Do not change package versions, changelog, package release notes, or release-specific analyzer-health metadata because --no-release was supplied."; + : "Do not change package versions, changelog, package release notes, or release-specific analyzer-health metadata because release tagging was not requested (pass --release to opt in)."; const mergePolicy = options.autoMerge ? "If review and checks pass, merge the PR using gh without asking another confirmation." : "Before merging the PR or creating any release tag, pause and ask the user for explicit confirmation with the PR URL, checks status, release version, and verification evidence."; diff --git a/CHANGELOG.md b/CHANGELOG.md index c4a954f..e744791 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to ConfigContraband will be documented in this file. +## 0.7.22 - 2026-07-21 + +- Fixed an infinite-loop analyzer hang in the `CFG002` constructor-overwrite proof. A public + parameterless constructor whose initializer is a zero-argument `: this()` (a self-cycle that is a + `CS0516` compile error, but well-formed broken-compilation input the analyzer must survive) made + the runtime-constructor walk re-select the same constructor forever, hanging the analyzer for any + `OptionsBuilder` registration of such a type. The self-targeting zero-argument `: this()` is now + treated as an unprovable constructor chain — the satisfying default is conservatively rejected and + the missing required key reported — instead of spinning. No valid compiling program is affected + because self-delegating constructors are illegal. No diagnostic IDs, severities, messages, or code + fixes changed. + ## 0.7.21 - 2026-07-21 - Fixed a potential `AD0001` analyzer crash: `BindConfiguration(...)` registration diff --git a/CLAUDE.md b/CLAUDE.md index b68183d..e3f1843 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # ConfigContraband (aka ConfiContraband / LinqContraband) -Roslyn analyzer for .NET configuration/Options validation. Rule IDs are `CFG0xx` (CFG001–CFG007) — +Roslyn analyzer for .NET configuration/Options validation. Rule IDs are `CFG0xx` (CFG001–CFG009) — not `DI0xx`; that prefix belongs to a different project/skill template. - Source of truth for current rule health, precision/recall scores, and known gaps: diff --git a/README.md b/README.md index 71b2ac1..7dd1320 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Use it when your app relies on strongly typed options and you want configuration ## Install ```xml - + ``` 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. diff --git a/analyzer-health.md b/analyzer-health.md index c823639..7b9494f 100644 --- a/analyzer-health.md +++ b/analyzer-health.md @@ -3,8 +3,8 @@ 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-21 -Package version: `0.7.21` -Base audited commit: `c570161` +Package version: `0.7.22` +Base audited commit: `04fc74e` ## Scoring Rubric @@ -35,7 +35,7 @@ Priority means "next investment priority", not diagnostic severity: ## Current Posture -ConfigContraband has nine diagnostics (`CFG001`-`CFG009`), five code-fix families, and a shared configuration/options model consumed by both the analyzer package and `ConfigContraband.Tool`. Every rule is currently P3 / monitor in the Health Baseline; the Current Shortlist intentionally has no numbered work item, so the iteration automation falls back to the scored baseline instead of reselecting an accepted boundary. +ConfigContraband has nine diagnostics (`CFG001`-`CFG009`), five code-fix families, and a shared configuration/options model consumed by both the analyzer package and `ConfigContraband.Tool`. Every rule is currently P3 / monitor in the Health Baseline; the Current Shortlist intentionally has no numbered work item, so the iteration automation now refuses to start (monitor-only) rather than fabricating a target from the scored baseline — future work must be driven by a reproducible runtime or documentation mismatch. Supported options-registration coverage includes `BindConfiguration(...)`, explicit `Bind(GetSection(...))` / `Bind(GetRequiredSection(...))`, and direct `Configure(GetSection(...))` / `Configure(GetRequiredSection(...))` paths. The rule set checks missing sections and required keys, startup and DataAnnotations validation, recursive validation, loose and strict unknown keys, scalar conversion, and supported direct `IConfiguration` reads. Direct-read coverage includes signed-framework `GetRequiredSection(...)`, suggestion-gated `GetSection(...).Get()` / `Bind(...)`, keyed `Bind`, `GetConnectionString(...)`, generic `GetValue(...)`, and non-generic `GetValue(typeof(T), ...)` conversion checks. @@ -43,7 +43,7 @@ The analyzer discovers visible `appsettings.json` and dot-qualified `appsettings The live accepted boundaries are provider visibility/ordering, dynamic or origin-unknown stored-section paths, `CFG002` array-shaped defaults and non-file secrets, `CFG008` collection/dictionary element conversion, and `CFG009` configuration-indexer semantics. These are monitor items rather than active defects: future widening requires a reproducible runtime or documentation mismatch and a conservative test-first design. -Release readiness is healthy. The iteration-121 verifier passed 669 analyzer/test and 250 core tests (919 total), a zero-warning/zero-error Release build, and formatter verification; PR and post-merge CI, CodeQL, package, publish, and exact NuGet visibility gates remain required for every release. Historical findings, fixes, and score movements are retained below in the Improvement Changelog and per-rule notes rather than repeated in this current-state section. +Release readiness is healthy. The iteration-123 verifier passed 683 analyzer/test and 250 core tests (933 total), a zero-warning/zero-error CI-mode Release build (`ContinuousIntegrationBuild=true`), formatter verification, and `git diff --check`; PR and post-merge CI, CodeQL, package, publish, and exact NuGet visibility gates remain required for every release. Historical findings, fixes, and score movements are retained below in the Improvement Changelog and per-rule notes rather than repeated in this current-state section. ## Improvement Changelog @@ -172,6 +172,7 @@ Release readiness is healthy. The iteration-121 verifier passed 669 analyzer/tes | 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`. | +| 2026-07-21 | 123 | `CFG002` | The constructor-overwrite proof (`TryResolveRuntimeConstructorEffect`) walked the runtime constructor chain in a `while (true)` loop with no cycle guard. A public parameterless constructor whose initializer is a zero-argument `: this()` — a self-cycle that is a compile error (`CS0516`) but well-formed broken-compilation input the analyzer must survive — re-selected the same parameterless constructor via `SelectParameterlessConstructor` forever: the `Arguments.Count > 0` guard does not fire for zero arguments, an empty body classifies as a non-write, and the `ThisConstructorInitializer` branch looped without ever observing a cancellation token. Because `OptionsTypeMetadata.Create` runs for every recognized `OptionsBuilder` registration before any appsettings gating, the shape hung the analyzer (an uncancelable spin — worse than the recoverable `AD0001` class iteration 122 hardened), and no test covered any `: this()` shape. | Treated the self-targeting zero-argument `: this()` as unprovable: the `ThisConstructorInitializer` branch now compares the resolved parameterless target against the current constructor with `SymbolEqualityComparer.Default` and returns `false` (so the satisfying initializer is conservatively rejected and the missing required key is reported) instead of re-selecting the same constructor; the base-chain walk stays monotonic, the fix allocates nothing on the property-level hot path, and no valid compiling program is affected because self-delegating constructors are illegal. Added a broken-compilation RED regression (a parameterless `: this()` plus a satisfying initializer now reports `CFG002` instead of hanging, run under a new compiler-error-tolerant verifier overload because the shape is `CS0516`), a compiling parameterized-`: this()`-to-parameterless guard proving valid zero-argument delegation still stays quiet, and a parameterized-`: this()`-with-no-parameterless-target regression (`CS1729` broken code) covering the null-target fall-through. | `CFG002` stays `4.75` / P3: a robustness/hang fix on broken-compilation input rather than a precision change, so no score math changes; Test Depth evidence strengthens around the constructor-proof boundary. Full suite 683 analyzer/test + 250 core (933 total) passed. Patch bump to `0.7.22`. | ## Health Baseline @@ -248,11 +249,13 @@ Current behavior: - Recurses into nested objects and collection elements only where Options validation walks: `[ValidateObjectMembers]` and `[ValidateEnumeratedItems]`, including provably initialized nested objects whose section is missing entirely (null members and empty collection defaults are skipped, matching runtime validation; unprovable defaults keep the parent required without speculative child diagnostics). Dictionary values stay quiet because recursive Options validation attributes do not validate dictionary values. - Stays quiet when the bound section itself is missing so `CFG001` carries the signal alone. - Shares its required-key contract with the generated `appsettings.schema.json` `required` array through the same metadata flag. +- Treats a self-targeting zero-argument `: this()` constructor initializer (a `CS0516` self-cycle — broken-compilation input the analyzer must survive) as an unprovable constructor chain instead of looping forever: the satisfying default is conservatively rejected and the missing required key reported. Fixed 2026-07-21 (iteration 123, see Improvement Changelog). Known gaps: - Required values supplied only through environment variables, user secrets, or other non-JSON providers are invisible to the analyzer and remain the documented residual false-positive source. - **Fixed 2026-07-06 (iteration 96) — subclass false negative**: `IsRequired()` treated a property as required only when an attribute's `ToDisplayString()` exactly equalled `System.ComponentModel.DataAnnotations.RequiredAttribute`, so a property annotated with a user-defined `RequiredAttribute` *subclass* (e.g. `class MyRequiredAttribute : RequiredAttribute { }`, no weakening `IsValid` override) yielded `IsRequired=false` and CFG002 never fired, even though `Validator.TryValidateObject` enforces the inherited attribute and throws at runtime. Fixed by `IsRequiredAttribute`, which walks the attribute's base chain: it matches `RequiredAttribute` itself or any subclass that does not override `IsValid`, and stays conservative (not required) for a subclass that overrides `IsValid` between itself and `RequiredAttribute`, since such an override may weaken the check to accept a missing value. Regression tests: `Cfg002_reports_missing_key_for_user_defined_required_attribute_subclass` (fires) and `Cfg002_stays_quiet_for_required_attribute_subclass_overriding_is_valid` (conservative). +- **Fixed 2026-07-21 (iteration 123) — constructor self-cycle hang**: `TryResolveRuntimeConstructorEffect` walked the runtime constructor chain in an unguarded `while (true)`. A public parameterless constructor with a zero-argument `: this()` initializer (a `CS0516` self-cycle, but well-formed broken-compilation input) re-selected the same parameterless constructor forever — the `Arguments.Count > 0` guard skips zero arguments, an empty body classifies as a non-write, and the `ThisConstructorInitializer` branch looped with no visited set and no cancellation-token check — hanging the analyzer for any `OptionsBuilder` registration of such a type (metadata is built before any appsettings gating). Fixed by comparing the resolved parameterless target against the current constructor (`SymbolEqualityComparer.Default`) and returning `false` (unprovable) on a self-cycle, so the satisfying initializer is conservatively rejected and `CFG002` reports instead of spinning. No valid compiling program is affected because self-delegating constructors are illegal. Regression tests: `Cfg002_reports_required_with_satisfying_initializer_when_parameterless_constructor_chains_to_itself` (the former hang, now reports, run under a compiler-error-tolerant verifier), `Cfg002_stays_quiet_for_required_when_parameterized_constructor_chains_to_parameterless` (valid zero-argument delegation guard), and `Cfg002_reports_required_when_parameterized_constructor_chains_to_this_without_parameterless_target` (the null-target fall-through on `CS1729` broken code). - `[Required]` collection and array properties default-initialized with any array-creation expression (`= []`, `= new[] { ... }`, `= new string[0]`, `= new string[] { ... }`) still report, even though `RequiredAttribute` accepts any non-null non-string value: `InitializerDefinitelySatisfiesRequired` only handles `ObjectCreationExpressionSyntax`/`ImplicitObjectCreationExpressionSyntax`, with no `ArrayCreationExpressionSyntax` or collection-expression branch, so every array-shaped default stays on the conservative reporting path regardless of explicit, implicit, or collection-expression syntax. This narrow widening was reviewed and deliberately declined to keep that boundary closed; revisit only on real-world reports. Non-array collection/dictionary properties default-initialized via object-creation syntax (`= new List()`, `= new()`) already satisfy the existing object-creation-initializer suppression and do not report today — that was not an intentional collection-specific design decision, but the shape is now pinned by regression tests (2026-07-01, see Improvement Changelog) so it can't silently regress unnoticed. - No automatic fix because the missing key lives in JSON additional files. @@ -436,8 +439,8 @@ dotnet format ConfigContraband.slnx --verify-no-changes --no-restore --verbosity git diff --check ``` -Last executed 2026-07-16 verifier status: 669 analyzer/test and 250 core tests passed (919 total), the Release build completed with zero warnings and zero errors, and formatter verification passed for iteration 121. Local git commands were unavailable under the active execution policy, so this run does not claim `git diff --check`; the ready PR's CI diff and package gates remain authoritative for that remote slice. The prior Roslyn analyzer-testing reference-assembly resolution failure remains resolved and is not an active release-readiness blocker. +Last executed 2026-07-21 verifier status: 683 analyzer/test and 250 core tests passed (933 total), the CI-mode Release build (`ContinuousIntegrationBuild=true`) completed with zero warnings and zero errors, formatter verification passed, and `git diff --check` was clean for iteration 123. The prior Roslyn analyzer-testing reference-assembly resolution failure remains resolved and is not an active release-readiness blocker. -Latest follow-up audit evidence on 2026-07-16 reported verification status `pass`, with both package projects at `0.7.20` and no score-math issue. Release Readiness remains `5`; rule severity and score math are otherwise unchanged. +Latest follow-up audit evidence on 2026-07-21 reported verification status `pass`, with both package projects at `0.7.22` and no score-math issue. Release Readiness remains `5`; rule severity and score math are otherwise unchanged. CI verification is defined in `.github/workflows/ci.yml` and runs restore, build, formatting verification, test with coverage, pack, test-result upload, and package artifact upload against the SDK from `global.json`. Release publication is defined in `.github/workflows/publish.yml` and now runs the same formatting verification before tests, pack, NuGet push, and release asset upload. diff --git a/src/ConfigContraband.Core/OptionsTypeMetadata.ConstructorProofs.cs b/src/ConfigContraband.Core/OptionsTypeMetadata.ConstructorProofs.cs index 3ca74db..d63bb5f 100644 --- a/src/ConfigContraband.Core/OptionsTypeMetadata.ConstructorProofs.cs +++ b/src/ConfigContraband.Core/OptionsTypeMetadata.ConstructorProofs.cs @@ -90,7 +90,16 @@ private static bool TryResolveRuntimeConstructorEffect( if (declaration.Initializer?.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.ThisConstructorInitializer) == true) { - constructor = SelectParameterlessConstructor(constructor.ContainingType); + var chainedConstructor = SelectParameterlessConstructor(constructor.ContainingType); + if (SymbolEqualityComparer.Default.Equals(chainedConstructor, constructor)) + { + // A parameterless constructor's `: this()` can only target itself (CS0516, + // a self-cycle); treat it as unprovable instead of re-selecting the same + // constructor and looping forever. + return false; + } + + constructor = chainedConstructor; continue; } } diff --git a/src/ConfigContraband.Tool/ConfigContraband.Tool.csproj b/src/ConfigContraband.Tool/ConfigContraband.Tool.csproj index 9fe9d13..48c2d29 100644 --- a/src/ConfigContraband.Tool/ConfigContraband.Tool.csproj +++ b/src/ConfigContraband.Tool/ConfigContraband.Tool.csproj @@ -18,7 +18,7 @@ true configcontraband ConfigContraband.Tool - 0.7.21 + 0.7.22 George Wall ConfigContraband.Tool Generates appsettings.schema.json from your .NET Options types so editors give live autocomplete, type checking, and required-key hints while editing appsettings.json. diff --git a/src/ConfigContraband/ConfigContraband.csproj b/src/ConfigContraband/ConfigContraband.csproj index 090c43b..81267eb 100644 --- a/src/ConfigContraband/ConfigContraband.csproj +++ b/src/ConfigContraband/ConfigContraband.csproj @@ -14,7 +14,7 @@ ConfigContraband - 0.7.21 + 0.7.22 George Wall ConfigContraband Stop smuggling broken appsettings into production. ConfigContraband is a Roslyn analyzer for .NET Options and configuration correctness. @@ -25,7 +25,7 @@ https://github.com/georgepwall1991/ConfigContraband git https://github.com/georgepwall1991/ConfigContraband - Hardening release: fixed a potential AD0001 analyzer crash in BindConfiguration registration parsing, enabled the CI analyzer gate and triaged every build warning, memoized per-compilation options metadata, bounded appsettings JSON nesting depth, removed dead code, and completed the showcase to cover all nine rules. No diagnostic behavior changes. + Robustness release: fixed an infinite-loop analyzer hang in the CFG002 constructor-overwrite proof triggered by a self-cycling parameterless constructor (a zero-argument :this() initializer, CS0516 broken-compilation input), which now resolves as an unprovable constructor chain and reports conservatively instead of spinning. No diagnostic IDs, severities, messages, or code fixes changed. true false true diff --git a/tests/ConfigContraband.Tests/Cfg002ConstructorDefaultsAnalyzerTests.cs b/tests/ConfigContraband.Tests/Cfg002ConstructorDefaultsAnalyzerTests.cs index e03ef61..f6f3133 100644 --- a/tests/ConfigContraband.Tests/Cfg002ConstructorDefaultsAnalyzerTests.cs +++ b/tests/ConfigContraband.Tests/Cfg002ConstructorDefaultsAnalyzerTests.cs @@ -679,4 +679,125 @@ await Verifier.VerifyAnalyzerAsync( """), expected); } + + [Fact] + public async Task Cfg002_reports_required_with_satisfying_initializer_when_parameterless_constructor_chains_to_itself() + { + // A parameterless constructor that calls itself (`: this()`, CS0516) is broken code the + // analyzer must survive without hanging. The self-cycling constructor chain is unprovable, + // so the satisfying initializer is treated conservatively and the missing required key is + // reported. + var source = OptionsSource( + registration: """ + services.AddOptions() + .BindConfiguration({|#0:"Required"|}) + .ValidateDataAnnotations() + .ValidateOnStart(); + """, + optionsTypes: """ + public sealed class RequiredDefaultOptions + { + public RequiredDefaultOptions() : this() + { + } + + [Required] + public string ApiKey { get; set; } = "sk_default"; + } + """); + + var expected = Verifier.Diagnostic(DiagnosticDescriptors.MissingRequiredConfigurationKey) + .WithLocation(0) + .WithArguments("ApiKey", "Required"); + + await Verifier.VerifyAnalyzerAllowingCompilerErrorsAsync( + source, + ("appsettings.json", """ + { + "Required": { + } + } + """), + expected); + } + + [Fact] + public async Task Cfg002_stays_quiet_for_required_when_parameterized_constructor_chains_to_parameterless() + { + // A compiling zero-argument `: this()` delegation (a parameterized constructor chaining to + // a parameterless one) must keep working: the runtime-selected parameterless constructor + // has a clean empty body, so the satisfying initializer survives and no diagnostic fires. + var source = OptionsSource( + registration: """ + services.AddOptions() + .BindConfiguration("Required") + .ValidateDataAnnotations() + .ValidateOnStart(); + """, + optionsTypes: """ + public sealed class RequiredDefaultOptions + { + public RequiredDefaultOptions() + { + } + + public RequiredDefaultOptions(int port) : this() + { + } + + [Required] + public string ApiKey { get; set; } = "sk_default"; + } + """); + + await Verifier.VerifyAnalyzerAsync( + source, + ("appsettings.json", """ + { + "Required": { + } + } + """)); + } + + [Fact] + public async Task Cfg002_reports_required_when_parameterized_constructor_chains_to_this_without_parameterless_target() + { + // A parameterized constructor whose `: this()` has no parameterless target (CS1729) is + // broken code the analyzer must survive. The chain resolves to a null parameterless + // constructor and falls through to the base walk, which returns unprovable, so the + // satisfying initializer is conservatively rejected and the missing required key reported. + var source = OptionsSource( + registration: """ + services.AddOptions() + .BindConfiguration({|#0:"Required"|}) + .ValidateDataAnnotations() + .ValidateOnStart(); + """, + optionsTypes: """ + public sealed class RequiredDefaultOptions + { + public RequiredDefaultOptions(int port) : this() + { + } + + [Required] + public string ApiKey { get; set; } = "sk_default"; + } + """); + + var expected = Verifier.Diagnostic(DiagnosticDescriptors.MissingRequiredConfigurationKey) + .WithLocation(0) + .WithArguments("ApiKey", "Required"); + + await Verifier.VerifyAnalyzerAllowingCompilerErrorsAsync( + source, + ("appsettings.json", """ + { + "Required": { + } + } + """), + expected); + } } diff --git a/tests/ConfigContraband.Tests/Infrastructure/Verifier.cs b/tests/ConfigContraband.Tests/Infrastructure/Verifier.cs index 7b64839..db6700c 100644 --- a/tests/ConfigContraband.Tests/Infrastructure/Verifier.cs +++ b/tests/ConfigContraband.Tests/Infrastructure/Verifier.cs @@ -58,6 +58,18 @@ public static async Task VerifyAnalyzerAsync( await test.RunAsync(); } + public static async Task VerifyAnalyzerAllowingCompilerErrorsAsync( + string source, + (string filename, string content) additionalFile, + params DiagnosticResult[] expected) + { + var test = CreateAnalyzerTest(source); + test.CompilerDiagnostics = CompilerDiagnostics.None; + test.TestState.AdditionalFiles.Add(additionalFile); + test.ExpectedDiagnostics.AddRange(expected); + await test.RunAsync(); + } + public static async Task VerifyAnalyzerWithReferencesAsync( string source, (string filename, string content) additionalFile,