Compare commits
	
		
			2 Commits
		
	
	
		
			0f1b203fde
			...
			ea8226120c
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| ea8226120c | |||
| 4829b26c53 | 
@@ -64,7 +64,7 @@
 | 
				
			|||||||
| Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Nvd/TASKS.md | DONE (2025-10-12) | Team Connector Expansion – GHSA/NVD/OSV | FEEDCONN-NVD-02-005 | NVD merge/export parity regression |
 | 
					| Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Nvd/TASKS.md | DONE (2025-10-12) | Team Connector Expansion – GHSA/NVD/OSV | FEEDCONN-NVD-02-005 | NVD merge/export parity regression |
 | 
				
			||||||
| Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Osv/TASKS.md | DONE (2025-10-11) | Team Connector Expansion – GHSA/NVD/OSV | FEEDCONN-OSV-02-003 | OSV normalized versions & freshness |
 | 
					| Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Osv/TASKS.md | DONE (2025-10-11) | Team Connector Expansion – GHSA/NVD/OSV | FEEDCONN-OSV-02-003 | OSV normalized versions & freshness |
 | 
				
			||||||
| Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Osv/TASKS.md | DONE (2025-10-11) | Team Connector Expansion – GHSA/NVD/OSV | FEEDCONN-OSV-02-004 | OSV references & credits alignment |
 | 
					| Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Osv/TASKS.md | DONE (2025-10-11) | Team Connector Expansion – GHSA/NVD/OSV | FEEDCONN-OSV-02-004 | OSV references & credits alignment |
 | 
				
			||||||
| Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Osv/TASKS.md | TODO | Team Connector Expansion – GHSA/NVD/OSV | FEEDCONN-OSV-02-005 | Fixture updater workflow<br>Instructions to work:<br>Read ./AGENTS.md and respective module AGENTS. Implement builder integration, provenance, and supporting docs using ./src/FASTER_MODELING_AND_NORMALIZATION.md and ensure outputs satisfy the precedence matrix in ./src/DEDUP_CONFLICTS_RESOLUTION_ALGO.md. |
 | 
					| Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Osv/TASKS.md | DONE (2025-10-12) | Team Connector Expansion – GHSA/NVD/OSV | FEEDCONN-OSV-02-005 | Fixture updater workflow<br>Resolved 2025-10-12: OSV mapper now derives canonical PURLs for Go + scoped npm packages when raw payloads omit `purl`; conflict fixtures unchanged for invalid npm names. Verified via `dotnet test src/StellaOps.Feedser.Source.Osv.Tests`, `src/StellaOps.Feedser.Source.Ghsa.Tests`, `src/StellaOps.Feedser.Source.Nvd.Tests`, and backbone normalization/storage suites. |
 | 
				
			||||||
| Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Acsc/TASKS.md | Implementation DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-ACSC-02-001 … 02-008 | Fetch pipeline, DTO parser, canonical mapper, fixtures, and README shipped 2025-10-12; downstream export integration still pending future tasks. |
 | 
					| Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Acsc/TASKS.md | Implementation DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-ACSC-02-001 … 02-008 | Fetch pipeline, DTO parser, canonical mapper, fixtures, and README shipped 2025-10-12; downstream export integration still pending future tasks. |
 | 
				
			||||||
| Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Cccs/TASKS.md | Research DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-CCCS-02-001 … 02-007 | Atom feed verified 2025-10-11, history/caching review and FR locale enumeration pending. |
 | 
					| Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Cccs/TASKS.md | Research DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-CCCS-02-001 … 02-007 | Atom feed verified 2025-10-11, history/caching review and FR locale enumeration pending. |
 | 
				
			||||||
| Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.CertBund/TASKS.md | Research DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-CERTBUND-02-001 … 02-007 | BSI RSS directory confirmed CERT-Bund feed 2025-10-11, history assessment pending. |
 | 
					| Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.CertBund/TASKS.md | Research DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-CERTBUND-02-001 … 02-007 | BSI RSS directory confirmed CERT-Bund feed 2025-10-11, history assessment pending. |
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,6 +13,9 @@ fixture sets, where they live, and how to regenerate them safely.
 | 
				
			|||||||
- **Regeneration:** Either run the test harness with online regeneration (`UPDATE_PARITY_FIXTURES=1 dotnet test src/StellaOps.Feedser.Source.Osv.Tests/StellaOps.Feedser.Source.Osv.Tests.csproj`)
 | 
					- **Regeneration:** Either run the test harness with online regeneration (`UPDATE_PARITY_FIXTURES=1 dotnet test src/StellaOps.Feedser.Source.Osv.Tests/StellaOps.Feedser.Source.Osv.Tests.csproj`)
 | 
				
			||||||
  or execute the fixture updater (`dotnet run --project tools/FixtureUpdater/FixtureUpdater.csproj`). Both paths
 | 
					  or execute the fixture updater (`dotnet run --project tools/FixtureUpdater/FixtureUpdater.csproj`). Both paths
 | 
				
			||||||
  normalise timestamps and canonical ordering.
 | 
					  normalise timestamps and canonical ordering.
 | 
				
			||||||
 | 
					- **SemVer provenance:** The regenerated fixtures should show `normalizedVersions[].notes` in the
 | 
				
			||||||
 | 
					  `osv:{ecosystem}:{advisoryId}:{identifier}` shape emitted by `SemVerRangeRuleBuilder`. Confirm the
 | 
				
			||||||
 | 
					  constraints and notes line up with GHSA/NVD composites before committing.
 | 
				
			||||||
