-
Notifications
You must be signed in to change notification settings - Fork 728
feat: secondary manifest repository signal (CM-1393) #4570
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
joanagmaia
wants to merge
17
commits into
main
Choose a base branch
from
feat/CM-1393-secondary-manifest-signal
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
5096f34
feat: secondary manifest repository signal (CM-1393)
joanagmaia 6d371e3
fix: remove stale declared repo links and tighten host gate (CM-1393)
joanagmaia 09c0dbc
fix: sync packages.repository_url on packagist fallback and move pypi…
joanagmaia e59a8b6
fix: remove stale declared links in maven writeRepoLink (CM-1393)
joanagmaia d7b35b0
style: fix prettier formatting in pypi upsertProject (CM-1393)
joanagmaia ecdd1c4
test: update packagist dueSelection mock for homepage field (CM-1393)
joanagmaia 4490691
fix: prune cargo declared links unconditionally before relinking (CM-…
joanagmaia 58df2ee
fix: propagate NULL repository_url when cargo dump omits repo (CM-1393)
joanagmaia 52216a3
test: cover declaredRepositoryField and bug_tracker fallback in pypi …
joanagmaia 128987b
fix: cargo repository_url authority rule, pypi git key over-match (CM…
joanagmaia 8a6c192
style: fix prettier; add ADR-0021 alternatives considered (CM-1393)
joanagmaia 314d2bf
docs: use 4-alternative format in ADR-0021 for consistency with CM-13…
joanagmaia 5961195
fix: extract repository_url update into DAL setPackageRepositoryUrl (…
joanagmaia 699c68b
docs: point rescore script and confidence tests at V1788307300 (CM-1393)
joanagmaia 7a4ca11
test: mock setPackageRepositoryUrl in packagist persist tests (CM-1393)
joanagmaia 625ca9f
fix: address secondary-signal review comments and repo URL staleness …
joanagmaia 54dde72
fix: address new Copilot review comments on PR 4570 (CM-1393)
joanagmaia File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
178 changes: 178 additions & 0 deletions
178
backend/src/osspckgs/migrations/V1788307300__package_repo_signal.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| -- Secondary manifest repository signal (CM-1393). | ||
| -- | ||
| -- Extends the confidence scoring introduced in V1788307200 with the manifest field | ||
| -- that produced a declared link. A repo URL read from a fallback field (homepage, | ||
| -- bug_tracker) is weaker evidence than one read from the dedicated repository field, | ||
| -- so it lands one tier lower. | ||
| -- | ||
| -- The column defaults to 'primary', so existing rows keep their current score until | ||
| -- the next enrichment pass writes a real signal or a rescore sweep runs. | ||
|
|
||
| ALTER TABLE package_repos | ||
| ADD COLUMN IF NOT EXISTS signal text NOT NULL DEFAULT 'primary' | ||
| CHECK (signal IN ('primary', 'secondary')); | ||
|
|
||
| -- Adding a parameter changes the signature, so the V1788307200 function is dropped | ||
| -- rather than replaced — CREATE OR REPLACE would leave both overloads callable. | ||
| DROP FUNCTION IF EXISTS package_repo_confidence( | ||
| text, text, text, bool, bool, bool, text, bool, bigint | ||
| ); | ||
|
|
||
| CREATE OR REPLACE FUNCTION package_repo_confidence( | ||
| p_source text, | ||
| p_ecosystem text, | ||
| p_signal text, | ||
| p_provenance text, | ||
| p_archived bool, | ||
| p_is_fork bool, | ||
| p_disabled bool, | ||
| p_host text, | ||
| p_competing_github bool, | ||
| p_repo_id bigint | ||
| ) | ||
| RETURNS numeric(12, 9) | ||
| LANGUAGE plpgsql IMMUTABLE AS $$ | ||
| DECLARE | ||
| base numeric; | ||
| source_priority int; | ||
| offset_units bigint; | ||
| BEGIN | ||
| base := CASE p_source | ||
| WHEN 'manual' THEN 0.99 | ||
| WHEN 'heuristic' THEN 0.30 | ||
| WHEN 'deps_dev' THEN CASE p_provenance | ||
| WHEN 'SLSA_ATTESTATION' THEN 0.99 | ||
| WHEN 'RUBYGEMS_PUBLISH_ATTESTATION' THEN 0.95 | ||
| WHEN 'PYPI_PUBLISH_ATTESTATION' THEN 0.95 | ||
| WHEN 'GO_ORIGIN' THEN 0.90 | ||
| ELSE 0.50 | ||
| END | ||
| -- maven splits off npm/cargo/the rest: POM <scm> blocks are notoriously | ||
| -- stale (legacy SVN URLs, org renames, dead mirrors). | ||
| WHEN 'declared' THEN CASE WHEN p_ecosystem = 'maven' THEN 0.80 ELSE 0.85 END | ||
| ELSE 0.30 | ||
| END; | ||
|
|
||
| -- Signal adjusts the declared tier only. A deps.dev publish attestation already | ||
| -- proves the publisher–repo relationship, and manual links are operator-pinned. | ||
| IF p_source = 'declared' AND p_signal = 'secondary' THEN | ||
| base := base - 0.10; | ||
| END IF; | ||
|
|
||
| source_priority := CASE p_source | ||
| WHEN 'manual' THEN 3 | ||
| WHEN 'deps_dev' THEN 2 | ||
| WHEN 'declared' THEN 1 | ||
| ELSE 0 | ||
| END; | ||
|
|
||
| IF p_disabled IS TRUE THEN | ||
| -- Scale proportionally so pre-disabled claim ordering is preserved across sources. | ||
| -- The offset uses a tighter modulo so max contribution (3*1000+999)*1e-9 ≈ 4e-6 | ||
| -- stays below the 0.00016 minimum scaled tier gap and cannot invert source ordering. | ||
| base := 0.05 + LEAST(base, 0.99) * 0.004; | ||
| offset_units := source_priority::bigint * 1000 + COALESCE(p_repo_id, 0) % 1000; | ||
| ELSE | ||
| IF p_archived IS TRUE THEN | ||
| base := base - 0.20; | ||
| END IF; | ||
|
|
||
| IF p_is_fork IS TRUE THEN | ||
| base := base - 0.10; | ||
| END IF; | ||
|
|
||
| IF p_competing_github IS TRUE AND p_host IS NOT NULL AND p_host <> 'github' THEN | ||
| base := base - 0.05; | ||
| END IF; | ||
|
|
||
| base := GREATEST(base, 0.05); | ||
|
|
||
| -- Tie-breaker: reduces same-source collisions to the rare case where two repo IDs for | ||
| -- the same package are congruent mod 1,000,000. BEST_REPO_LINK_JOIN uses a secondary | ||
| -- ORDER BY repo_id DESC as the canonical deterministic pick when confidence ties. | ||
| offset_units := source_priority::bigint * 1000000 + COALESCE(p_repo_id, 0) % 1000000; | ||
| END IF; | ||
|
|
||
| RETURN LEAST(base + offset_units * 0.000000001, 0.999999999); | ||
| END; | ||
| $$; | ||
|
|
||
| -- Replaced only to pass cur.signal through to the widened scoring function; the | ||
| -- chunking, locking and keyset paging are unchanged from V1788307200. | ||
| CREATE OR REPLACE PROCEDURE rescore_package_repo_confidence( | ||
| p_repo_ids bigint[] DEFAULT NULL, | ||
| chunk_size int DEFAULT 25000, | ||
| INOUT applied_rows int DEFAULT 0 | ||
| ) | ||
| LANGUAGE plpgsql AS $$ | ||
| DECLARE | ||
| batch_rows int; | ||
| updated_rows int; | ||
| cursor_id bigint := 0; | ||
| BEGIN | ||
| IF chunk_size IS NULL OR chunk_size <= 0 THEN | ||
| RAISE EXCEPTION 'rescore_package_repo_confidence: chunk_size must be positive, got %', chunk_size; | ||
| END IF; | ||
|
|
||
| -- Session-level: survives the internal COMMITs below. | ||
| IF NOT pg_try_advisory_lock(hashtextextended('rescore_package_repo_confidence', 0)) THEN | ||
| RAISE EXCEPTION 'rescore_package_repo_confidence: another execution is already in progress'; | ||
| END IF; | ||
|
|
||
| applied_rows := 0; | ||
|
|
||
| LOOP | ||
| WITH batch AS ( | ||
| SELECT pr.id | ||
| FROM package_repos pr | ||
| WHERE pr.id > cursor_id | ||
| AND (p_repo_ids IS NULL OR pr.repo_id = ANY(p_repo_ids)) | ||
| -- deps_dev rows with NULL provenance were ingested before this column existed; | ||
| -- skip them so the backfill does not downgrade SLSA/attestation links to 0.50. | ||
| -- They will be rescored correctly once the next ingest populates provenance. | ||
| AND NOT (pr.source = 'deps_dev' AND pr.provenance IS NULL) | ||
| ORDER BY pr.id | ||
| LIMIT chunk_size | ||
| FOR UPDATE | ||
| ), | ||
| updated AS ( | ||
| UPDATE package_repos pr | ||
| SET confidence = s.confidence, verified_at = NOW() | ||
| FROM batch b | ||
| JOIN package_repos cur ON cur.id = b.id | ||
| JOIN packages p ON p.id = cur.package_id | ||
| JOIN repos r ON r.id = cur.repo_id, | ||
| LATERAL ( | ||
| SELECT package_repo_confidence( | ||
| cur.source, p.ecosystem, cur.signal, cur.provenance, | ||
| r.archived, r.is_fork, r.disabled, r.host, | ||
| EXISTS ( | ||
| SELECT 1 | ||
| FROM package_repos c | ||
| JOIN repos cr ON cr.id = c.repo_id | ||
| WHERE c.package_id = cur.package_id | ||
| AND c.repo_id <> cur.repo_id | ||
| AND cr.host = 'github' | ||
| ), | ||
| cur.repo_id | ||
| ) AS confidence | ||
| ) s | ||
| WHERE pr.id = b.id | ||
| AND s.confidence IS DISTINCT FROM cur.confidence | ||
| RETURNING pr.id | ||
| ) | ||
| SELECT COUNT(*), COALESCE(MAX(b.id), cursor_id), | ||
| (SELECT COUNT(*) FROM updated) | ||
| INTO batch_rows, cursor_id, updated_rows | ||
| FROM batch b; | ||
|
|
||
| applied_rows := applied_rows + updated_rows; | ||
|
|
||
| COMMIT; | ||
|
|
||
| EXIT WHEN batch_rows < chunk_size; | ||
| END LOOP; | ||
|
|
||
| PERFORM pg_advisory_unlock(hashtextextended('rescore_package_repo_confidence', 0)); | ||
| END; | ||
| $$; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| # ADR-0021: Secondary manifest repository signal | ||
|
|
||
| **Date**: 2026-09-01 | ||
| **Status**: accepted | ||
| **Deciders**: Joana Maia | ||
|
|
||
| ## Context | ||
|
|
||
| Every registry writer only created a `package_repos` row when the ecosystem's | ||
| canonical repository field parsed — npm `repository`, cargo `repository`, | ||
| rubygems `source_code_uri`, NuGet `<repository>`, POM `<scm><url>`. A large | ||
| share of packages leave that field empty while publishing the same repo URL in | ||
| `homepage`, `bugs.url`, `projectUrl`, `bug_tracker_uri`, or the POM `<url>`, and | ||
| those packages ended up with no repo link at all — invisible to criticality, | ||
| blast radius, and Insights. | ||
|
|
||
| Simply widening each writer to accept any of those fields would trade | ||
| under-coverage for wrong links: fallback fields are free-form, so | ||
| `https://example.com/docs/getting-started` canonicalizes into a plausible | ||
| `owner/repo` shape without being a repository. Fallback links also should not | ||
| rank equally with a declared one. | ||
| [ADR-0020](./0020-package-repo-confidence-scoring.md) already reserved the | ||
| `signal` column and its −0.10 penalty for exactly this. | ||
|
|
||
| ## Decision | ||
|
|
||
| One shared helper, `resolveManifestRepo(candidates)` | ||
| (`packages_worker/src/utils/resolveManifestRepo.ts`), resolves a package's repo | ||
| from an ordered candidate list. The first candidate is the ecosystem's canonical | ||
| field and resolves as `primary`; every later candidate resolves as `secondary`. | ||
| The result carries `{ repo, signal }`, and each writer persists the | ||
| returned `signal` on the link. No writer computes a confidence value. | ||
|
|
||
| ### Chains | ||
|
|
||
| | Ecosystem | Chain | | ||
| | --- | --- | | ||
| | npm | `repository` → `homepage` → `bugs.url` | | ||
| | pypi | Source/Code project URL → `Homepage` → bug tracker URL | | ||
| | cargo | `repository` → `homepage` | | ||
| | rubygems | `source_code_uri` → `homepage_uri` → `bug_tracker_uri` | | ||
| | packagist | `support.source` → `homepage` | | ||
| | nuget | `<repository>` → `projectUrl` | | ||
| | maven | POM `<scm><url>` → POM `<url>` | | ||
|
|
||
| ### Host gate | ||
|
|
||
| Candidates go through the shared `canonicalizeRepoUrl`. A `secondary` candidate | ||
| is rejected when canonicalization yields `host === 'other'` — recognized VCS | ||
| hosts only. The `primary` candidate keeps its historical behaviour and still | ||
| accepts `other`, so existing links to self-hosted Gitea, cgit, and SVN are | ||
| unaffected. Packagist already applied this gate locally; it is now the shared | ||
| rule. | ||
|
|
||
| Cargo is the exception on mechanics, not on policy: its pipeline is set-based | ||
| SQL over a dump, so `normalizeRepos` stages both `declared_repository_url` and | ||
| `homepage` into `repo_norm`, and a new `repo_choice` table applies the same | ||
| first-wins-with-host-gate rule in SQL. `documentation` is not staged — it is | ||
| almost always docs.rs, which the host gate rejects anyway. | ||
|
|
||
| ## Alternatives Considered | ||
|
|
||
| ### Alternative 1: Widen each writer's existing extractor in place | ||
|
|
||
| - **Pros**: no new module; smallest diff per ecosystem. | ||
| - **Cons**: seven copies of the fallback order and the host gate, which is how | ||
| the current per-ecosystem divergence arose in the first place; the `signal` | ||
| value would be derived independently in each writer. | ||
| - **Why not**: the whole point is one rule; nine implementations of "which | ||
| field won" is the defect, not the fix. | ||
|
|
||
| ### Alternative 2: Accept fallback URLs on any host, like the primary field does | ||
|
|
||
| - **Pros**: maximum coverage; no URL is discarded. | ||
| - **Cons**: a documentation site or a marketing page with two path segments | ||
| becomes a repo link, creating a `repos` row and an incorrect | ||
| `packages_published` attribution — the exact failure this epic exists to fix. | ||
| - **Why not**: coverage gained by inventing repos is negative value; the | ||
| primary field at least carries the publisher's explicit claim. | ||
|
|
||
| ### Alternative 3: Score fallback links lower directly in the writers instead of adding `signal` | ||
|
|
||
| - **Pros**: no schema column; visible in one place. | ||
| - **Cons**: reintroduces per-writer confidence literals, and the penalty could | ||
| not be retuned or audited afterwards — nothing records *why* a row scored | ||
| lower. | ||
| - **Why not**: ADR-0020 makes the stored score derivable from stored evidence; | ||
| `signal` is that evidence. | ||
|
|
||
| ### Alternative 4: Backfill a separate pass that mines fallback fields for packages with no link | ||
|
|
||
| - **Pros**: zero risk to the existing write paths; can be re-run at will. | ||
| - **Cons**: a second code path that has to re-fetch or re-read every manifest, | ||
| and it goes stale the moment a package is re-ingested. | ||
| - **Why not**: the data is already in hand at write time; the write path is | ||
| the cheapest place to fix coverage. | ||
|
|
||
| ## Consequences | ||
|
|
||
| ### Positive | ||
|
|
||
| - Packages that only publish their repo in a secondary field now get a link, | ||
| and the link is honestly labelled as weaker. | ||
| - The fallback order and the host gate exist once, so adding an ecosystem means | ||
| declaring a candidate list. | ||
| - Per-run counters (`primary_field_hit`, `fallback_hit_by_field`, `no_signal`) | ||
| make the coverage uplift measurable against the pre-merge baseline. | ||
|
|
||
| ### Negative | ||
|
|
||
| - Secondary links are, by construction, less certain than declared ones; some | ||
| will be wrong even with the host gate. | ||
| - Maven and cargo needed local restructuring (maven's POM-specific | ||
| `normalizeScmUrl` feeds the same `package_repos` write path; cargo's | ||
| `repo_choice` applies the host-gate rule in SQL) to reach the same behaviour. | ||
| - Row counts in `package_repos` grow, and the dedup/keep-highest path now sees | ||
| more competing links per package. | ||
|
|
||
| ### Risks | ||
|
|
||
| - **A secondary link can outrank a genuine one when the declared field is | ||
| missing on the true repo but present on a fork.** Mitigation: ADR-0020's | ||
| fork and archived penalties, plus ADR-0022's ownership evidence, which | ||
| penalises the fork's owner mismatch far more heavily than the secondary | ||
| penalty. | ||
| - **Recognized-host gating rejects legitimate self-hosted repos found in a | ||
| fallback field.** Accepted deliberately: an unrecognized host in a free-form | ||
| field carries no signal that it is a repository at all. Revisit if the | ||
| `no_signal` counters show a material self-hosted tail. | ||
| - **Coverage growth is hard to attribute after the fact.** Mitigation: record | ||
| per-ecosystem `package_repos` row counts before merge, and compare against | ||
| the `fallback_hit_by_field` counters afterwards. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.