agoda-com / agoda-com/AgodaAnalyzers
AG0051: Reduce false positives — suppress past dates, sentinels, frozen clocks, and copy-through assertions
- Lingua principale
- C#
- Stelle
- 25
- Fork
- 15
- Metriche di merge delle PR
- Nessuna PR unita negli ultimi 30g
Descrizione
Issue title
AG0051: Reduce false positives — suppress past dates, sentinels, frozen clocks, and copy-through assertions
Issue body
Summary
AG0051DetectHardcodedDateLiterals currently flags any new DateTime(...)/new DateTimeOffset(...) with all-literal (y, m, d) args and year >= 2020, and any DateTime.Parse("YYYY-MM-DD..."), inside a test context. It never inspects how the value is used, whether the test clock is frozen, or whether the date has already passed.
A triage of a real result set (922 warnings, 106 files, 5 repositories) against a strict time-bomb definition — a test that depends on the real current date internally and has fixed fixture dates that will cross a threshold as wall-clock time advances — found that none of the sampled warnings were confirmed time bombs. The dominant false-positive shapes were:
- Fixture dates already years in the past (deterministic DTO/mapper coverage)
- Explicitly frozen clocks (
FakeTimeProvider, mockedNow/GetUtcNow()) - Copy-through / equality-assertion fixtures (
Prop = new DateTime(...)…result.Prop.ShouldBe(new DateTime(...))) - Open-ended sentinel dates (
9999-12-31,2999-12-31) - Relative-ordering validation where dates are only compared to each other
- Pure formatter/parser tests (fixed date in → fixed string out)
This issue proposes four analyzer changes (P1–P4) that eliminate categories 1–4 and most of 6, plus a confidence-tier mechanism (P5) so downstream SCA tooling can measure the redistribution before anything is deleted.
The rule must continue to flag the genuine shape:
// Service reads the real wall clock internally.
var service = new BookingEligibilityService();
var offer = new Offer
{
StartDate = new DateTime(2026, 12, 1), // intended to be "future"
EndDate = new DateTime(2026, 12, 31)
};
service.IsBookable(offer).ShouldBeTrue(); // flips to red after 2026-12-31
P1 — Suppress dates already in the past relative to analysis time
A date that has already passed cannot be a future time bomb: if it were going to flip the test, it already would have. This alone removes the largest false-positive category (historical mock/fixture data such as new DateTime(2020, 12, 21), new DateTime(2021, 1, 1)).
// Replace:
// private const int SafeYearThreshold = 2020;
private static readonly DateTime AnalysisDate = DateTime.UtcNow.Date;
private static bool IsPastDate(int year, int month, int day)
{
if (month < 1 || month > 12 || day < 1 || day > 31) return false;
// Suppress only when the whole month has already elapsed (small grace buffer).
return new DateTime(year, Math.Max(1, month), 1).AddMonths(1) < AnalysisDate;
}
In AnalyzeObjectCreation, the month/day literals are already available in the first three arguments; extract them and bail out when IsPastDate returns true. In AnalyzeInvocation, use DateTime.TryParse on the matched string instead of only extracting the year.
Optionally keep a configurable floor via .editorconfig (e.g. dotnet_diagnostic.AG0051.min_year) for teams that want the old fixed-threshold behavior.
Performance impact: zero — integer comparisons on values already extracted.
Feasibility: trivial.
Trade-off to document: diagnostics become non-deterministic across calendar time — the same commit can be clean in December and warn in January. That is philosophically what the rule is about, but it should be stated in doc/AG0051.md because it can surprise build caching and "who broke main" triage. Computing AnalysisDate once per process (static readonly) keeps a single build self-consistent.
P2 — Suppress open-ended sentinel dates
9999-12-31 / 2999-12-31 / DateTime.MaxValue-adjacent values are domain sentinels for "no end date", not dates that will expire during the test's lifetime.
private static bool IsSentinelDate(int year) => year >= 2999;
One guard in both analyze paths.
Performance impact: zero.
Feasibility: trivial. Highest-precision change in the set — no one writes new DateTime(9999, 12, 31) intending "next quarter".
P3 — Suppress when the test class freezes the clock
If the class freezes time (FakeTimeProvider, SetUtcNow, NSubstitute xxx.Now.Returns(...), Moq Setup(x => x.Now)), fixture dates live in the fake timeline, not the wall clock.
Restructure to RegisterSymbolStartAction (available on the referenced Roslyn 4.5): collect candidate diagnostics and freeze evidence in one class walk; report at SymbolEnd only if no freeze was found.
context.RegisterSymbolStartAction(symbolContext =>
{
var state = new ClassAnalysisState(); // candidate diagnostics + HasFrozenClock flag
symbolContext.RegisterSyntaxNodeAction(c => CollectDateLiteral(c, state),
SyntaxKind.ObjectCreationExpression, SyntaxKind.InvocationExpression);
symbolContext.RegisterSyntaxNodeAction(c => DetectClockFreeze(c, state),
SyntaxKind.ObjectCreationExpression, SyntaxKind.InvocationExpression);
symbolContext.RegisterSymbolEndAction(c =>
{
if (!state.HasFrozenClock)
foreach (var d in state.Candidates) c.ReportDiagnostic(d);
});
}, SymbolKind.NamedType);
Syntactic freeze heuristic (no semantic calls needed for a first cut):
private static bool IsClockFreeze(SyntaxNode node) => node switch
{
ObjectCreationExpressionSyntax o
when o.Type.ToString().Contains("FakeTimeProvider") => true,
InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax m }
when m.Name.Identifier.Text is "SetUtcNow" or "SetLocalTimeZone" => true,
// NSubstitute: provider.Now.Returns(...), provider.GetUtcNow().Returns(...)
InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax { Name.Identifier.Text: "Returns" } m2 }
when m2.Expression.ToString() is var t &&
(t.EndsWith(".Now") || t.EndsWith(".UtcNow") ||
t.EndsWith(".Today") || t.EndsWith("GetUtcNow()")) => true,
_ => false
};
Optionally confirm FakeTimeProvider resolves to Microsoft.Extensions.Time.Testing.FakeTimeProvider with one GetSymbolInfo per candidate.
Performance impact: moderate but acceptable — one extra pattern match per creation/invocation node, still linear in class size. Symbol-start/end analyzers have coarser incremental behavior in the IDE (a keystroke re-analyzes the whole class); acceptable for a test-only rule, and CI cost is unchanged. Side benefit: IsInTestContext currently performs semantic attribute lookups on every node; the symbol-start restructure allows caching it once per class, which likely nets out as a perf improvement.
Feasibility: high for the heuristic version.
Known trade-off: class-level suppression is coarse — one frozen test method suppresses warnings in an unfrozen sibling method. A follow-up refinement is method-level detection first (containing method body), falling back to [SetUp]/constructor/field initializers at class level. Ship coarse first.
P4 — Demote copy-through / equality-assertion usage, keep risky property names flagged
If the literal's only role is "assigned into a mock/expected DTO with a non-risky property name" or "argument to an assertion", it is data-shape coverage, not a wall-clock threshold. This is a bounded, purely syntactic walk up creation.Parent.
private static readonly HashSet<string> AssertionMethods = new()
{ "ShouldBe", "ShouldBeEquivalentTo", "AreEqual", "Equal", "Be", "BeEquivalentTo" };
private static readonly HashSet<string> RiskyPropertyNames = new(StringComparer.OrdinalIgnoreCase)
{ "StartDate", "EndDate", "From", "To", "Expiry", "Expiration", "ExpiresAt",
"CheckIn", "CheckOut", "CheckInDate", "CheckOutDate", "ValidFrom", "ValidTo",
"PayableDate", "FirstLiveDate", "EffectiveDate" };
private static bool IsLowRiskUsage(SyntaxNode creation)
{
switch (creation.Parent)
{
// Prop = new DateTime(...) inside an object initializer
case AssignmentExpressionSyntax { Parent: InitializerExpressionSyntax } assign
when assign.Left is IdentifierNameSyntax id:
return !RiskyPropertyNames.Contains(id.Identifier.Text);
// x.ShouldBe(new DateTime(...)) / Assert.AreEqual(new DateTime(...), ...)
case ArgumentSyntax { Parent.Parent: InvocationExpressionSyntax inv }
when GetInvokedName(inv) is string name && AssertionMethods.Contains(name):
return true;
}
return false;
}
The asymmetry is deliberate: dates assigned to StartDate/CheckIn/Expiry-style properties stay flagged even inside initializers, because that is exactly the shape that feeds wall-clock logic (IsBookable, expiry rules, current-year checks). Dates assigned to arbitrary DTO properties, or sitting inside ShouldBe(...), are suppressed or demoted.
Performance impact: negligible — bounded parent walk with string comparisons, no semantic model.
Feasibility: high.
Known trade-off: false negatives when a blandly-named property (Date = new DateTime(2022, 1, 1)) flows into current-time logic. Combined with P1, most such cases are already-past dates, so the layers compound.
P5 — Emit confidence in the diagnostic properties bag
Rather than hard-suppressing the P4 cases, emit a confidence marker downstream tooling can filter on. The plumbing already exists (KEY_TECH_DEBT_IN_MINUTES ships through Properties today).
var props = Properties.Add("confidence", confidence); // "high" | "low"
context.ReportDiagnostic(Diagnostic.Create(Rule, location, props));
Keep only high-confidence at Warning severity in the IDE (or split into two diagnostic IDs for per-tier .editorconfig control). This de-risks rollout: the redistribution of existing warnings across tiers can be measured on a fresh scan before any behavior is removed.
Performance impact: zero. Feasibility: trivial.
Explicitly out of scope (and why)
"Does production code reachable from the test read the wall clock?" — the highest-value signal, but not feasible inside a Roslyn analyzer: the test compilation references the SUT as a compiled assembly, so metadata symbols are visible but method bodies are not, and Roslyn has no IL inspection. service.IsBookable(offer) is opaque from the test project. This belongs in out-of-band, whole-repo SCA tooling; the analyzer's proxy is the RiskyPropertyNames list in P4.
Local data-flow cases — "dates only compared to each other" (invalid-range validation tests) and "all dates derived from one fixed base date" need RegisterOperationAction + DataFlowAnalysis. Doable but the most complex and slowest piece, and these fixtures age into P1's past-date suppression over time. Defer.
Expected impact on the triage set
False-positive pattern | Eliminated by
-- | --
Historical mock/DTO fixture dates (2020–2021) | P1
Current-year logic with already-past fixture dates | P1
Formatter test with fixed 2024 input | P1
Sentinel 9999-12-31 ranges | P2
NSubstitute frozen Now | P3
FakeTimeProvider.SetUtcNow | P3
DTO copy-through with ShouldBe round-trip | P4
expected*Date modification-date pass-through | P4
Relative-ordering validation (from > to) | deferred (data-flow)
Fixed base date + AddHours offsets | deferred (data-flow; ages into P1)
The genuine time-bomb shape (future date → risky property → unfrozen clock → active/bookable assertion) survives every filter.
Implementation checklist
- [ ] P1: past-date suppression with process-stable
AnalysisDate;TryParsefull date in theParsepath; optional.editorconfigfloor - [ ] P2: sentinel-year guard (
year >= 2999) - [ ] P4:
IsLowRiskUsageparent walk +RiskyPropertyNameskeep-list - [ ] P5:
confidenceproperty on reported diagnostics - [ ] Unit tests in
AG0051UnitTestsstyle for each suppression and each keep-flagging case (risky property name, future date, unfrozen clock) - [ ] Update
doc/AG0051.md: new suppression rules + non-determinism note - [ ] Follow-up PR — P3: symbol-start restructure with frozen-clock detection (and cached
IsInTestContext)
Guida per i contributori
Apri la guida per i contributori
Valutazione
Questa issue non è ancora stata valutata.