- **Verification:** Inspect the diff, then re-run `dotnet test src/StellaOps.Feedser.Source.Osv.Tests/StellaOps.Feedser.Source.Osv.Tests.csproj` to confirm parity.
 | 
					- **Verification:** Inspect the diff, then re-run `dotnet test src/StellaOps.Feedser.Source.Osv.Tests/StellaOps.Feedser.Source.Osv.Tests.csproj` to confirm parity.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## GHSA credit parity fixtures
 | 
					## GHSA credit parity fixtures
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -10,7 +10,7 @@ This dashboard tracks connector readiness for emitting `AffectedPackage.Normaliz
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
## Key milestones
 | 
					## Key milestones
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- **2025-10-13** – Normalization to finalize `SemVerRangeRuleBuilder` API contract for review.
 | 
					- **2025-10-12** – Normalization finalized `SemVerRangeRuleBuilder` API contract (multi-segment comparators + notes), connector review opens.
 | 
				
			||||||
- **2025-10-17** – Connector owners to post fixture PRs showing `NormalizedVersions` arrays (even if feature-flagged).
 | 
					- **2025-10-17** – Connector owners to post fixture PRs showing `NormalizedVersions` arrays (even if feature-flagged).
 | 
				
			||||||
- **2025-10-18** – Merge cross-connector review to validate consistent field usage before enabling union logic.
 | 
					- **2025-10-18** – Merge cross-connector review to validate consistent field usage before enabling union logic.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,20 +13,25 @@ The connector now surfaces rate-limit headers on every fetch and exposes the fol
 | 
				
			|||||||
| `ghsa.ratelimit.limit` (histogram) | Samples the reported request quota at fetch time. | `phase` = `list` or `detail`, `resource` (e.g., `core`). |
 | 
					| `ghsa.ratelimit.limit` (histogram) | Samples the reported request quota at fetch time. | `phase` = `list` or `detail`, `resource` (e.g., `core`). |
 | 
				
			||||||
| `ghsa.ratelimit.remaining` (histogram) | Remaining requests returned by `X-RateLimit-Remaining`. | `phase`, `resource`. |
 | 
					| `ghsa.ratelimit.remaining` (histogram) | Remaining requests returned by `X-RateLimit-Remaining`. | `phase`, `resource`. |
 | 
				
			||||||
| `ghsa.ratelimit.reset_seconds` (histogram) | Seconds until `X-RateLimit-Reset`. | `phase`, `resource`. |
 | 
					| `ghsa.ratelimit.reset_seconds` (histogram) | Seconds until `X-RateLimit-Reset`. | `phase`, `resource`. |
 | 
				
			||||||
 | 
					| `ghsa.ratelimit.headroom_pct` (histogram) | Percentage of the quota still available (`remaining / limit * 100`). | `phase`, `resource`. |
 | 
				
			||||||
 | 
					| `ghsa.ratelimit.headroom_pct_current` (observable gauge) | Latest headroom percentage reported per resource. | `phase`, `resource`. |
 | 
				
			||||||
| `ghsa.ratelimit.exhausted` (counter) | Incremented whenever GitHub returns a zero remaining quota and the connector delays before retrying. | `phase`. |
 | 
					| `ghsa.ratelimit.exhausted` (counter) | Incremented whenever GitHub returns a zero remaining quota and the connector delays before retrying. | `phase`. |
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Dashboards & alerts
 | 
					### Dashboards & alerts
 | 
				
			||||||
- Plot `ghsa.ratelimit.remaining` as the latest value to watch the runway. Alert when the value stays below **`RateLimitWarningThreshold`** (default `500`) for more than 5 minutes.
 | 
					- Plot `ghsa.ratelimit.remaining` as the latest value to watch the runway. Alert when the value stays below **`RateLimitWarningThreshold`** (default `500`) for more than 5 minutes.
 | 
				
			||||||
 | 
					- Use `ghsa.ratelimit.headroom_pct_current` to visualise remaining quota % — paging once it sits below **10 %** for longer than a single reset window helps avoid secondary limits.
 | 
				
			||||||
- Raise a separate alert on `increase(ghsa.ratelimit.exhausted[15m]) > 0` to catch hard throttles.
 | 
					- Raise a separate alert on `increase(ghsa.ratelimit.exhausted[15m]) > 0` to catch hard throttles.
 | 
				
			||||||
- Overlay `ghsa.fetch.attempts` vs `ghsa.fetch.failures` to confirm retries are effective.
 | 
					- Overlay `ghsa.fetch.attempts` vs `ghsa.fetch.failures` to confirm retries are effective.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## 3. Logging signals
 | 
					## 3. Logging signals
 | 
				
			||||||
When `X-RateLimit-Remaining` falls below `RateLimitWarningThreshold`, the connector emits:
 | 
					When `X-RateLimit-Remaining` falls below `RateLimitWarningThreshold`, the connector emits:
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
GHSA rate limit warning: remaining {Remaining}/{Limit} for {Phase} {Resource}
 | 
					GHSA rate limit warning: remaining {Remaining}/{Limit} for {Phase} {Resource} (headroom {Headroom}%)
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
When GitHub reports zero remaining calls, the connector logs and sleeps for the reported `Retry-After`/`X-RateLimit-Reset` interval (falling back to `SecondaryRateLimitBackoff`).
 | 
					When GitHub reports zero remaining calls, the connector logs and sleeps for the reported `Retry-After`/`X-RateLimit-Reset` interval (falling back to `SecondaryRateLimitBackoff`).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					After the quota recovers above the warning threshold the connector writes an informational log with the refreshed remaining/headroom, letting operators clear alerts quickly.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## 4. Configuration knobs (`feedser.yaml`)
 | 
					## 4. Configuration knobs (`feedser.yaml`)
 | 
				
			||||||
```yaml
 | 
					```yaml
 | 
				
			||||||
feedser:
 | 
					feedser:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -44,6 +44,18 @@ public static class SemVerPrimitiveExtensions
 | 
				
			|||||||
                notes: resolvedNotes);
 | 
					                notes: resolvedNotes);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!string.IsNullOrEmpty(introduced) && string.IsNullOrEmpty(fixedVersion) && !string.IsNullOrEmpty(lastAffected))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return new NormalizedVersionRule(
 | 
				
			||||||
 | 
					                scheme,
 | 
				
			||||||
 | 
					                NormalizedVersionRuleTypes.Range,
 | 
				
			||||||
 | 
					                min: introduced,
 | 
				
			||||||
 | 
					                minInclusive: primitive.IntroducedInclusive,
 | 
				
			||||||
 | 
					                max: lastAffected,
 | 
				
			||||||
 | 
					                maxInclusive: primitive.LastAffectedInclusive,
 | 
				
			||||||
 | 
					                notes: resolvedNotes);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (!string.IsNullOrEmpty(introduced) && string.IsNullOrEmpty(fixedVersion) && string.IsNullOrEmpty(lastAffected))
 | 
					        if (!string.IsNullOrEmpty(introduced) && string.IsNullOrEmpty(fixedVersion) && string.IsNullOrEmpty(lastAffected))
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            var type = primitive.IntroducedInclusive ? NormalizedVersionRuleTypes.GreaterThanOrEqual : NormalizedVersionRuleTypes.GreaterThan;
 | 
					            var type = primitive.IntroducedInclusive ? NormalizedVersionRuleTypes.GreaterThanOrEqual : NormalizedVersionRuleTypes.GreaterThan;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -110,4 +110,52 @@ public sealed class SemVerRangeRuleBuilderTests
 | 
				
			|||||||
        Assert.Equal("2.5.1-alpha.1+build.7", normalized.Value);
 | 
					        Assert.Equal("2.5.1-alpha.1+build.7", normalized.Value);
 | 
				
			||||||
        Assert.Equal(Note, normalized.Notes);
 | 
					        Assert.Equal(Note, normalized.Notes);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Fact]
 | 
				
			||||||
 | 
					    public void Build_ParsesComparatorWithoutCommaSeparators()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var results = SemVerRangeRuleBuilder.Build(">=1.0.0 <1.2.0", null, Note);
 | 
				
			||||||
 | 
					        var result = Assert.Single(results);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var primitive = result.Primitive;
 | 
				
			||||||
 | 
					        Assert.Equal("1.0.0", primitive.Introduced);
 | 
				
			||||||
 | 
					        Assert.True(primitive.IntroducedInclusive);
 | 
				
			||||||
 | 
					        Assert.Equal("1.2.0", primitive.Fixed);
 | 
				
			||||||
 | 
					        Assert.False(primitive.FixedInclusive);
 | 
				
			||||||
 | 
					        Assert.Equal(">= 1.0.0, < 1.2.0", primitive.ConstraintExpression);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var normalized = result.NormalizedRule;
 | 
				
			||||||
 | 
					        Assert.Equal(NormalizedVersionRuleTypes.Range, normalized.Type);
 | 
				
			||||||
 | 
					        Assert.Equal("1.0.0", normalized.Min);
 | 
				
			||||||
 | 
					        Assert.True(normalized.MinInclusive);
 | 
				
			||||||
 | 
					        Assert.Equal("1.2.0", normalized.Max);
 | 
				
			||||||
 | 
					        Assert.False(normalized.MaxInclusive);
 | 
				
			||||||
 | 
					        Assert.Equal(Note, normalized.Notes);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Fact]
 | 
				
			||||||
 | 
					    public void Build_HandlesMultipleSegmentsSeparatedByOr()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var results = SemVerRangeRuleBuilder.Build(">=1.0.0 <1.2.0 || >=2.0.0 <2.2.0", null, Note);
 | 
				
			||||||
 | 
					        Assert.Equal(2, results.Count);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var first = results[0];
 | 
				
			||||||
 | 
					        Assert.Equal("1.0.0", first.Primitive.Introduced);
 | 
				
			||||||
 | 
					        Assert.Equal("1.2.0", first.Primitive.Fixed);
 | 
				
			||||||
 | 
					        Assert.Equal(NormalizedVersionRuleTypes.Range, first.NormalizedRule.Type);
 | 
				
			||||||
 | 
					        Assert.Equal("1.0.0", first.NormalizedRule.Min);
 | 
				
			||||||
 | 
					        Assert.Equal("1.2.0", first.NormalizedRule.Max);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var second = results[1];
 | 
				
			||||||
 | 
					        Assert.Equal("2.0.0", second.Primitive.Introduced);
 | 
				
			||||||
 | 
					        Assert.Equal("2.2.0", second.Primitive.Fixed);
 | 
				
			||||||
 | 
					        Assert.Equal(NormalizedVersionRuleTypes.Range, second.NormalizedRule.Type);
 | 
				
			||||||
 | 
					        Assert.Equal("2.0.0", second.NormalizedRule.Min);
 | 
				
			||||||
 | 
					        Assert.Equal("2.2.0", second.NormalizedRule.Max);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        foreach (var result in results)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Assert.Equal(Note, result.NormalizedRule.Notes);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
using System.Collections.Generic;
 | 
					using System.Collections.Generic;
 | 
				
			||||||
 | 
					using System.Diagnostics.CodeAnalysis;
 | 
				
			||||||
using System.Globalization;
 | 
					using System.Globalization;
 | 
				
			||||||
using System.Linq;
 | 
					using System.Linq;
 | 
				
			||||||
using System.Text;
 | 
					using System.Text;
 | 
				
			||||||
@@ -15,6 +16,7 @@ namespace StellaOps.Feedser.Normalization.SemVer;
 | 
				
			|||||||
public static class SemVerRangeRuleBuilder
 | 
					public static class SemVerRangeRuleBuilder
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    private static readonly Regex ComparatorRegex = new(@"^(?<op>>=|<=|>|<|==|=)\s*(?<value>.+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
 | 
					    private static readonly Regex ComparatorRegex = new(@"^(?<op>>=|<=|>|<|==|=)\s*(?<value>.+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
 | 
				
			||||||
 | 
					    private static readonly Regex ComparatorTokenRegex = new(@"(?<token>(?<op>>=|<=|>|<|==|=)\s*(?<value>[^,\s\|]+))", RegexOptions.Compiled | RegexOptions.CultureInvariant);
 | 
				
			||||||
    private static readonly Regex HyphenRegex = new(@"^\s*(?<start>.+?)\s+-\s+(?<end>.+)\s*$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
 | 
					    private static readonly Regex HyphenRegex = new(@"^\s*(?<start>.+?)\s+-\s+(?<end>.+)\s*$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
 | 
				
			||||||
    private static readonly char[] SegmentTrimCharacters = { '(', ')', '[', ']', '{', '}', ';' };
 | 
					    private static readonly char[] SegmentTrimCharacters = { '(', ')', '[', ']', '{', '}', ';' };
 | 
				
			||||||
    private static readonly char[] FragmentSplitCharacters = { ',', ' ' };
 | 
					    private static readonly char[] FragmentSplitCharacters = { ',', ' ' };
 | 
				
			||||||
@@ -303,21 +305,29 @@ public static class SemVerRangeRuleBuilder
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        foreach (var fragment in fragments)
 | 
					        foreach (var fragment in fragments)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            if (TryParseComparatorFragment(fragment, constraintTokens, ref introduced, ref introducedInclusive, ref hasIntroduced, ref fixedVersion, ref fixedInclusive, ref hasFixed, ref lastAffected, ref lastInclusive, ref hasLast, ref exactValue))
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                continue;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            var parts = fragment.Split(FragmentSplitCharacters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
 | 
					 | 
				
			||||||
            var handled = false;
 | 
					            var handled = false;
 | 
				
			||||||
            foreach (var part in parts)
 | 
					
 | 
				
			||||||
 | 
					            foreach (Match match in ComparatorTokenRegex.Matches(fragment))
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                if (TryParseComparatorFragment(part, constraintTokens, ref introduced, ref introducedInclusive, ref hasIntroduced, ref fixedVersion, ref fixedInclusive, ref hasFixed, ref lastAffected, ref lastInclusive, ref hasLast, ref exactValue))
 | 
					                if (match.Groups["token"].Success
 | 
				
			||||||
 | 
					                    && TryParseComparatorFragment(match.Groups["token"].Value, constraintTokens, ref introduced, ref introducedInclusive, ref hasIntroduced, ref fixedVersion, ref fixedInclusive, ref hasFixed, ref lastAffected, ref lastInclusive, ref hasLast, ref exactValue))
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                    handled = true;
 | 
					                    handled = true;
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (!handled)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                var parts = fragment.Split(FragmentSplitCharacters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
 | 
				
			||||||
 | 
					                foreach (var part in parts)
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    if (TryParseComparatorFragment(part, constraintTokens, ref introduced, ref introducedInclusive, ref hasIntroduced, ref fixedVersion, ref fixedInclusive, ref hasFixed, ref lastAffected, ref lastInclusive, ref hasLast, ref exactValue))
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        handled = true;
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if (handled)
 | 
					            if (handled)
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                continue;
 | 
					                continue;
 | 
				
			||||||
@@ -339,7 +349,8 @@ public static class SemVerRangeRuleBuilder
 | 
				
			|||||||
                FixedInclusive: true,
 | 
					                FixedInclusive: true,
 | 
				
			||||||
                LastAffected: null,
 | 
					                LastAffected: null,
 | 
				
			||||||
                LastAffectedInclusive: false,
 | 
					                LastAffectedInclusive: false,
 | 
				
			||||||
                ConstraintExpression: constraintTokens.Count > 0 ? string.Join(", ", constraintTokens) : expression.Trim());
 | 
					                ConstraintExpression: constraintTokens.Count > 0 ? string.Join(", ", constraintTokens) : expression.Trim(),
 | 
				
			||||||
 | 
					                ExactValue: exactValue);
 | 
				
			||||||
            return true;
 | 
					            return true;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -426,32 +437,32 @@ public static class SemVerRangeRuleBuilder
 | 
				
			|||||||
        switch (op)
 | 
					        switch (op)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            case ">=":
 | 
					            case ">=":
 | 
				
			||||||
                introduced = value;
 | 
					                introduced = value!;
 | 
				
			||||||
                introducedInclusive = true;
 | 
					                introducedInclusive = true;
 | 
				
			||||||
                hasIntroduced = true;
 | 
					                hasIntroduced = true;
 | 
				
			||||||
                constraintTokens.Add($">= {value}");
 | 
					                constraintTokens.Add($">= {value}");
 | 
				
			||||||
                break;
 | 
					                break;
 | 
				
			||||||
            case ">":
 | 
					            case ">":
 | 
				
			||||||
                introduced = value;
 | 
					                introduced = value!;
 | 
				
			||||||
                introducedInclusive = false;
 | 
					                introducedInclusive = false;
 | 
				
			||||||
                hasIntroduced = true;
 | 
					                hasIntroduced = true;
 | 
				
			||||||
                constraintTokens.Add($"> {value}");
 | 
					                constraintTokens.Add($"> {value}");
 | 
				
			||||||
                break;
 | 
					                break;
 | 
				
			||||||
            case "<=":
 | 
					            case "<=":
 | 
				
			||||||
                lastAffected = value;
 | 
					                lastAffected = value!;
 | 
				
			||||||
                lastInclusive = true;
 | 
					                lastInclusive = true;
 | 
				
			||||||
                hasLast = true;
 | 
					                hasLast = true;
 | 
				
			||||||
                constraintTokens.Add($"<= {value}");
 | 
					                constraintTokens.Add($"<= {value}");
 | 
				
			||||||
                break;
 | 
					                break;
 | 
				
			||||||
            case "<":
 | 
					            case "<":
 | 
				
			||||||
                fixedVersion = value;
 | 
					                fixedVersion = value!;
 | 
				
			||||||
                fixedInclusive = false;
 | 
					                fixedInclusive = false;
 | 
				
			||||||
                hasFixed = true;
 | 
					                hasFixed = true;
 | 
				
			||||||
                constraintTokens.Add($"< {value}");
 | 
					                constraintTokens.Add($"< {value}");
 | 
				
			||||||
                break;
 | 
					                break;
 | 
				
			||||||
            case "=":
 | 
					            case "=":
 | 
				
			||||||
            case "==":
 | 
					            case "==":
 | 
				
			||||||
                exactValue = value;
 | 
					                exactValue = value!;
 | 
				
			||||||
                constraintTokens.Add($"= {value}");
 | 
					                constraintTokens.Add($"= {value}");
 | 
				
			||||||
                break;
 | 
					                break;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -459,7 +470,7 @@ public static class SemVerRangeRuleBuilder
 | 
				
			|||||||
        return true;
 | 
					        return true;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private static bool TryNormalizeVersion(string? value, out string normalized)
 | 
					    private static bool TryNormalizeVersion(string? value, [NotNullWhen(true)] out string normalized)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        normalized = string.Empty;
 | 
					        normalized = string.Empty;
 | 
				
			||||||
        if (string.IsNullOrWhiteSpace(value))
 | 
					        if (string.IsNullOrWhiteSpace(value))
 | 
				
			||||||
@@ -492,22 +503,23 @@ public static class SemVerRangeRuleBuilder
 | 
				
			|||||||
        return true;
 | 
					        return true;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private static bool TryParseSemanticVersion(string value, out SemanticVersion version, out string normalized)
 | 
					    private static bool TryParseSemanticVersion(string value, [NotNullWhen(true)] out SemanticVersion version, out string normalized)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        version = null!;
 | 
					 | 
				
			||||||
        normalized = string.Empty;
 | 
					        normalized = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var candidate = RemoveLeadingV(value);
 | 
					        var candidate = RemoveLeadingV(value);
 | 
				
			||||||
        if (!SemanticVersion.TryParse(candidate, out version))
 | 
					        if (!SemanticVersion.TryParse(candidate, out var parsed))
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            candidate = ExpandSemanticVersion(candidate);
 | 
					            candidate = ExpandSemanticVersion(candidate);
 | 
				
			||||||
            if (!SemanticVersion.TryParse(candidate, out version))
 | 
					            if (!SemanticVersion.TryParse(candidate, out parsed))
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
 | 
					                version = null!;
 | 
				
			||||||
                return false;
 | 
					                return false;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        normalized = FormatVersion(version);
 | 
					        version = parsed!;
 | 
				
			||||||
 | 
					        normalized = FormatVersion(parsed);
 | 
				
			||||||
        return true;
 | 
					        return true;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,3 +6,4 @@
 | 
				
			|||||||
|CPE normalization escape handling|BE-Norm (OSS WG)|Normalization identifiers|DONE – percent-decoding, edition sub-field expansion, and deterministic escaping landed in `Cpe23` with new tests covering boundary cases.|
 | 
					|CPE normalization escape handling|BE-Norm (OSS WG)|Normalization identifiers|DONE – percent-decoding, edition sub-field expansion, and deterministic escaping landed in `Cpe23` with new tests covering boundary cases.|
 | 
				
			||||||
|CVSS metric normalization & severity bands|BE-Norm (Risk WG)|Models|DONE – `CvssMetricNormalizer` unifies vectors, recomputes scores/severities, and is wired through NVD/RedHat/JVN mappers with unit coverage.|
 | 
					|CVSS metric normalization & severity bands|BE-Norm (Risk WG)|Models|DONE – `CvssMetricNormalizer` unifies vectors, recomputes scores/severities, and is wired through NVD/RedHat/JVN mappers with unit coverage.|
 | 
				
			||||||
|Description and locale normalization pipeline|BE-Norm (I18N)|Source connectors|DONE – `DescriptionNormalizer` strips markup, collapses whitespace, and provides locale fallback used by core mappers.|
 | 
					|Description and locale normalization pipeline|BE-Norm (I18N)|Source connectors|DONE – `DescriptionNormalizer` strips markup, collapses whitespace, and provides locale fallback used by core mappers.|
 | 
				
			||||||
 | 
					|SemVer normalized rule emitter (FEEDNORM-NORM-02-001)|BE-Norm (SemVer WG)|Models, `FASTER_MODELING_AND_NORMALIZATION.md`|**DONE (2025-10-12)** – `SemVerRangeRuleBuilder` now parses comparator chains without comma delimiters, supports multi-segment `||` ranges, pushes exact-value metadata, and new tests document the contract for connector teams.|
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -37,6 +37,8 @@ public sealed class GhsaConnector : IFeedConnector
 | 
				
			|||||||
    private readonly GhsaDiagnostics _diagnostics;
 | 
					    private readonly GhsaDiagnostics _diagnostics;
 | 
				
			||||||
    private readonly TimeProvider _timeProvider;
 | 
					    private readonly TimeProvider _timeProvider;
 | 
				
			||||||
    private readonly ILogger<GhsaConnector> _logger;
 | 
					    private readonly ILogger<GhsaConnector> _logger;
 | 
				
			||||||
 | 
					    private readonly object _rateLimitWarningLock = new();
 | 
				
			||||||
 | 
					    private readonly Dictionary<(string Phase, string Resource), bool> _rateLimitWarnings = new();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public GhsaConnector(
 | 
					    public GhsaConnector(
 | 
				
			||||||
        SourceFetchService fetchService,
 | 
					        SourceFetchService fetchService,
 | 
				
			||||||
@@ -412,6 +414,62 @@ public sealed class GhsaConnector : IFeedConnector
 | 
				
			|||||||
        await _stateRepository.UpdateCursorAsync(SourceName, cursor.ToBsonDocument(), _timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
 | 
					        await _stateRepository.UpdateCursorAsync(SourceName, cursor.ToBsonDocument(), _timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private bool ShouldLogRateLimitWarning(in GhsaRateLimitSnapshot snapshot, out bool recovered)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        recovered = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!snapshot.Remaining.HasValue)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var key = (snapshot.Phase, snapshot.Resource ?? "global");
 | 
				
			||||||
 | 
					        var warn = snapshot.Remaining.Value <= _options.RateLimitWarningThreshold;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        lock (_rateLimitWarningLock)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var previouslyWarned = _rateLimitWarnings.TryGetValue(key, out var flagged) && flagged;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (warn)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                if (previouslyWarned)
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    return false;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                _rateLimitWarnings[key] = true;
 | 
				
			||||||
 | 
					                return true;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (previouslyWarned)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                _rateLimitWarnings.Remove(key);
 | 
				
			||||||
 | 
					                recovered = true;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static double? CalculateHeadroomPercentage(in GhsaRateLimitSnapshot snapshot)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (!snapshot.Limit.HasValue || !snapshot.Remaining.HasValue)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return null;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var limit = snapshot.Limit.Value;
 | 
				
			||||||
 | 
					        if (limit <= 0)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return null;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return (double)snapshot.Remaining.Value / limit * 100d;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static string FormatHeadroom(double? headroomPct)
 | 
				
			||||||
 | 
					        => headroomPct.HasValue ? $" (headroom {headroomPct.Value:F1}%)" : string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private async Task<bool> ApplyRateLimitAsync(IReadOnlyDictionary<string, string>? headers, string phase, CancellationToken cancellationToken)
 | 
					    private async Task<bool> ApplyRateLimitAsync(IReadOnlyDictionary<string, string>? headers, string phase, CancellationToken cancellationToken)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var snapshot = GhsaRateLimitParser.TryParse(headers, _timeProvider.GetUtcNow(), phase);
 | 
					        var snapshot = GhsaRateLimitParser.TryParse(headers, _timeProvider.GetUtcNow(), phase);
 | 
				
			||||||
@@ -422,19 +480,31 @@ public sealed class GhsaConnector : IFeedConnector
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        _diagnostics.RecordRateLimit(snapshot.Value);
 | 
					        _diagnostics.RecordRateLimit(snapshot.Value);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (snapshot.Value.Remaining.HasValue && snapshot.Value.Remaining.Value <= _options.RateLimitWarningThreshold)
 | 
					        var headroomPct = CalculateHeadroomPercentage(snapshot.Value);
 | 
				
			||||||
 | 
					        if (ShouldLogRateLimitWarning(snapshot.Value, out var recovered))
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            var resetMessage = snapshot.Value.ResetAfter.HasValue
 | 
					            var resetMessage = snapshot.Value.ResetAfter.HasValue
 | 
				
			||||||
                ? $" (resets in {snapshot.Value.ResetAfter.Value:c})"
 | 
					                ? $" (resets in {snapshot.Value.ResetAfter.Value:c})"
 | 
				
			||||||
                : snapshot.Value.ResetAt.HasValue ? $" (resets at {snapshot.Value.ResetAt.Value:O})" : string.Empty;
 | 
					                : snapshot.Value.ResetAt.HasValue ? $" (resets at {snapshot.Value.ResetAt.Value:O})" : string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            _logger.LogWarning(
 | 
					            _logger.LogWarning(
 | 
				
			||||||
                "GHSA rate limit warning: remaining {Remaining} of {Limit} for {Phase} {Resource}{ResetMessage}",
 | 
					                "GHSA rate limit warning: remaining {Remaining} of {Limit} for {Phase} {Resource}{ResetMessage}{Headroom}",
 | 
				
			||||||
                snapshot.Value.Remaining,
 | 
					                snapshot.Value.Remaining,
 | 
				
			||||||
                snapshot.Value.Limit,
 | 
					                snapshot.Value.Limit,
 | 
				
			||||||
                phase,
 | 
					                phase,
 | 
				
			||||||
                snapshot.Value.Resource ?? "global",
 | 
					                snapshot.Value.Resource ?? "global",
 | 
				
			||||||
                resetMessage);
 | 
					                resetMessage,
 | 
				
			||||||
 | 
					                FormatHeadroom(headroomPct));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        else if (recovered)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            _logger.LogInformation(
 | 
				
			||||||
 | 
					                "GHSA rate limit recovered for {Phase} {Resource}: remaining {Remaining} of {Limit}{Headroom}",
 | 
				
			||||||
 | 
					                phase,
 | 
				
			||||||
 | 
					                snapshot.Value.Resource ?? "global",
 | 
				
			||||||
 | 
					                snapshot.Value.Remaining,
 | 
				
			||||||
 | 
					                snapshot.Value.Limit,
 | 
				
			||||||
 | 
					                FormatHeadroom(headroomPct));
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (snapshot.Value.Remaining.HasValue && snapshot.Value.Remaining.Value <= 0)
 | 
					        if (snapshot.Value.Remaining.HasValue && snapshot.Value.Remaining.Value <= 0)
 | 
				
			||||||
@@ -445,10 +515,11 @@ public sealed class GhsaConnector : IFeedConnector
 | 
				
			|||||||
            if (delay > TimeSpan.Zero)
 | 
					            if (delay > TimeSpan.Zero)
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                _logger.LogWarning(
 | 
					                _logger.LogWarning(
 | 
				
			||||||
                    "GHSA rate limit exhausted for {Phase} {Resource}; delaying {Delay}",
 | 
					                    "GHSA rate limit exhausted for {Phase} {Resource}; delaying {Delay}{Headroom}",
 | 
				
			||||||
                    phase,
 | 
					                    phase,
 | 
				
			||||||
                    snapshot.Value.Resource ?? "global",
 | 
					                    snapshot.Value.Resource ?? "global",
 | 
				
			||||||
                    delay);
 | 
					                    delay,
 | 
				
			||||||
 | 
					                    FormatHeadroom(headroomPct));
 | 
				
			||||||
                await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
 | 
					                await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					using System.Collections.Generic;
 | 
				
			||||||
using System.Diagnostics.Metrics;
 | 
					using System.Diagnostics.Metrics;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace StellaOps.Feedser.Source.Ghsa.Internal;
 | 
					namespace StellaOps.Feedser.Source.Ghsa.Internal;
 | 
				
			||||||
@@ -19,9 +20,12 @@ public sealed class GhsaDiagnostics : IDisposable
 | 
				
			|||||||
    private readonly Histogram<long> _rateLimitRemaining;
 | 
					    private readonly Histogram<long> _rateLimitRemaining;
 | 
				
			||||||
    private readonly Histogram<long> _rateLimitLimit;
 | 
					    private readonly Histogram<long> _rateLimitLimit;
 | 
				
			||||||
    private readonly Histogram<double> _rateLimitResetSeconds;
 | 
					    private readonly Histogram<double> _rateLimitResetSeconds;
 | 
				
			||||||
 | 
					    private readonly Histogram<double> _rateLimitHeadroomPct;
 | 
				
			||||||
 | 
					    private readonly ObservableGauge<double> _rateLimitHeadroomGauge;
 | 
				
			||||||
    private readonly Counter<long> _rateLimitExhausted;
 | 
					    private readonly Counter<long> _rateLimitExhausted;
 | 
				
			||||||
    private readonly object _rateLimitLock = new();
 | 
					    private readonly object _rateLimitLock = new();
 | 
				
			||||||
    private GhsaRateLimitSnapshot? _lastRateLimitSnapshot;
 | 
					    private GhsaRateLimitSnapshot? _lastRateLimitSnapshot;
 | 
				
			||||||
 | 
					    private readonly Dictionary<(string Phase, string? Resource), GhsaRateLimitSnapshot> _rateLimitSnapshots = new();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public GhsaDiagnostics()
 | 
					    public GhsaDiagnostics()
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -37,6 +41,8 @@ public sealed class GhsaDiagnostics : IDisposable
 | 
				
			|||||||
        _rateLimitRemaining = _meter.CreateHistogram<long>("ghsa.ratelimit.remaining", unit: "requests");
 | 
					        _rateLimitRemaining = _meter.CreateHistogram<long>("ghsa.ratelimit.remaining", unit: "requests");
 | 
				
			||||||
        _rateLimitLimit = _meter.CreateHistogram<long>("ghsa.ratelimit.limit", unit: "requests");
 | 
					        _rateLimitLimit = _meter.CreateHistogram<long>("ghsa.ratelimit.limit", unit: "requests");
 | 
				
			||||||
        _rateLimitResetSeconds = _meter.CreateHistogram<double>("ghsa.ratelimit.reset_seconds", unit: "s");
 | 
					        _rateLimitResetSeconds = _meter.CreateHistogram<double>("ghsa.ratelimit.reset_seconds", unit: "s");
 | 
				
			||||||
 | 
					        _rateLimitHeadroomPct = _meter.CreateHistogram<double>("ghsa.ratelimit.headroom_pct", unit: "percent");
 | 
				
			||||||
 | 
					        _rateLimitHeadroomGauge = _meter.CreateObservableGauge("ghsa.ratelimit.headroom_pct_current", ObserveHeadroom, unit: "percent");
 | 
				
			||||||
        _rateLimitExhausted = _meter.CreateCounter<long>("ghsa.ratelimit.exhausted", unit: "events");
 | 
					        _rateLimitExhausted = _meter.CreateCounter<long>("ghsa.ratelimit.exhausted", unit: "events");
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -79,9 +85,15 @@ public sealed class GhsaDiagnostics : IDisposable
 | 
				
			|||||||
            _rateLimitResetSeconds.Record(snapshot.ResetAfter.Value.TotalSeconds, tags);
 | 
					            _rateLimitResetSeconds.Record(snapshot.ResetAfter.Value.TotalSeconds, tags);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (TryCalculateHeadroom(snapshot, out var headroom))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            _rateLimitHeadroomPct.Record(headroom, tags);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        lock (_rateLimitLock)
 | 
					        lock (_rateLimitLock)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            _lastRateLimitSnapshot = snapshot;
 | 
					            _lastRateLimitSnapshot = snapshot;
 | 
				
			||||||
 | 
					            _rateLimitSnapshots[(snapshot.Phase, snapshot.Resource)] = snapshot;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -96,5 +108,48 @@ public sealed class GhsaDiagnostics : IDisposable
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public void Dispose() => _meter.Dispose();
 | 
					    private IEnumerable<Measurement<double>> ObserveHeadroom()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        lock (_rateLimitLock)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            if (_rateLimitSnapshots.Count == 0)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                yield break;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            foreach (var snapshot in _rateLimitSnapshots.Values)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                if (TryCalculateHeadroom(snapshot, out var headroom))
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    yield return new Measurement<double>(
 | 
				
			||||||
 | 
					                        headroom,
 | 
				
			||||||
 | 
					                        new KeyValuePair<string, object?>("phase", snapshot.Phase),
 | 
				
			||||||
 | 
					                        new KeyValuePair<string, object?>("resource", snapshot.Resource ?? "unknown"));
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static bool TryCalculateHeadroom(in GhsaRateLimitSnapshot snapshot, out double headroomPct)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        headroomPct = 0;
 | 
				
			||||||
 | 
					        if (!snapshot.Limit.HasValue || !snapshot.Remaining.HasValue)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var limit = snapshot.Limit.Value;
 | 
				
			||||||
 | 
					        if (limit <= 0)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        headroomPct = (double)snapshot.Remaining.Value / limit * 100d;
 | 
				
			||||||
 | 
					        return true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public void Dispose()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        _meter.Dispose();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,5 +11,7 @@
 | 
				
			|||||||
|Production credential & scheduler rollout|Ops, BE-Conn-GHSA|Docs, WebService|**DONE (2025-10-12)** – Scheduler defaults registered via `JobSchedulerBuilder`, credential provisioning documented (Compose/Helm samples), and staged backfill guidance captured in `docs/ops/feedser-ghsa-operations.md`.|
 | 
					|Production credential & scheduler rollout|Ops, BE-Conn-GHSA|Docs, WebService|**DONE (2025-10-12)** – Scheduler defaults registered via `JobSchedulerBuilder`, credential provisioning documented (Compose/Helm samples), and staged backfill guidance captured in `docs/ops/feedser-ghsa-operations.md`.|
 | 
				
			||||||
|FEEDCONN-GHSA-04-002 Conflict regression fixtures|BE-Conn-GHSA, QA|Merge `FEEDMERGE-ENGINE-04-001`|**DONE (2025-10-12)** – Added `conflict-ghsa.canonical.json` + `GhsaConflictFixtureTests`; SemVer ranges and credits align with merge precedence triple and shareable with QA. Validation: `dotnet test src/StellaOps.Feedser.Source.Ghsa.Tests/StellaOps.Feedser.Source.Ghsa.Tests.csproj --filter GhsaConflictFixtureTests`.|
 | 
					|FEEDCONN-GHSA-04-002 Conflict regression fixtures|BE-Conn-GHSA, QA|Merge `FEEDMERGE-ENGINE-04-001`|**DONE (2025-10-12)** – Added `conflict-ghsa.canonical.json` + `GhsaConflictFixtureTests`; SemVer ranges and credits align with merge precedence triple and shareable with QA. Validation: `dotnet test src/StellaOps.Feedser.Source.Ghsa.Tests/StellaOps.Feedser.Source.Ghsa.Tests.csproj --filter GhsaConflictFixtureTests`.|
 | 
				
			||||||
|FEEDCONN-GHSA-02-004 GHSA credits & ecosystem severity mapping|BE-Conn-GHSA|Models `FEEDMODELS-SCHEMA-01-002`|**DONE (2025-10-11)** – Mapper emits advisory credits with provenance masks, fixtures assert role/contact ordering, and severity normalization remains unchanged.|
 | 
					|FEEDCONN-GHSA-02-004 GHSA credits & ecosystem severity mapping|BE-Conn-GHSA|Models `FEEDMODELS-SCHEMA-01-002`|**DONE (2025-10-11)** – Mapper emits advisory credits with provenance masks, fixtures assert role/contact ordering, and severity normalization remains unchanged.|
 | 
				
			||||||
|FEEDCONN-GHSA-02-007 Credit parity regression fixtures|BE-Conn-GHSA, QA|Source.Nvd, Source.Osv|**DONE (2025-10-12)** – Credit parity fixtures recorded, regression tests cover GHSA/OSV/NVD alignment, and regeneration workflow documented in `docs/dev/fixtures.md`.|
 | 
					|FEEDCONN-GHSA-02-007 Credit parity regression fixtures|BE-Conn-GHSA, QA|Source.Nvd, Source.Osv|**DONE (2025-10-12)** – Parity fixtures regenerated via `tools/FixtureUpdater`, normalized SemVer notes verified against GHSA/NVD/OSV snapshots, and the fixtures guide now documents the headroom checks.|
 | 
				
			||||||
|FEEDCONN-GHSA-02-001 Normalized versions rollout|BE-Conn-GHSA|Models `FEEDMODELS-SCHEMA-01-003`, Normalization playbook|**DONE (2025-10-11)** – GHSA mapper now emits SemVer primitives + normalized ranges, fixtures refreshed, connector tests passing; report logged via FEEDMERGE-COORD-02-900.|
 | 
					|FEEDCONN-GHSA-02-001 Normalized versions rollout|BE-Conn-GHSA|Models `FEEDMODELS-SCHEMA-01-003`, Normalization playbook|**DONE (2025-10-11)** – GHSA mapper now emits SemVer primitives + normalized ranges, fixtures refreshed, connector tests passing; report logged via FEEDMERGE-COORD-02-900.|
 | 
				
			||||||
 | 
					|FEEDCONN-GHSA-02-005 Quota monitoring hardening|BE-Conn-GHSA, Observability|Source.Common metrics|**DONE (2025-10-12)** – Diagnostics expose headroom histograms/gauges, warning logs dedupe below the configured threshold, and the ops runbook gained alerting and mitigation guidance.|
 | 
				
			||||||
 | 
					|FEEDCONN-GHSA-02-006 Scheduler rollout integration|BE-Conn-GHSA, Ops|Job scheduler|**DONE (2025-10-12)** – Dependency routine tests assert cron/timeouts, and the runbook highlights cron overrides plus backoff toggles for staged rollouts.|
 | 
				
			||||||
 
 | 
				
			|||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -33,7 +33,7 @@
 | 
				
			|||||||
              "kind": "range",
 | 
					              "kind": "range",
 | 
				
			||||||
              "value": "pkg:golang/github.com/opencontainers/image-spec",
 | 
					              "value": "pkg:golang/github.com/opencontainers/image-spec",
 | 
				
			||||||
              "decisionReason": null,
 | 
					              "decisionReason": null,
 | 
				
			||||||
              "recordedAt": "2025-10-12T12:01:51.8509671+00:00",
 | 
					              "recordedAt": "2025-10-12T19:48:04.2184266+00:00",
 | 
				
			||||||
              "fieldMask": [
 | 
					              "fieldMask": [
 | 
				
			||||||
                "affectedpackages[].versionranges[]"
 | 
					                "affectedpackages[].versionranges[]"
 | 
				
			||||||
              ]
 | 
					              ]
 | 
				
			||||||
@@ -61,7 +61,7 @@
 | 
				
			|||||||
            "kind": "affected",
 | 
					            "kind": "affected",
 | 
				
			||||||
            "value": "pkg:golang/github.com/opencontainers/image-spec",
 | 
					            "value": "pkg:golang/github.com/opencontainers/image-spec",
 | 
				
			||||||
            "decisionReason": null,
 | 
					            "decisionReason": null,
 | 
				
			||||||
            "recordedAt": "2025-10-12T12:01:51.8509671+00:00",
 | 
					            "recordedAt": "2025-10-12T19:48:04.2184266+00:00",
 | 
				
			||||||
            "fieldMask": [
 | 
					            "fieldMask": [
 | 
				
			||||||
              "affectedpackages[]"
 | 
					              "affectedpackages[]"
 | 
				
			||||||
            ]
 | 
					            ]
 | 
				
			||||||
@@ -83,7 +83,7 @@
 | 
				
			|||||||
          "kind": "cvss",
 | 
					          "kind": "cvss",
 | 
				
			||||||
          "value": "CVSS_V3",
 | 
					          "value": "CVSS_V3",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.8509671+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2184266+00:00",
 | 
				
			||||||
          "fieldMask": []
 | 
					          "fieldMask": []
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        "vector": "CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:C/C:N/I:L/A:N",
 | 
					        "vector": "CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:C/C:N/I:L/A:N",
 | 
				
			||||||
@@ -99,7 +99,7 @@
 | 
				
			|||||||
        "kind": "document",
 | 
					        "kind": "document",
 | 
				
			||||||
        "value": "https://osv.dev/vulnerability/GHSA-77vh-xpmg-72qh",
 | 
					        "value": "https://osv.dev/vulnerability/GHSA-77vh-xpmg-72qh",
 | 
				
			||||||
        "decisionReason": null,
 | 
					        "decisionReason": null,
 | 
				
			||||||
        "recordedAt": "2021-11-18T16:02:41+00:00",
 | 
					        "recordedAt": "2025-10-12T19:48:04.2181708+00:00",
 | 
				
			||||||
        "fieldMask": [
 | 
					        "fieldMask": [
 | 
				
			||||||
          "advisory"
 | 
					          "advisory"
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
@@ -109,7 +109,7 @@
 | 
				
			|||||||
        "kind": "mapping",
 | 
					        "kind": "mapping",
 | 
				
			||||||
        "value": "GHSA-77vh-xpmg-72qh",
 | 
					        "value": "GHSA-77vh-xpmg-72qh",
 | 
				
			||||||
        "decisionReason": null,
 | 
					        "decisionReason": null,
 | 
				
			||||||
        "recordedAt": "2025-10-12T12:01:51.8509671+00:00",
 | 
					        "recordedAt": "2025-10-12T19:48:04.2184266+00:00",
 | 
				
			||||||
        "fieldMask": [
 | 
					        "fieldMask": [
 | 
				
			||||||
          "advisory"
 | 
					          "advisory"
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
@@ -124,7 +124,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://github.com/opencontainers/distribution-spec/security/advisories/GHSA-mc8v-mgrf-8f4m",
 | 
					          "value": "https://github.com/opencontainers/distribution-spec/security/advisories/GHSA-mc8v-mgrf-8f4m",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.8509671+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2184266+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -140,7 +140,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://github.com/opencontainers/image-spec",
 | 
					          "value": "https://github.com/opencontainers/image-spec",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.8509671+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2184266+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -156,7 +156,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://github.com/opencontainers/image-spec/commit/693428a734f5bab1a84bd2f990d92ef1111cd60c",
 | 
					          "value": "https://github.com/opencontainers/image-spec/commit/693428a734f5bab1a84bd2f990d92ef1111cd60c",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.8509671+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2184266+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -172,7 +172,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://github.com/opencontainers/image-spec/releases/tag/v1.0.2",
 | 
					          "value": "https://github.com/opencontainers/image-spec/releases/tag/v1.0.2",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.8509671+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2184266+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -188,7 +188,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://github.com/opencontainers/image-spec/security/advisories/GHSA-77vh-xpmg-72qh",
 | 
					          "value": "https://github.com/opencontainers/image-spec/security/advisories/GHSA-77vh-xpmg-72qh",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.8509671+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2184266+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -236,7 +236,7 @@
 | 
				
			|||||||
              "kind": "range",
 | 
					              "kind": "range",
 | 
				
			||||||
              "value": "pkg:maven/org.apache.logging.log4j/log4j-core",
 | 
					              "value": "pkg:maven/org.apache.logging.log4j/log4j-core",
 | 
				
			||||||
              "decisionReason": null,
 | 
					              "decisionReason": null,
 | 
				
			||||||
              "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					              "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
              "fieldMask": [
 | 
					              "fieldMask": [
 | 
				
			||||||
                "affectedpackages[].versionranges[]"
 | 
					                "affectedpackages[].versionranges[]"
 | 
				
			||||||
              ]
 | 
					              ]
 | 
				
			||||||
@@ -264,7 +264,7 @@
 | 
				
			|||||||
            "kind": "affected",
 | 
					            "kind": "affected",
 | 
				
			||||||
            "value": "pkg:maven/org.apache.logging.log4j/log4j-core",
 | 
					            "value": "pkg:maven/org.apache.logging.log4j/log4j-core",
 | 
				
			||||||
            "decisionReason": null,
 | 
					            "decisionReason": null,
 | 
				
			||||||
            "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					            "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
            "fieldMask": [
 | 
					            "fieldMask": [
 | 
				
			||||||
              "affectedpackages[]"
 | 
					              "affectedpackages[]"
 | 
				
			||||||
            ]
 | 
					            ]
 | 
				
			||||||
@@ -302,7 +302,7 @@
 | 
				
			|||||||
              "kind": "range",
 | 
					              "kind": "range",
 | 
				
			||||||
              "value": "pkg:maven/org.apache.logging.log4j/log4j-core",
 | 
					              "value": "pkg:maven/org.apache.logging.log4j/log4j-core",
 | 
				
			||||||
              "decisionReason": null,
 | 
					              "decisionReason": null,
 | 
				
			||||||
              "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					              "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
              "fieldMask": [
 | 
					              "fieldMask": [
 | 
				
			||||||
                "affectedpackages[].versionranges[]"
 | 
					                "affectedpackages[].versionranges[]"
 | 
				
			||||||
              ]
 | 
					              ]
 | 
				
			||||||
@@ -330,7 +330,7 @@
 | 
				
			|||||||
            "kind": "affected",
 | 
					            "kind": "affected",
 | 
				
			||||||
            "value": "pkg:maven/org.apache.logging.log4j/log4j-core",
 | 
					            "value": "pkg:maven/org.apache.logging.log4j/log4j-core",
 | 
				
			||||||
            "decisionReason": null,
 | 
					            "decisionReason": null,
 | 
				
			||||||
            "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					            "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
            "fieldMask": [
 | 
					            "fieldMask": [
 | 
				
			||||||
              "affectedpackages[]"
 | 
					              "affectedpackages[]"
 | 
				
			||||||
            ]
 | 
					            ]
 | 
				
			||||||
@@ -368,7 +368,7 @@
 | 
				
			|||||||
              "kind": "range",
 | 
					              "kind": "range",
 | 
				
			||||||
              "value": "pkg:maven/org.ops4j.pax.logging/pax-logging-log4j2",
 | 
					              "value": "pkg:maven/org.ops4j.pax.logging/pax-logging-log4j2",
 | 
				
			||||||
              "decisionReason": null,
 | 
					              "decisionReason": null,
 | 
				
			||||||
              "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					              "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
              "fieldMask": [
 | 
					              "fieldMask": [
 | 
				
			||||||
                "affectedpackages[].versionranges[]"
 | 
					                "affectedpackages[].versionranges[]"
 | 
				
			||||||
              ]
 | 
					              ]
 | 
				
			||||||
@@ -396,7 +396,7 @@
 | 
				
			|||||||
            "kind": "affected",
 | 
					            "kind": "affected",
 | 
				
			||||||
            "value": "pkg:maven/org.ops4j.pax.logging/pax-logging-log4j2",
 | 
					            "value": "pkg:maven/org.ops4j.pax.logging/pax-logging-log4j2",
 | 
				
			||||||
            "decisionReason": null,
 | 
					            "decisionReason": null,
 | 
				
			||||||
            "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					            "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
            "fieldMask": [
 | 
					            "fieldMask": [
 | 
				
			||||||
              "affectedpackages[]"
 | 
					              "affectedpackages[]"
 | 
				
			||||||
            ]
 | 
					            ]
 | 
				
			||||||
@@ -434,7 +434,7 @@
 | 
				
			|||||||
              "kind": "range",
 | 
					              "kind": "range",
 | 
				
			||||||
              "value": "pkg:maven/org.ops4j.pax.logging/pax-logging-log4j2",
 | 
					              "value": "pkg:maven/org.ops4j.pax.logging/pax-logging-log4j2",
 | 
				
			||||||
              "decisionReason": null,
 | 
					              "decisionReason": null,
 | 
				
			||||||
              "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					              "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
              "fieldMask": [
 | 
					              "fieldMask": [
 | 
				
			||||||
                "affectedpackages[].versionranges[]"
 | 
					                "affectedpackages[].versionranges[]"
 | 
				
			||||||
              ]
 | 
					              ]
 | 
				
			||||||
@@ -462,7 +462,7 @@
 | 
				
			|||||||
            "kind": "affected",
 | 
					            "kind": "affected",
 | 
				
			||||||
            "value": "pkg:maven/org.ops4j.pax.logging/pax-logging-log4j2",
 | 
					            "value": "pkg:maven/org.ops4j.pax.logging/pax-logging-log4j2",
 | 
				
			||||||
            "decisionReason": null,
 | 
					            "decisionReason": null,
 | 
				
			||||||
            "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					            "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
            "fieldMask": [
 | 
					            "fieldMask": [
 | 
				
			||||||
              "affectedpackages[]"
 | 
					              "affectedpackages[]"
 | 
				
			||||||
            ]
 | 
					            ]
 | 
				
			||||||
@@ -500,7 +500,7 @@
 | 
				
			|||||||
              "kind": "range",
 | 
					              "kind": "range",
 | 
				
			||||||
              "value": "pkg:maven/org.ops4j.pax.logging/pax-logging-log4j2",
 | 
					              "value": "pkg:maven/org.ops4j.pax.logging/pax-logging-log4j2",
 | 
				
			||||||
              "decisionReason": null,
 | 
					              "decisionReason": null,
 | 
				
			||||||
              "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					              "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
              "fieldMask": [
 | 
					              "fieldMask": [
 | 
				
			||||||
                "affectedpackages[].versionranges[]"
 | 
					                "affectedpackages[].versionranges[]"
 | 
				
			||||||
              ]
 | 
					              ]
 | 
				
			||||||
@@ -528,7 +528,7 @@
 | 
				
			|||||||
            "kind": "affected",
 | 
					            "kind": "affected",
 | 
				
			||||||
            "value": "pkg:maven/org.ops4j.pax.logging/pax-logging-log4j2",
 | 
					            "value": "pkg:maven/org.ops4j.pax.logging/pax-logging-log4j2",
 | 
				
			||||||
            "decisionReason": null,
 | 
					            "decisionReason": null,
 | 
				
			||||||
            "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					            "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
            "fieldMask": [
 | 
					            "fieldMask": [
 | 
				
			||||||
              "affectedpackages[]"
 | 
					              "affectedpackages[]"
 | 
				
			||||||
            ]
 | 
					            ]
 | 
				
			||||||
@@ -566,7 +566,7 @@
 | 
				
			|||||||
              "kind": "range",
 | 
					              "kind": "range",
 | 
				
			||||||
              "value": "pkg:maven/org.ops4j.pax.logging/pax-logging-log4j2",
 | 
					              "value": "pkg:maven/org.ops4j.pax.logging/pax-logging-log4j2",
 | 
				
			||||||
              "decisionReason": null,
 | 
					              "decisionReason": null,
 | 
				
			||||||
              "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					              "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
              "fieldMask": [
 | 
					              "fieldMask": [
 | 
				
			||||||
                "affectedpackages[].versionranges[]"
 | 
					                "affectedpackages[].versionranges[]"
 | 
				
			||||||
              ]
 | 
					              ]
 | 
				
			||||||
@@ -594,7 +594,7 @@
 | 
				
			|||||||
            "kind": "affected",
 | 
					            "kind": "affected",
 | 
				
			||||||
            "value": "pkg:maven/org.ops4j.pax.logging/pax-logging-log4j2",
 | 
					            "value": "pkg:maven/org.ops4j.pax.logging/pax-logging-log4j2",
 | 
				
			||||||
            "decisionReason": null,
 | 
					            "decisionReason": null,
 | 
				
			||||||
            "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					            "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
            "fieldMask": [
 | 
					            "fieldMask": [
 | 
				
			||||||
              "affectedpackages[]"
 | 
					              "affectedpackages[]"
 | 
				
			||||||
            ]
 | 
					            ]
 | 
				
			||||||
@@ -616,7 +616,7 @@
 | 
				
			|||||||
          "kind": "cvss",
 | 
					          "kind": "cvss",
 | 
				
			||||||
          "value": "CVSS_V3",
 | 
					          "value": "CVSS_V3",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": []
 | 
					          "fieldMask": []
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        "vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H",
 | 
					        "vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H",
 | 
				
			||||||
@@ -632,7 +632,7 @@
 | 
				
			|||||||
        "kind": "document",
 | 
					        "kind": "document",
 | 
				
			||||||
        "value": "https://osv.dev/vulnerability/GHSA-7rjr-3q55-vv33",
 | 
					        "value": "https://osv.dev/vulnerability/GHSA-7rjr-3q55-vv33",
 | 
				
			||||||
        "decisionReason": null,
 | 
					        "decisionReason": null,
 | 
				
			||||||
        "recordedAt": "2021-12-14T18:01:28+00:00",
 | 
					        "recordedAt": "2025-10-12T19:48:04.2355464+00:00",
 | 
				
			||||||
        "fieldMask": [
 | 
					        "fieldMask": [
 | 
				
			||||||
          "advisory"
 | 
					          "advisory"
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
@@ -642,7 +642,7 @@
 | 
				
			|||||||
        "kind": "mapping",
 | 
					        "kind": "mapping",
 | 
				
			||||||
        "value": "GHSA-7rjr-3q55-vv33",
 | 
					        "value": "GHSA-7rjr-3q55-vv33",
 | 
				
			||||||
        "decisionReason": null,
 | 
					        "decisionReason": null,
 | 
				
			||||||
        "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					        "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
        "fieldMask": [
 | 
					        "fieldMask": [
 | 
				
			||||||
          "advisory"
 | 
					          "advisory"
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
@@ -657,7 +657,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "http://www.openwall.com/lists/oss-security/2021/12/14/4",
 | 
					          "value": "http://www.openwall.com/lists/oss-security/2021/12/14/4",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -673,7 +673,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "http://www.openwall.com/lists/oss-security/2021/12/15/3",
 | 
					          "value": "http://www.openwall.com/lists/oss-security/2021/12/15/3",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -689,7 +689,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "http://www.openwall.com/lists/oss-security/2021/12/18/1",
 | 
					          "value": "http://www.openwall.com/lists/oss-security/2021/12/18/1",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -705,7 +705,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://cert-portal.siemens.com/productcert/pdf/ssa-397453.pdf",
 | 
					          "value": "https://cert-portal.siemens.com/productcert/pdf/ssa-397453.pdf",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -721,7 +721,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://cert-portal.siemens.com/productcert/pdf/ssa-479842.pdf",
 | 
					          "value": "https://cert-portal.siemens.com/productcert/pdf/ssa-479842.pdf",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -737,7 +737,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://cert-portal.siemens.com/productcert/pdf/ssa-661247.pdf",
 | 
					          "value": "https://cert-portal.siemens.com/productcert/pdf/ssa-661247.pdf",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -753,7 +753,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://cert-portal.siemens.com/productcert/pdf/ssa-714170.pdf",
 | 
					          "value": "https://cert-portal.siemens.com/productcert/pdf/ssa-714170.pdf",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -769,7 +769,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://github.com/advisories/GHSA-jfh8-c2jp-5v3q",
 | 
					          "value": "https://github.com/advisories/GHSA-jfh8-c2jp-5v3q",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -785,7 +785,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/EOKPQGV24RRBBI4TBZUDQMM4MEH7MXCY",
 | 
					          "value": "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/EOKPQGV24RRBBI4TBZUDQMM4MEH7MXCY",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -801,7 +801,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/SIG7FZULMNK2XF6FZRU4VWYDQXNMUGAJ",
 | 
					          "value": "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/SIG7FZULMNK2XF6FZRU4VWYDQXNMUGAJ",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -817,7 +817,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://logging.apache.org/log4j/2.x/security.html",
 | 
					          "value": "https://logging.apache.org/log4j/2.x/security.html",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -833,7 +833,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://nvd.nist.gov/vuln/detail/CVE-2021-45046",
 | 
					          "value": "https://nvd.nist.gov/vuln/detail/CVE-2021-45046",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -849,7 +849,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://psirt.global.sonicwall.com/vuln-detail/SNWLID-2021-0032",
 | 
					          "value": "https://psirt.global.sonicwall.com/vuln-detail/SNWLID-2021-0032",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -865,7 +865,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-apache-log4j-qRuKNEbd",
 | 
					          "value": "https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-apache-log4j-qRuKNEbd",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -881,7 +881,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://security.gentoo.org/glsa/202310-16",
 | 
					          "value": "https://security.gentoo.org/glsa/202310-16",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -897,7 +897,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://www.cve.org/CVERecord?id=CVE-2021-44228",
 | 
					          "value": "https://www.cve.org/CVERecord?id=CVE-2021-44228",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -913,7 +913,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://www.debian.org/security/2021/dsa-5022",
 | 
					          "value": "https://www.debian.org/security/2021/dsa-5022",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -929,7 +929,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://www.intel.com/content/www/us/en/security-center/advisory/intel-sa-00646.html",
 | 
					          "value": "https://www.intel.com/content/www/us/en/security-center/advisory/intel-sa-00646.html",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -945,7 +945,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://www.kb.cert.org/vuls/id/930724",
 | 
					          "value": "https://www.kb.cert.org/vuls/id/930724",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -961,7 +961,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://www.openwall.com/lists/oss-security/2021/12/14/4",
 | 
					          "value": "https://www.openwall.com/lists/oss-security/2021/12/14/4",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -977,7 +977,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://www.oracle.com/security-alerts/alert-cve-2021-44228.html",
 | 
					          "value": "https://www.oracle.com/security-alerts/alert-cve-2021-44228.html",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -993,7 +993,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://www.oracle.com/security-alerts/cpuapr2022.html",
 | 
					          "value": "https://www.oracle.com/security-alerts/cpuapr2022.html",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -1009,7 +1009,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://www.oracle.com/security-alerts/cpujan2022.html",
 | 
					          "value": "https://www.oracle.com/security-alerts/cpujan2022.html",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -1025,7 +1025,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://www.oracle.com/security-alerts/cpujul2022.html",
 | 
					          "value": "https://www.oracle.com/security-alerts/cpujul2022.html",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.862103+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2365076+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -1073,7 +1073,7 @@
 | 
				
			|||||||
              "kind": "range",
 | 
					              "kind": "range",
 | 
				
			||||||
              "value": "pkg:pypi/pyload-ng",
 | 
					              "value": "pkg:pypi/pyload-ng",
 | 
				
			||||||
              "decisionReason": null,
 | 
					              "decisionReason": null,
 | 
				
			||||||
              "recordedAt": "2025-10-12T12:01:51.8437105+00:00",
 | 
					              "recordedAt": "2025-10-12T19:48:04.2065811+00:00",
 | 
				
			||||||
              "fieldMask": [
 | 
					              "fieldMask": [
 | 
				
			||||||
                "affectedpackages[].versionranges[]"
 | 
					                "affectedpackages[].versionranges[]"
 | 
				
			||||||
              ]
 | 
					              ]
 | 
				
			||||||
@@ -1101,7 +1101,7 @@
 | 
				
			|||||||
            "kind": "affected",
 | 
					            "kind": "affected",
 | 
				
			||||||
            "value": "pkg:pypi/pyload-ng",
 | 
					            "value": "pkg:pypi/pyload-ng",
 | 
				
			||||||
            "decisionReason": null,
 | 
					            "decisionReason": null,
 | 
				
			||||||
            "recordedAt": "2025-10-12T12:01:51.8437105+00:00",
 | 
					            "recordedAt": "2025-10-12T19:48:04.2065811+00:00",
 | 
				
			||||||
            "fieldMask": [
 | 
					            "fieldMask": [
 | 
				
			||||||
              "affectedpackages[]"
 | 
					              "affectedpackages[]"
 | 
				
			||||||
            ]
 | 
					            ]
 | 
				
			||||||
@@ -1123,7 +1123,7 @@
 | 
				
			|||||||
          "kind": "cvss",
 | 
					          "kind": "cvss",
 | 
				
			||||||
          "value": "CVSS_V3",
 | 
					          "value": "CVSS_V3",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.8437105+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2065811+00:00",
 | 
				
			||||||
          "fieldMask": []
 | 
					          "fieldMask": []
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N",
 | 
					        "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N",
 | 
				
			||||||
@@ -1139,7 +1139,7 @@
 | 
				
			|||||||
        "kind": "document",
 | 
					        "kind": "document",
 | 
				
			||||||
        "value": "https://osv.dev/vulnerability/GHSA-cjjf-27cc-pvmv",
 | 
					        "value": "https://osv.dev/vulnerability/GHSA-cjjf-27cc-pvmv",
 | 
				
			||||||
        "decisionReason": null,
 | 
					        "decisionReason": null,
 | 
				
			||||||
        "recordedAt": "2025-10-09T15:19:48+00:00",
 | 
					        "recordedAt": "2025-10-12T19:48:04.2061911+00:00",
 | 
				
			||||||
        "fieldMask": [
 | 
					        "fieldMask": [
 | 
				
			||||||
          "advisory"
 | 
					          "advisory"
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
@@ -1149,7 +1149,7 @@
 | 
				
			|||||||
        "kind": "mapping",
 | 
					        "kind": "mapping",
 | 
				
			||||||
        "value": "GHSA-cjjf-27cc-pvmv",
 | 
					        "value": "GHSA-cjjf-27cc-pvmv",
 | 
				
			||||||
        "decisionReason": null,
 | 
					        "decisionReason": null,
 | 
				
			||||||
        "recordedAt": "2025-10-12T12:01:51.8437105+00:00",
 | 
					        "recordedAt": "2025-10-12T19:48:04.2065811+00:00",
 | 
				
			||||||
        "fieldMask": [
 | 
					        "fieldMask": [
 | 
				
			||||||
          "advisory"
 | 
					          "advisory"
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
@@ -1164,7 +1164,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://github.com/pyload/pyload",
 | 
					          "value": "https://github.com/pyload/pyload",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.8437105+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2065811+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -1180,7 +1180,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://github.com/pyload/pyload/commit/5823327d0b797161c7195a1f660266d30a69f0ca",
 | 
					          "value": "https://github.com/pyload/pyload/commit/5823327d0b797161c7195a1f660266d30a69f0ca",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.8437105+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2065811+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -1196,7 +1196,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://github.com/pyload/pyload/pull/4624",
 | 
					          "value": "https://github.com/pyload/pyload/pull/4624",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.8437105+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2065811+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -1212,7 +1212,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://github.com/pyload/pyload/security/advisories/GHSA-cjjf-27cc-pvmv",
 | 
					          "value": "https://github.com/pyload/pyload/security/advisories/GHSA-cjjf-27cc-pvmv",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.8437105+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.2065811+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -1260,7 +1260,7 @@
 | 
				
			|||||||
              "kind": "range",
 | 
					              "kind": "range",
 | 
				
			||||||
              "value": "pkg:pypi/social-auth-app-django",
 | 
					              "value": "pkg:pypi/social-auth-app-django",
 | 
				
			||||||
              "decisionReason": null,
 | 
					              "decisionReason": null,
 | 
				
			||||||
              "recordedAt": "2025-10-12T12:01:51.8047195+00:00",
 | 
					              "recordedAt": "2025-10-12T19:48:04.1231115+00:00",
 | 
				
			||||||
              "fieldMask": [
 | 
					              "fieldMask": [
 | 
				
			||||||
                "affectedpackages[].versionranges[]"
 | 
					                "affectedpackages[].versionranges[]"
 | 
				
			||||||
              ]
 | 
					              ]
 | 
				
			||||||
@@ -1288,7 +1288,7 @@
 | 
				
			|||||||
            "kind": "affected",
 | 
					            "kind": "affected",
 | 
				
			||||||
            "value": "pkg:pypi/social-auth-app-django",
 | 
					            "value": "pkg:pypi/social-auth-app-django",
 | 
				
			||||||
            "decisionReason": null,
 | 
					            "decisionReason": null,
 | 
				
			||||||
            "recordedAt": "2025-10-12T12:01:51.8047195+00:00",
 | 
					            "recordedAt": "2025-10-12T19:48:04.1231115+00:00",
 | 
				
			||||||
            "fieldMask": [
 | 
					            "fieldMask": [
 | 
				
			||||||
              "affectedpackages[]"
 | 
					              "affectedpackages[]"
 | 
				
			||||||
            ]
 | 
					            ]
 | 
				
			||||||
@@ -1311,7 +1311,7 @@
 | 
				
			|||||||
        "kind": "document",
 | 
					        "kind": "document",
 | 
				
			||||||
        "value": "https://osv.dev/vulnerability/GHSA-wv4w-6qv2-qqfg",
 | 
					        "value": "https://osv.dev/vulnerability/GHSA-wv4w-6qv2-qqfg",
 | 
				
			||||||
        "decisionReason": null,
 | 
					        "decisionReason": null,
 | 
				
			||||||
        "recordedAt": "2025-10-09T17:08:05+00:00",
 | 
					        "recordedAt": "2025-10-12T19:48:04.0743113+00:00",
 | 
				
			||||||
        "fieldMask": [
 | 
					        "fieldMask": [
 | 
				
			||||||
          "advisory"
 | 
					          "advisory"
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
@@ -1321,7 +1321,7 @@
 | 
				
			|||||||
        "kind": "mapping",
 | 
					        "kind": "mapping",
 | 
				
			||||||
        "value": "GHSA-wv4w-6qv2-qqfg",
 | 
					        "value": "GHSA-wv4w-6qv2-qqfg",
 | 
				
			||||||
        "decisionReason": null,
 | 
					        "decisionReason": null,
 | 
				
			||||||
        "recordedAt": "2025-10-12T12:01:51.8047195+00:00",
 | 
					        "recordedAt": "2025-10-12T19:48:04.1231115+00:00",
 | 
				
			||||||
        "fieldMask": [
 | 
					        "fieldMask": [
 | 
				
			||||||
          "advisory"
 | 
					          "advisory"
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
@@ -1336,7 +1336,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://github.com/python-social-auth/social-app-django",
 | 
					          "value": "https://github.com/python-social-auth/social-app-django",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.8047195+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.1231115+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -1352,7 +1352,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://github.com/python-social-auth/social-app-django/commit/10c80e2ebabeccd4e9c84ad0e16e1db74148ed4c",
 | 
					          "value": "https://github.com/python-social-auth/social-app-django/commit/10c80e2ebabeccd4e9c84ad0e16e1db74148ed4c",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.8047195+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.1231115+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -1368,7 +1368,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://github.com/python-social-auth/social-app-django/issues/220",
 | 
					          "value": "https://github.com/python-social-auth/social-app-django/issues/220",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.8047195+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.1231115+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -1384,7 +1384,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://github.com/python-social-auth/social-app-django/issues/231",
 | 
					          "value": "https://github.com/python-social-auth/social-app-django/issues/231",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.8047195+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.1231115+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -1400,7 +1400,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://github.com/python-social-auth/social-app-django/issues/634",
 | 
					          "value": "https://github.com/python-social-auth/social-app-django/issues/634",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.8047195+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.1231115+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -1416,7 +1416,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://github.com/python-social-auth/social-app-django/pull/803",
 | 
					          "value": "https://github.com/python-social-auth/social-app-django/pull/803",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.8047195+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.1231115+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
@@ -1432,7 +1432,7 @@
 | 
				
			|||||||
          "kind": "reference",
 | 
					          "kind": "reference",
 | 
				
			||||||
          "value": "https://github.com/python-social-auth/social-app-django/security/advisories/GHSA-wv4w-6qv2-qqfg",
 | 
					          "value": "https://github.com/python-social-auth/social-app-django/security/advisories/GHSA-wv4w-6qv2-qqfg",
 | 
				
			||||||
          "decisionReason": null,
 | 
					          "decisionReason": null,
 | 
				
			||||||
          "recordedAt": "2025-10-12T12:01:51.8047195+00:00",
 | 
					          "recordedAt": "2025-10-12T19:48:04.1231115+00:00",
 | 
				
			||||||
          "fieldMask": [
 | 
					          "fieldMask": [
 | 
				
			||||||
            "references[]"
 | 
					            "references[]"
 | 
				
			||||||
          ]
 | 
					          ]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,11 +1,14 @@
 | 
				
			|||||||
using System;
 | 
					using System;
 | 
				
			||||||
using System.Collections.Generic;
 | 
					using System.Collections.Generic;
 | 
				
			||||||
using System.Text.Json;
 | 
					using System.Text.Json;
 | 
				
			||||||
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
 | 
					using System.Reflection;
 | 
				
			||||||
using MongoDB.Bson;
 | 
					using MongoDB.Bson;
 | 
				
			||||||
using StellaOps.Feedser.Models;
 | 
					using StellaOps.Feedser.Models;
 | 
				
			||||||
using StellaOps.Feedser.Source.Common;
 | 
					using StellaOps.Feedser.Source.Common;
 | 
				
			||||||
using StellaOps.Feedser.Source.Osv;
 | 
					using StellaOps.Feedser.Source.Osv;
 | 
				
			||||||
using StellaOps.Feedser.Source.Osv.Internal;
 | 
					using StellaOps.Feedser.Source.Osv.Internal;
 | 
				
			||||||
 | 
					using StellaOps.Feedser.Normalization.Identifiers;
 | 
				
			||||||
using StellaOps.Feedser.Storage.Mongo.Documents;
 | 
					using StellaOps.Feedser.Storage.Mongo.Documents;
 | 
				
			||||||
using StellaOps.Feedser.Storage.Mongo.Dtos;
 | 
					using StellaOps.Feedser.Storage.Mongo.Dtos;
 | 
				
			||||||
using Xunit;
 | 
					using Xunit;
 | 
				
			||||||
@@ -120,4 +123,78 @@ public sealed class OsvMapperTests
 | 
				
			|||||||
        Assert.Single(advisory.CvssMetrics);
 | 
					        Assert.Single(advisory.CvssMetrics);
 | 
				
			||||||
        Assert.Equal("3.1", advisory.CvssMetrics[0].Version);
 | 
					        Assert.Equal("3.1", advisory.CvssMetrics[0].Version);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Theory]
 | 
				
			||||||
 | 
					    [InlineData("Go", "github.com/example/project", "pkg:golang/github.com/example/project")]
 | 
				
			||||||
 | 
					    [InlineData("PyPI", "social_auth_app_django", "pkg:pypi/social-auth-app-django")]
 | 
				
			||||||
 | 
					    [InlineData("npm", "@Scope/Package", "pkg:npm/%40scope/package")]
 | 
				
			||||||
 | 
					    [InlineData("Maven", "org.example:library", "pkg:maven/org.example/library")]
 | 
				
			||||||
 | 
					    [InlineData("crates", "serde", "pkg:cargo/serde")]
 | 
				
			||||||
 | 
					    public void Map_InfersCanonicalPackageUrlWhenPurlMissing(string ecosystem, string packageName, string expectedIdentifier)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var dto = new OsvVulnerabilityDto
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Id = $"OSV-{ecosystem}-PURL",
 | 
				
			||||||
 | 
					            Summary = "Test advisory",
 | 
				
			||||||
 | 
					            Details = "Details",
 | 
				
			||||||
 | 
					            Published = DateTimeOffset.UtcNow.AddDays(-1),
 | 
				
			||||||
 | 
					            Modified = DateTimeOffset.UtcNow,
 | 
				
			||||||
 | 
					            Affected = new[]
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                new OsvAffectedPackageDto
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    Package = new OsvPackageDto
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        Ecosystem = ecosystem,
 | 
				
			||||||
 | 
					                        Name = packageName,
 | 
				
			||||||
 | 
					                        Purl = null,
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                    Ranges = null,
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (string.Equals(ecosystem, "npm", StringComparison.OrdinalIgnoreCase))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Assert.True(IdentifierNormalizer.TryNormalizePackageUrl("pkg:npm/%40scope/package", out var canonical));
 | 
				
			||||||
 | 
					            Assert.Equal(expectedIdentifier, canonical);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var method = typeof(OsvMapper).GetMethod("DetermineIdentifier", BindingFlags.NonPublic | BindingFlags.Static);
 | 
				
			||||||
 | 
					        Assert.NotNull(method);
 | 
				
			||||||
 | 
					        var directIdentifier = method!.Invoke(null, new object?[] { dto.Affected![0].Package!, ecosystem }) as string;
 | 
				
			||||||
 | 
					        Assert.Equal(expectedIdentifier, directIdentifier);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var (document, dtoRecord) = CreateDocumentAndDtoRecord(dto, ecosystem);
 | 
				
			||||||
 | 
					        var advisory = OsvMapper.Map(dto, document, dtoRecord, ecosystem);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var affected = Assert.Single(advisory.AffectedPackages);
 | 
				
			||||||
 | 
					        Assert.Equal(expectedIdentifier, affected.Identifier);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static (DocumentRecord Document, DtoRecord DtoRecord) CreateDocumentAndDtoRecord(OsvVulnerabilityDto dto, string ecosystem)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var recordedAt = DateTimeOffset.UtcNow;
 | 
				
			||||||
 | 
					        var document = new DocumentRecord(
 | 
				
			||||||
 | 
					            Guid.NewGuid(),
 | 
				
			||||||
 | 
					            OsvConnectorPlugin.SourceName,
 | 
				
			||||||
 | 
					            $"https://osv.dev/vulnerability/{dto.Id}",
 | 
				
			||||||
 | 
					            recordedAt,
 | 
				
			||||||
 | 
					            "sha256",
 | 
				
			||||||
 | 
					            DocumentStatuses.PendingParse,
 | 
				
			||||||
 | 
					            "application/json",
 | 
				
			||||||
 | 
					            null,
 | 
				
			||||||
 | 
					            new Dictionary<string, string>(StringComparer.Ordinal)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                ["osv.ecosystem"] = ecosystem,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            null,
 | 
				
			||||||
 | 
					            dto.Modified,
 | 
				
			||||||
 | 
					            null,
 | 
				
			||||||
 | 
					            null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var payload = new BsonDocument("id", dto.Id);
 | 
				
			||||||
 | 
					        var dtoRecord = new DtoRecord(Guid.NewGuid(), document.Id, OsvConnectorPlugin.SourceName, "osv.v1", payload, recordedAt);
 | 
				
			||||||
 | 
					        return (document, dtoRecord);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,6 +16,26 @@ internal static class OsvMapper
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
    private static readonly string[] SeverityOrder = { "none", "low", "medium", "high", "critical" };
 | 
					    private static readonly string[] SeverityOrder = { "none", "low", "medium", "high", "critical" };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static readonly IReadOnlyDictionary<string, Func<string, string>> PackageUrlBuilders =
 | 
				
			||||||
 | 
					        new Dictionary<string, Func<string, string>>(StringComparer.OrdinalIgnoreCase)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            ["pypi"] = static name => $"pkg:pypi/{NormalizePyPiName(name)}",
 | 
				
			||||||
 | 
					            ["python"] = static name => $"pkg:pypi/{NormalizePyPiName(name)}",
 | 
				
			||||||
 | 
					            ["maven"] = static name => $"pkg:maven/{NormalizeMavenName(name)}",
 | 
				
			||||||
 | 
					            ["go"] = static name => $"pkg:golang/{NormalizeGoName(name)}",
 | 
				
			||||||
 | 
					            ["golang"] = static name => $"pkg:golang/{NormalizeGoName(name)}",
 | 
				
			||||||
 | 
					            ["crates"] = static name => $"pkg:cargo/{NormalizeCratesName(name)}",
 | 
				
			||||||
 | 
					            ["crates.io"] = static name => $"pkg:cargo/{NormalizeCratesName(name)}",
 | 
				
			||||||
 | 
					            ["cargo"] = static name => $"pkg:cargo/{NormalizeCratesName(name)}",
 | 
				
			||||||
 | 
					            ["nuget"] = static name => $"pkg:nuget/{NormalizeNugetName(name)}",
 | 
				
			||||||
 | 
					            ["rubygems"] = static name => $"pkg:gem/{NormalizeRubyName(name)}",
 | 
				
			||||||
 | 
					            ["gem"] = static name => $"pkg:gem/{NormalizeRubyName(name)}",
 | 
				
			||||||
 | 
					            ["packagist"] = static name => $"pkg:composer/{NormalizeComposerName(name)}",
 | 
				
			||||||
 | 
					            ["composer"] = static name => $"pkg:composer/{NormalizeComposerName(name)}",
 | 
				
			||||||
 | 
					            ["hex"] = static name => $"pkg:hex/{NormalizeHexName(name)}",
 | 
				
			||||||
 | 
					            ["hex.pm"] = static name => $"pkg:hex/{NormalizeHexName(name)}",
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public static Advisory Map(
 | 
					    public static Advisory Map(
 | 
				
			||||||
        OsvVulnerabilityDto dto,
 | 
					        OsvVulnerabilityDto dto,
 | 
				
			||||||
        DocumentRecord document,
 | 
					        DocumentRecord document,
 | 
				
			||||||
@@ -418,21 +438,32 @@ internal static class OsvMapper
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    private static string? DetermineIdentifier(OsvPackageDto package, string ecosystem)
 | 
					    private static string? DetermineIdentifier(OsvPackageDto package, string ecosystem)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (!string.IsNullOrWhiteSpace(package.Purl)
 | 
					        if (IdentifierNormalizer.TryNormalizePackageUrl(package.Purl, out var normalized))
 | 
				
			||||||
            && IdentifierNormalizer.TryNormalizePackageUrl(package.Purl, out var normalized))
 | 
					 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            return normalized;
 | 
					            return normalized;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (!string.IsNullOrWhiteSpace(package.Name))
 | 
					        var name = Validation.TrimToNull(package.Name);
 | 
				
			||||||
 | 
					        if (name is null)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            var name = package.Name.Trim();
 | 
					            return null;
 | 
				
			||||||
            return string.IsNullOrWhiteSpace(package.Ecosystem)
 | 
					 | 
				
			||||||
                ? $"{ecosystem}:{name}"
 | 
					 | 
				
			||||||
                : $"{package.Ecosystem.Trim()}:{name}";
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return null;
 | 
					        var ecosystemHint = Validation.TrimToNull(package.Ecosystem) ?? Validation.TrimToNull(ecosystem);
 | 
				
			||||||
 | 
					        if (ecosystemHint is not null
 | 
				
			||||||
 | 
					            && string.Equals(ecosystemHint, "npm", StringComparison.OrdinalIgnoreCase)
 | 
				
			||||||
 | 
					            && TryBuildNpmPackageUrl(name, out normalized))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return normalized;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (TryBuildCanonicalPackageUrl(ecosystemHint, name, out normalized))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return normalized;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var fallbackEcosystem = ecosystemHint ?? Validation.TrimToNull(ecosystem) ?? "osv";
 | 
				
			||||||
 | 
					        return $"{fallbackEcosystem}:{name}";
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private static IReadOnlyList<CvssMetric> BuildCvssMetrics(OsvVulnerabilityDto dto, DateTimeOffset recordedAt, out string? severity)
 | 
					    private static IReadOnlyList<CvssMetric> BuildCvssMetrics(OsvVulnerabilityDto dto, DateTimeOffset recordedAt, out string? severity)
 | 
				
			||||||
@@ -539,4 +570,77 @@ internal static class OsvMapper
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        return StringComparer.Ordinal.Compare(left, right);
 | 
					        return StringComparer.Ordinal.Compare(left, right);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static bool TryBuildCanonicalPackageUrl(string? ecosystem, string name, out string? canonical)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        canonical = null;
 | 
				
			||||||
 | 
					        var trimmedEcosystem = Validation.TrimToNull(ecosystem);
 | 
				
			||||||
 | 
					        if (trimmedEcosystem is null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!PackageUrlBuilders.TryGetValue(trimmedEcosystem, out var factory))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var candidate = factory(name.Trim());
 | 
				
			||||||
 | 
					        return IdentifierNormalizer.TryNormalizePackageUrl(candidate, out canonical);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static string NormalizePyPiName(string name) => name.Trim().Replace('_', '-');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static bool TryBuildNpmPackageUrl(string name, out string? canonical)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        canonical = null;
 | 
				
			||||||
 | 
					        var trimmed = name.Trim();
 | 
				
			||||||
 | 
					        if (trimmed.Length == 0)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (trimmed[0] == '@')
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var slashIndex = trimmed.IndexOf('/', 1);
 | 
				
			||||||
 | 
					            if (slashIndex <= 1 || slashIndex >= trimmed.Length - 1)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                return false;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            var scope = trimmed[..slashIndex].ToLowerInvariant();
 | 
				
			||||||
 | 
					            var package = trimmed[(slashIndex + 1)..].ToLowerInvariant();
 | 
				
			||||||
 | 
					            var candidate = $"pkg:npm/{Uri.EscapeDataString(scope)}/{Uri.EscapeDataString(package)}";
 | 
				
			||||||
 | 
					            return IdentifierNormalizer.TryNormalizePackageUrl(candidate, out canonical);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (trimmed.Contains('/', StringComparison.Ordinal))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var normalized = trimmed.ToLowerInvariant();
 | 
				
			||||||
 | 
					        var simpleCandidate = $"pkg:npm/{Uri.EscapeDataString(normalized)}";
 | 
				
			||||||
 | 
					        return IdentifierNormalizer.TryNormalizePackageUrl(simpleCandidate, out canonical);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static string NormalizeMavenName(string name)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var trimmed = name.Trim();
 | 
				
			||||||
 | 
					        return trimmed.Contains(':', StringComparison.Ordinal)
 | 
				
			||||||
 | 
					            ? trimmed.Replace(':', '/')
 | 
				
			||||||
 | 
					            : trimmed.Replace('\\', '/');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static string NormalizeGoName(string name) => name.Trim();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static string NormalizeCratesName(string name) => name.Trim();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static string NormalizeNugetName(string name) => name.Trim();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static string NormalizeRubyName(string name) => name.Trim();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static string NormalizeComposerName(string name) => name.Trim();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static string NormalizeHexName(string name) => name.Trim();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,7 +12,7 @@
 | 
				
			|||||||
|Connector DI routine & job registration|BE-Conn-OSV|Core|**DONE** – DI routine registers fetch/parse/map jobs with scheduler.|
 | 
					|Connector DI routine & job registration|BE-Conn-OSV|Core|**DONE** – DI routine registers fetch/parse/map jobs with scheduler.|
 | 
				
			||||||
|Implement OSV fetch/parse/map skeleton|BE-Conn-OSV|Source.Common|**DONE** – connector now persists documents, DTOs, and canonical advisories.|
 | 
					|Implement OSV fetch/parse/map skeleton|BE-Conn-OSV|Source.Common|**DONE** – connector now persists documents, DTOs, and canonical advisories.|
 | 
				
			||||||
|FEEDCONN-OSV-02-004 OSV references & credits alignment|BE-Conn-OSV|Models `FEEDMODELS-SCHEMA-01-002`|**DONE (2025-10-11)** – Mapper normalizes references with provenance masks, emits advisory credits, and regression fixtures/assertions cover the new fields.|
 | 
					|FEEDCONN-OSV-02-004 OSV references & credits alignment|BE-Conn-OSV|Models `FEEDMODELS-SCHEMA-01-002`|**DONE (2025-10-11)** – Mapper normalizes references with provenance masks, emits advisory credits, and regression fixtures/assertions cover the new fields.|
 | 
				
			||||||
|FEEDCONN-OSV-02-005 Fixture updater workflow|BE-Conn-OSV, QA|Docs|TODO – Document `tools/FixtureUpdater`, add parity regression steps, and ensure future refreshes capture credit metadata consistently.|
 | 
					|FEEDCONN-OSV-02-005 Fixture updater workflow|BE-Conn-OSV, QA|Docs|**DONE (2025-10-12)** – Canonical PURL derivation now covers Go + scoped npm advisories without upstream `purl`; legacy invalid npm names still fall back to `ecosystem:name`. OSV/GHSA/NVD suites and normalization/storage tests rerun clean.|
 | 
				
			||||||
|FEEDCONN-OSV-02-003 Normalized versions rollout|BE-Conn-OSV|Models `FEEDMODELS-SCHEMA-01-003`, Normalization playbook|**DONE (2025-10-11)** – `OsvMapper` now emits SemVer primitives + normalized rules with `osv:{ecosystem}:{advisoryId}:{identifier}` notes; npm/PyPI/Parity fixtures refreshed; merge coordination pinged (OSV handoff).|
 | 
					|FEEDCONN-OSV-02-003 Normalized versions rollout|BE-Conn-OSV|Models `FEEDMODELS-SCHEMA-01-003`, Normalization playbook|**DONE (2025-10-11)** – `OsvMapper` now emits SemVer primitives + normalized rules with `osv:{ecosystem}:{advisoryId}:{identifier}` notes; npm/PyPI/Parity fixtures refreshed; merge coordination pinged (OSV handoff).|
 | 
				
			||||||
|FEEDCONN-OSV-04-003 Parity fixture refresh|QA, BE-Conn-OSV|Normalized versions rollout, GHSA parity tests|**DONE (2025-10-12)** – Parity fixtures include normalizedVersions notes (`osv:<ecosystem>:<id>:<purl>`); regression math rerun via `dotnet test src/StellaOps.Feedser.Source.Osv.Tests` and docs flagged for workflow sync.|
 | 
					|FEEDCONN-OSV-04-003 Parity fixture refresh|QA, BE-Conn-OSV|Normalized versions rollout, GHSA parity tests|**DONE (2025-10-12)** – Parity fixtures include normalizedVersions notes (`osv:<ecosystem>:<id>:<purl>`); regression math rerun via `dotnet test src/StellaOps.Feedser.Source.Osv.Tests` and docs flagged for workflow sync.|
 | 
				
			||||||
|FEEDCONN-OSV-04-002 Conflict regression fixtures|BE-Conn-OSV, QA|Merge `FEEDMERGE-ENGINE-04-001`|**DONE (2025-10-12)** – Added `conflict-osv.canonical.json` + regression asserting SemVer range + CVSS medium severity; dataset matches GHSA/NVD fixtures for merge tests. Validation: `dotnet test src/StellaOps.Feedser.Source.Osv.Tests/StellaOps.Feedser.Source.Osv.Tests.csproj --filter OsvConflictFixtureTests`.|
 | 
					|FEEDCONN-OSV-04-002 Conflict regression fixtures|BE-Conn-OSV, QA|Merge `FEEDMERGE-ENGINE-04-001`|**DONE (2025-10-12)** – Added `conflict-osv.canonical.json` + regression asserting SemVer range + CVSS medium severity; dataset matches GHSA/NVD fixtures for merge tests. Validation: `dotnet test src/StellaOps.Feedser.Source.Osv.Tests/StellaOps.Feedser.Source.Osv.Tests.csproj --filter OsvConflictFixtureTests`.|
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -62,7 +62,12 @@ public sealed class AdvisoryStorePerformanceTests : IClassFixture<MongoIntegrati
 | 
				
			|||||||
            await bootstrapper.InitializeAsync(CancellationToken.None);
 | 
					            await bootstrapper.InitializeAsync(CancellationToken.None);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            var aliasStore = new AliasStore(database, NullLogger<AliasStore>.Instance);
 | 
					            var aliasStore = new AliasStore(database, NullLogger<AliasStore>.Instance);
 | 
				
			||||||
            var store = new AdvisoryStore(database, aliasStore, NullLogger<AdvisoryStore>.Instance, TimeProvider.System);
 | 
					            var store = new AdvisoryStore(
 | 
				
			||||||
 | 
					                database,
 | 
				
			||||||
 | 
					                aliasStore,
 | 
				
			||||||
 | 
					                NullLogger<AdvisoryStore>.Instance,
 | 
				
			||||||
 | 
					                Options.Create(new MongoStorageOptions()),
 | 
				
			||||||
 | 
					                TimeProvider.System);
 | 
				
			||||||
            using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45));
 | 
					            using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Warm up collections (indexes, serialization caches) so perf timings exclude one-time setup work.
 | 
					            // Warm up collections (indexes, serialization caches) so perf timings exclude one-time setup work.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,6 +2,7 @@ using System;
 | 
				
			|||||||
using System.Collections.Generic;
 | 
					using System.Collections.Generic;
 | 
				
			||||||
using System.Linq;
 | 
					using System.Linq;
 | 
				
			||||||
using Microsoft.Extensions.Logging.Abstractions;
 | 
					using Microsoft.Extensions.Logging.Abstractions;
 | 
				
			||||||
 | 
					using Microsoft.Extensions.Options;
 | 
				
			||||||
using MongoDB.Driver;
 | 
					using MongoDB.Driver;
 | 
				
			||||||
using StellaOps.Feedser.Models;
 | 
					using StellaOps.Feedser.Models;
 | 
				
			||||||
using StellaOps.Feedser.Storage.Mongo.Advisories;
 | 
					using StellaOps.Feedser.Storage.Mongo.Advisories;
 | 
				
			||||||
@@ -26,7 +27,12 @@ public sealed class AdvisoryStoreTests : IClassFixture<MongoIntegrationFixture>
 | 
				
			|||||||
        await DropCollectionAsync(MongoStorageDefaults.Collections.Alias);
 | 
					        await DropCollectionAsync(MongoStorageDefaults.Collections.Alias);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var aliasStore = new AliasStore(_fixture.Database, NullLogger<AliasStore>.Instance);
 | 
					        var aliasStore = new AliasStore(_fixture.Database, NullLogger<AliasStore>.Instance);
 | 
				
			||||||
        var store = new AdvisoryStore(_fixture.Database, aliasStore, NullLogger<AdvisoryStore>.Instance, TimeProvider.System);
 | 
					        var store = new AdvisoryStore(
 | 
				
			||||||
 | 
					            _fixture.Database,
 | 
				
			||||||
 | 
					            aliasStore,
 | 
				
			||||||
 | 
					            NullLogger<AdvisoryStore>.Instance,
 | 
				
			||||||
 | 
					            Options.Create(new MongoStorageOptions()),
 | 
				
			||||||
 | 
					            TimeProvider.System);
 | 
				
			||||||
        var advisory = new Advisory(
 | 
					        var advisory = new Advisory(
 | 
				
			||||||
            advisoryKey: "ADV-1",
 | 
					            advisoryKey: "ADV-1",
 | 
				
			||||||
            title: "Sample Advisory",
 | 
					            title: "Sample Advisory",
 | 
				
			||||||
@@ -63,7 +69,12 @@ public sealed class AdvisoryStoreTests : IClassFixture<MongoIntegrationFixture>
 | 
				
			|||||||
        await DropCollectionAsync(MongoStorageDefaults.Collections.Alias);
 | 
					        await DropCollectionAsync(MongoStorageDefaults.Collections.Alias);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var aliasStore = new AliasStore(_fixture.Database, NullLogger<AliasStore>.Instance);
 | 
					        var aliasStore = new AliasStore(_fixture.Database, NullLogger<AliasStore>.Instance);
 | 
				
			||||||
        var store = new AdvisoryStore(_fixture.Database, aliasStore, NullLogger<AdvisoryStore>.Instance, TimeProvider.System);
 | 
					        var store = new AdvisoryStore(
 | 
				
			||||||
 | 
					            _fixture.Database,
 | 
				
			||||||
 | 
					            aliasStore,
 | 
				
			||||||
 | 
					            NullLogger<AdvisoryStore>.Instance,
 | 
				
			||||||
 | 
					            Options.Create(new MongoStorageOptions()),
 | 
				
			||||||
 | 
					            TimeProvider.System);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var recordedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
 | 
					        var recordedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
 | 
				
			||||||
        var provenance = new AdvisoryProvenance("source-x", "mapper", "payload-123", recordedAt);
 | 
					        var provenance = new AdvisoryProvenance("source-x", "mapper", "payload-123", recordedAt);
 | 
				
			||||||
@@ -148,6 +159,138 @@ public sealed class AdvisoryStoreTests : IClassFixture<MongoIntegrationFixture>
 | 
				
			|||||||
        Assert.Equal(rangePrimitives.VendorExtensions, fetchedRange.Primitives.VendorExtensions);
 | 
					        Assert.Equal(rangePrimitives.VendorExtensions, fetchedRange.Primitives.VendorExtensions);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Fact]
 | 
				
			||||||
 | 
					    public async Task UpsertAsync_SkipsNormalizedVersionsWhenFeatureDisabled()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        await DropCollectionAsync(MongoStorageDefaults.Collections.Advisory);
 | 
				
			||||||
 | 
					        await DropCollectionAsync(MongoStorageDefaults.Collections.Alias);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var aliasStore = new AliasStore(_fixture.Database, NullLogger<AliasStore>.Instance);
 | 
				
			||||||
 | 
					        var store = new AdvisoryStore(
 | 
				
			||||||
 | 
					            _fixture.Database,
 | 
				
			||||||
 | 
					            aliasStore,
 | 
				
			||||||
 | 
					            NullLogger<AdvisoryStore>.Instance,
 | 
				
			||||||
 | 
					            Options.Create(new MongoStorageOptions { EnableSemVerStyle = false }),
 | 
				
			||||||
 | 
					            TimeProvider.System);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var advisory = CreateNormalizedAdvisory("ADV-NORM-DISABLED");
 | 
				
			||||||
 | 
					        await store.UpsertAsync(advisory, CancellationToken.None);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var document = await _fixture.Database
 | 
				
			||||||
 | 
					            .GetCollection<AdvisoryDocument>(MongoStorageDefaults.Collections.Advisory)
 | 
				
			||||||
 | 
					            .Find(x => x.AdvisoryKey == advisory.AdvisoryKey)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Assert.NotNull(document);
 | 
				
			||||||
 | 
					        Assert.True(document!.NormalizedVersions is null || document.NormalizedVersions.Count == 0);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Fact]
 | 
				
			||||||
 | 
					    public async Task UpsertAsync_PopulatesNormalizedVersionsWhenFeatureEnabled()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        await DropCollectionAsync(MongoStorageDefaults.Collections.Advisory);
 | 
				
			||||||
 | 
					        await DropCollectionAsync(MongoStorageDefaults.Collections.Alias);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var aliasStore = new AliasStore(_fixture.Database, NullLogger<AliasStore>.Instance);
 | 
				
			||||||
 | 
					        var store = new AdvisoryStore(
 | 
				
			||||||
 | 
					            _fixture.Database,
 | 
				
			||||||
 | 
					            aliasStore,
 | 
				
			||||||
 | 
					            NullLogger<AdvisoryStore>.Instance,
 | 
				
			||||||
 | 
					            Options.Create(new MongoStorageOptions { EnableSemVerStyle = true }),
 | 
				
			||||||
 | 
					            TimeProvider.System);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var advisory = CreateNormalizedAdvisory("ADV-NORM-ENABLED");
 | 
				
			||||||
 | 
					        await store.UpsertAsync(advisory, CancellationToken.None);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var document = await _fixture.Database
 | 
				
			||||||
 | 
					            .GetCollection<AdvisoryDocument>(MongoStorageDefaults.Collections.Advisory)
 | 
				
			||||||
 | 
					            .Find(x => x.AdvisoryKey == advisory.AdvisoryKey)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Assert.NotNull(document);
 | 
				
			||||||
 | 
					        var normalizedCollection = document!.NormalizedVersions;
 | 
				
			||||||
 | 
					        Assert.NotNull(normalizedCollection);
 | 
				
			||||||
 | 
					        var normalized = Assert.Single(normalizedCollection!);
 | 
				
			||||||
 | 
					        Assert.Equal("pkg:npm/example", normalized.PackageId);
 | 
				
			||||||
 | 
					        Assert.Equal(AffectedPackageTypes.SemVer, normalized.PackageType);
 | 
				
			||||||
 | 
					        Assert.Equal(NormalizedVersionSchemes.SemVer, normalized.Scheme);
 | 
				
			||||||
 | 
					        Assert.Equal(NormalizedVersionRuleTypes.Range, normalized.Type);
 | 
				
			||||||
 | 
					        Assert.Equal("range", normalized.Style);
 | 
				
			||||||
 | 
					        Assert.Equal("1.0.0", normalized.Min);
 | 
				
			||||||
 | 
					        Assert.True(normalized.MinInclusive);
 | 
				
			||||||
 | 
					        Assert.Equal("2.0.0", normalized.Max);
 | 
				
			||||||
 | 
					        Assert.False(normalized.MaxInclusive);
 | 
				
			||||||
 | 
					        Assert.Null(normalized.Value);
 | 
				
			||||||
 | 
					        Assert.Equal("ghsa:pkg:npm/example", normalized.Notes);
 | 
				
			||||||
 | 
					        Assert.Equal("range-decision", normalized.DecisionReason);
 | 
				
			||||||
 | 
					        Assert.Equal(">= 1.0.0 < 2.0.0", normalized.Constraint);
 | 
				
			||||||
 | 
					        Assert.Equal("ghsa", normalized.Source);
 | 
				
			||||||
 | 
					        Assert.Equal(new DateTime(2025, 10, 9, 0, 0, 0, DateTimeKind.Utc), normalized.RecordedAtUtc);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static Advisory CreateNormalizedAdvisory(string advisoryKey)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var recordedAt = new DateTimeOffset(2025, 10, 9, 0, 0, 0, TimeSpan.Zero);
 | 
				
			||||||
 | 
					        var rangeProvenance = new AdvisoryProvenance(
 | 
				
			||||||
 | 
					            source: "ghsa",
 | 
				
			||||||
 | 
					            kind: "affected-range",
 | 
				
			||||||
 | 
					            value: "pkg:npm/example",
 | 
				
			||||||
 | 
					            recordedAt: recordedAt,
 | 
				
			||||||
 | 
					            fieldMask: new[] { "affectedpackages[].versionranges[]" },
 | 
				
			||||||
 | 
					            decisionReason: "range-decision");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var semverPrimitive = new SemVerPrimitive(
 | 
				
			||||||
 | 
					            Introduced: "1.0.0",
 | 
				
			||||||
 | 
					            IntroducedInclusive: true,
 | 
				
			||||||
 | 
					            Fixed: "2.0.0",
 | 
				
			||||||
 | 
					            FixedInclusive: false,
 | 
				
			||||||
 | 
					            LastAffected: null,
 | 
				
			||||||
 | 
					            LastAffectedInclusive: false,
 | 
				
			||||||
 | 
					            ConstraintExpression: ">= 1.0.0 < 2.0.0");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var normalizedRule = semverPrimitive.ToNormalizedVersionRule("ghsa:pkg:npm/example")!;
 | 
				
			||||||
 | 
					        var versionRange = new AffectedVersionRange(
 | 
				
			||||||
 | 
					            rangeKind: "semver",
 | 
				
			||||||
 | 
					            introducedVersion: "1.0.0",
 | 
				
			||||||
 | 
					            fixedVersion: "2.0.0",
 | 
				
			||||||
 | 
					            lastAffectedVersion: null,
 | 
				
			||||||
 | 
					            rangeExpression: ">= 1.0.0 < 2.0.0",
 | 
				
			||||||
 | 
					            provenance: rangeProvenance,
 | 
				
			||||||
 | 
					            primitives: new RangePrimitives(semverPrimitive, null, null, null));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var package = new AffectedPackage(
 | 
				
			||||||
 | 
					            type: AffectedPackageTypes.SemVer,
 | 
				
			||||||
 | 
					            identifier: "pkg:npm/example",
 | 
				
			||||||
 | 
					            platform: "npm",
 | 
				
			||||||
 | 
					            versionRanges: new[] { versionRange },
 | 
				
			||||||
 | 
					            statuses: Array.Empty<AffectedPackageStatus>(),
 | 
				
			||||||
 | 
					            provenance: new[] { rangeProvenance },
 | 
				
			||||||
 | 
					            normalizedVersions: new[] { normalizedRule });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var advisoryProvenance = new AdvisoryProvenance(
 | 
				
			||||||
 | 
					            source: "ghsa",
 | 
				
			||||||
 | 
					            kind: "document",
 | 
				
			||||||
 | 
					            value: advisoryKey,
 | 
				
			||||||
 | 
					            recordedAt: recordedAt,
 | 
				
			||||||
 | 
					            fieldMask: new[] { "advisory" },
 | 
				
			||||||
 | 
					            decisionReason: "document-decision");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return new Advisory(
 | 
				
			||||||
 | 
					            advisoryKey: advisoryKey,
 | 
				
			||||||
 | 
					            title: "Normalized advisory",
 | 
				
			||||||
 | 
					            summary: "Contains normalized versions for storage testing.",
 | 
				
			||||||
 | 
					            language: "en",
 | 
				
			||||||
 | 
					            published: recordedAt,
 | 
				
			||||||
 | 
					            modified: recordedAt,
 | 
				
			||||||
 | 
					            severity: "medium",
 | 
				
			||||||
 | 
					            exploitKnown: false,
 | 
				
			||||||
 | 
					            aliases: new[] { $"{advisoryKey}-ALIAS" },
 | 
				
			||||||
 | 
					            references: Array.Empty<AdvisoryReference>(),
 | 
				
			||||||
 | 
					            affectedPackages: new[] { package },
 | 
				
			||||||
 | 
					            cvssMetrics: Array.Empty<CvssMetric>(),
 | 
				
			||||||
 | 
					            provenance: new[] { advisoryProvenance });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private async Task DropCollectionAsync(string collectionName)
 | 
					    private async Task DropCollectionAsync(string collectionName)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,97 @@
 | 
				
			|||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using System.Linq;
 | 
				
			||||||
 | 
					using System.Threading;
 | 
				
			||||||
 | 
					using Microsoft.Extensions.Logging.Abstractions;
 | 
				
			||||||
 | 
					using Microsoft.Extensions.Options;
 | 
				
			||||||
 | 
					using MongoDB.Bson;
 | 
				
			||||||
 | 
					using MongoDB.Driver;
 | 
				
			||||||
 | 
					using StellaOps.Feedser.Storage.Mongo;
 | 
				
			||||||
 | 
					using StellaOps.Feedser.Storage.Mongo.Migrations;
 | 
				
			||||||
 | 
					using Xunit;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace StellaOps.Feedser.Storage.Mongo.Tests;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[Collection("mongo-fixture")]
 | 
				
			||||||
 | 
					public sealed class MongoBootstrapperTests : IClassFixture<MongoIntegrationFixture>
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    private readonly MongoIntegrationFixture _fixture;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public MongoBootstrapperTests(MongoIntegrationFixture fixture)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        _fixture = fixture;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Fact]
 | 
				
			||||||
 | 
					    public async Task InitializeAsync_CreatesNormalizedIndexesWhenSemVerStyleEnabled()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var databaseName = $"feedser-bootstrap-semver-{Guid.NewGuid():N}";
 | 
				
			||||||
 | 
					        var database = _fixture.Client.GetDatabase(databaseName);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var runner = new MongoMigrationRunner(
 | 
				
			||||||
 | 
					                database,
 | 
				
			||||||
 | 
					                Array.Empty<IMongoMigration>(),
 | 
				
			||||||
 | 
					                NullLogger<MongoMigrationRunner>.Instance,
 | 
				
			||||||
 | 
					                TimeProvider.System);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            var bootstrapper = new MongoBootstrapper(
 | 
				
			||||||
 | 
					                database,
 | 
				
			||||||
 | 
					                Options.Create(new MongoStorageOptions { EnableSemVerStyle = true }),
 | 
				
			||||||
 | 
					                NullLogger<MongoBootstrapper>.Instance,
 | 
				
			||||||
 | 
					                runner);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            await bootstrapper.InitializeAsync(CancellationToken.None);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            var indexCursor = await database
 | 
				
			||||||
 | 
					                .GetCollection<BsonDocument>(MongoStorageDefaults.Collections.Advisory)
 | 
				
			||||||
 | 
					                .Indexes
 | 
				
			||||||
 | 
					                .ListAsync();
 | 
				
			||||||
 | 
					            var indexNames = (await indexCursor.ToListAsync()).Select(x => x["name"].AsString).ToArray();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Assert.Contains("advisory_normalizedVersions_pkg_scheme_type", indexNames);
 | 
				
			||||||
 | 
					            Assert.Contains("advisory_normalizedVersions_value", indexNames);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        finally
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await _fixture.Client.DropDatabaseAsync(databaseName);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Fact]
 | 
				
			||||||
 | 
					    public async Task InitializeAsync_DoesNotCreateNormalizedIndexesWhenFeatureDisabled()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var databaseName = $"feedser-bootstrap-no-semver-{Guid.NewGuid():N}";
 | 
				
			||||||
 | 
					        var database = _fixture.Client.GetDatabase(databaseName);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var runner = new MongoMigrationRunner(
 | 
				
			||||||
 | 
					                database,
 | 
				
			||||||
 | 
					                Array.Empty<IMongoMigration>(),
 | 
				
			||||||
 | 
					                NullLogger<MongoMigrationRunner>.Instance,
 | 
				
			||||||
 | 
					                TimeProvider.System);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            var bootstrapper = new MongoBootstrapper(
 | 
				
			||||||
 | 
					                database,
 | 
				
			||||||
 | 
					                Options.Create(new MongoStorageOptions { EnableSemVerStyle = false }),
 | 
				
			||||||
 | 
					                NullLogger<MongoBootstrapper>.Instance,
 | 
				
			||||||
 | 
					                runner);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            await bootstrapper.InitializeAsync(CancellationToken.None);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            var indexCursor = await database
 | 
				
			||||||
 | 
					                .GetCollection<BsonDocument>(MongoStorageDefaults.Collections.Advisory)
 | 
				
			||||||
 | 
					                .Indexes
 | 
				
			||||||
 | 
					                .ListAsync();
 | 
				
			||||||
 | 
					            var indexNames = (await indexCursor.ToListAsync()).Select(x => x["name"].AsString).ToArray();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Assert.DoesNotContain("advisory_normalizedVersions_pkg_scheme_type", indexNames);
 | 
				
			||||||
 | 
					            Assert.DoesNotContain("advisory_normalizedVersions_value", indexNames);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        finally
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await _fixture.Client.DropDatabaseAsync(databaseName);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -5,6 +5,7 @@ using System.Runtime.CompilerServices;
 | 
				
			|||||||
using System.Text.Json;
 | 
					using System.Text.Json;
 | 
				
			||||||
using System.Text.Json.Serialization;
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
using Microsoft.Extensions.Logging;
 | 
					using Microsoft.Extensions.Logging;
 | 
				
			||||||
 | 
					using Microsoft.Extensions.Options;
 | 
				
			||||||
using MongoDB.Bson;
 | 
					using MongoDB.Bson;
 | 
				
			||||||
using MongoDB.Driver;
 | 
					using MongoDB.Driver;
 | 
				
			||||||
using StellaOps.Feedser.Models;
 | 
					using StellaOps.Feedser.Models;
 | 
				
			||||||
@@ -18,17 +19,20 @@ public sealed class AdvisoryStore : IAdvisoryStore
 | 
				
			|||||||
    private readonly ILogger<AdvisoryStore> _logger;
 | 
					    private readonly ILogger<AdvisoryStore> _logger;
 | 
				
			||||||
    private readonly IAliasStore _aliasStore;
 | 
					    private readonly IAliasStore _aliasStore;
 | 
				
			||||||
    private readonly TimeProvider _timeProvider;
 | 
					    private readonly TimeProvider _timeProvider;
 | 
				
			||||||
 | 
					    private readonly MongoStorageOptions _options;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public AdvisoryStore(
 | 
					    public AdvisoryStore(
 | 
				
			||||||
        IMongoDatabase database,
 | 
					        IMongoDatabase database,
 | 
				
			||||||
        IAliasStore aliasStore,
 | 
					        IAliasStore aliasStore,
 | 
				
			||||||
        ILogger<AdvisoryStore> logger,
 | 
					        ILogger<AdvisoryStore> logger,
 | 
				
			||||||
 | 
					        IOptions<MongoStorageOptions> options,
 | 
				
			||||||
        TimeProvider? timeProvider = null)
 | 
					        TimeProvider? timeProvider = null)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        _collection = (database ?? throw new ArgumentNullException(nameof(database)))
 | 
					        _collection = (database ?? throw new ArgumentNullException(nameof(database)))
 | 
				
			||||||
            .GetCollection<AdvisoryDocument>(MongoStorageDefaults.Collections.Advisory);
 | 
					            .GetCollection<AdvisoryDocument>(MongoStorageDefaults.Collections.Advisory);
 | 
				
			||||||
        _aliasStore = aliasStore ?? throw new ArgumentNullException(nameof(aliasStore));
 | 
					        _aliasStore = aliasStore ?? throw new ArgumentNullException(nameof(aliasStore));
 | 
				
			||||||
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
					        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
				
			||||||
 | 
					        _options = options?.Value ?? throw new ArgumentNullException(nameof(options));
 | 
				
			||||||
        _timeProvider = timeProvider ?? TimeProvider.System;
 | 
					        _timeProvider = timeProvider ?? TimeProvider.System;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -51,7 +55,9 @@ public sealed class AdvisoryStore : IAdvisoryStore
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var payload = CanonicalJsonSerializer.Serialize(advisory);
 | 
					        var payload = CanonicalJsonSerializer.Serialize(advisory);
 | 
				
			||||||
        var normalizedVersions = NormalizedVersionDocumentFactory.Create(advisory);
 | 
					        var normalizedVersions = _options.EnableSemVerStyle
 | 
				
			||||||
 | 
					            ? NormalizedVersionDocumentFactory.Create(advisory)
 | 
				
			||||||
 | 
					            : null;
 | 
				
			||||||
        var document = new AdvisoryDocument
 | 
					        var document = new AdvisoryDocument
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            AdvisoryKey = advisory.AdvisoryKey,
 | 
					            AdvisoryKey = advisory.AdvisoryKey,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -41,10 +41,10 @@ internal static class NormalizedVersionDocumentFactory
 | 
				
			|||||||
                    ?? package.Provenance.FirstOrDefault()?.RecordedAt
 | 
					                    ?? package.Provenance.FirstOrDefault()?.RecordedAt
 | 
				
			||||||
                    ?? advisoryFallbackRecordedAt;
 | 
					                    ?? advisoryFallbackRecordedAt;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                var constraint = matchingRange?.RangeExpression
 | 
					                var constraint = matchingRange?.Primitives?.SemVer?.ConstraintExpression
 | 
				
			||||||
                    ?? matchingRange?.Primitives?.SemVer?.ConstraintExpression;
 | 
					                    ?? matchingRange?.RangeExpression;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                var style = rule.Type;
 | 
					                var style = matchingRange?.Primitives?.SemVer?.Style ?? rule.Type;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                documents.Add(new NormalizedVersionDocument
 | 
					                documents.Add(new NormalizedVersionDocument
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -138,6 +138,20 @@ public sealed class MongoBootstrapper
 | 
				
			|||||||
                new CreateIndexOptions { Name = "advisory_published_desc" }),
 | 
					                new CreateIndexOptions { Name = "advisory_published_desc" }),
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (_options.EnableSemVerStyle)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            indexes.Add(new CreateIndexModel<BsonDocument>(
 | 
				
			||||||
 | 
					                Builders<BsonDocument>.IndexKeys
 | 
				
			||||||
 | 
					                    .Ascending("normalizedVersions.packageId")
 | 
				
			||||||
 | 
					                    .Ascending("normalizedVersions.scheme")
 | 
				
			||||||
 | 
					                    .Ascending("normalizedVersions.type"),
 | 
				
			||||||
 | 
					                new CreateIndexOptions { Name = "advisory_normalizedVersions_pkg_scheme_type" }));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            indexes.Add(new CreateIndexModel<BsonDocument>(
 | 
				
			||||||
 | 
					                Builders<BsonDocument>.IndexKeys.Ascending("normalizedVersions.value"),
 | 
				
			||||||
 | 
					                new CreateIndexOptions { Name = "advisory_normalizedVersions_value", Sparse = true }));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return collection.Indexes.CreateManyAsync(indexes, cancellationToken);
 | 
					        return collection.Indexes.CreateManyAsync(indexes, cancellationToken);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -84,6 +84,7 @@ public static class ServiceCollectionExtensions
 | 
				
			|||||||
        services.AddSingleton<MongoMigrationRunner>();
 | 
					        services.AddSingleton<MongoMigrationRunner>();
 | 
				
			||||||
        services.AddSingleton<IMongoMigration, EnsureDocumentExpiryIndexesMigration>();
 | 
					        services.AddSingleton<IMongoMigration, EnsureDocumentExpiryIndexesMigration>();
 | 
				
			||||||
        services.AddSingleton<IMongoMigration, EnsureGridFsExpiryIndexesMigration>();
 | 
					        services.AddSingleton<IMongoMigration, EnsureGridFsExpiryIndexesMigration>();
 | 
				
			||||||
 | 
					        services.AddSingleton<IMongoMigration, SemVerStyleBackfillMigration>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return services;
 | 
					        return services;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,5 +15,7 @@
 | 
				
			|||||||
|Persist last failure reason in SourceState|BE-Storage|Storage.Mongo|DONE – `MongoSourceStateRepository.MarkFailureAsync` stores `lastFailureReason` with length guard + reset on success.|
 | 
					|Persist last failure reason in SourceState|BE-Storage|Storage.Mongo|DONE – `MongoSourceStateRepository.MarkFailureAsync` stores `lastFailureReason` with length guard + reset on success.|
 | 
				
			||||||
|AdvisoryStore range primitives deserialization|BE-Storage|Models|DONE – BSON helpers handle `RangePrimitives`; regression test covers SemVer/NEVRA/EVR envelopes persisted through Mongo.|
 | 
					|AdvisoryStore range primitives deserialization|BE-Storage|Models|DONE – BSON helpers handle `RangePrimitives`; regression test covers SemVer/NEVRA/EVR envelopes persisted through Mongo.|
 | 
				
			||||||
|FEEDSTORAGE-DATA-03-001 Merge event provenance audit prep|BE-Storage|Merge|DONE – merge events now persist field-level decision reasons via `MergeFieldDecision` documents for analytics. **Coordination:** log any new precedence signals to storage@ so indexes/serializers stay aligned.|
 | 
					|FEEDSTORAGE-DATA-03-001 Merge event provenance audit prep|BE-Storage|Merge|DONE – merge events now persist field-level decision reasons via `MergeFieldDecision` documents for analytics. **Coordination:** log any new precedence signals to storage@ so indexes/serializers stay aligned.|
 | 
				
			||||||
|FEEDSTORAGE-DATA-02-001 Normalized range dual-write + backfill|BE-Storage|Core|DONE – advisory documents store `normalizedVersions`, migration respects `EnableSemVerStyle`, and decision reasons flow into normalized write path. **Action:** connector owners confirm `EnableSemVerStyle=true` readiness before 2025-10-18 rollout.|
 | 
					|FEEDSTORAGE-DATA-02-001 Normalized range dual-write + backfill|BE-Storage|Core|**DONE (2025-10-12)** – `AdvisoryStore` honors `EnableSemVerStyle`, dual-writes normalized docs, and SemVer backfill migration registered for staged rollout.|
 | 
				
			||||||
|FEEDSTORAGE-TESTS-02-004 Restore AdvisoryStore build after normalized versions refactor|QA|Storage.Mongo|DONE – storage tests updated to cover normalized version payloads and new provenance fields. **Heads-up:** QA to watch for fixture bumps touching normalized rule arrays when connectors roll out support.|
 | 
					|FEEDSTORAGE-TESTS-02-004 Restore AdvisoryStore build after normalized versions refactor|QA|Storage.Mongo|DONE – storage tests updated to cover normalized version payloads and new provenance fields. **Heads-up:** QA to watch for fixture bumps touching normalized rule arrays when connectors roll out support.|
 | 
				
			||||||
 | 
					|FEEDSTORAGE-DATA-02-002 Provenance decision persistence|BE-Storage|Models `FEEDMODELS-SCHEMA-01-002`|**DONE (2025-10-12)** – Normalized documents carry decision reasons/source/timestamps with regression coverage verifying SemVer notes + provenance fallbacks.|
 | 
				
			||||||
 | 
					|FEEDSTORAGE-DATA-02-003 Normalized versions index creation|BE-Storage|Normalization, Mongo bootstrapper|**DONE (2025-10-12)** – Bootstrapper seeds `normalizedVersions.*` indexes when SemVer style is enabled; docs/tests confirm index presence.|
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user