@@ -57,22 +57,40 @@ export interface PackageListRow {
5757 stewardshipStatus : string | null
5858 openVulns : number
5959 maintainerCount : number
60+ scorecardScore : number | null
6061 total : string
6162}
6263
64+ export type HealthBand = 'healthy' | 'fair' | 'concerning' | 'critical'
65+ export type VulnSeverityFilter = 'any' | 'high' | 'critical'
66+
6367export interface ListPackagesOptions {
6468 page : number
6569 pageSize : number
6670 ecosystem ?: string
71+ lifecycle ?: string
72+ name ?: string
73+ status ?: string
74+ healthBand ?: HealthBand
75+ vulnSeverity ?: VulnSeverityFilter
6776 staleOnly : boolean
6877 unstewardedOnly : boolean
6978 busFactor1Only : boolean
70- sortBy : 'name' | 'impact' | 'openVulns'
79+ sortBy : 'name' | 'impact' | 'openVulns' | 'health' | 'risk'
7180 sortDir : 'asc' | 'desc'
7281}
7382
7483const STALE_MONTHS = 18
7584
85+ // Severity stored as uppercase in advisories table.
86+ // Ranks: CRITICAL=4, HIGH=3, MEDIUM=2, LOW=1
87+ const SEVERITY_RANK_EXPR = `MAX(CASE a.severity
88+ WHEN 'CRITICAL' THEN 4
89+ WHEN 'HIGH' THEN 3
90+ WHEN 'MEDIUM' THEN 2
91+ WHEN 'LOW' THEN 1
92+ ELSE 0 END)::int`
93+
7694export async function listPackagesForApi (
7795 qx : QueryExecutor ,
7896 opts : ListPackagesOptions ,
@@ -85,6 +103,55 @@ export async function listPackagesForApi(
85103 params . ecosystem = opts . ecosystem
86104 }
87105
106+ if ( opts . name ) {
107+ conditions . push ( 'p.name ILIKE $(name)' )
108+ params . name = `%${ opts . name } %`
109+ }
110+
111+ // Exclude packages with no registry status when a lifecycle filter is active.
112+ // Full lifecycle column support is pending; this prevents null-lifecycle rows
113+ // from leaking into filtered results.
114+ if ( opts . lifecycle ) {
115+ conditions . push ( 'p.status IS NOT NULL' )
116+ }
117+
118+ if ( opts . status ) {
119+ // 'unassigned' includes packages that have no stewardship row yet
120+ if ( opts . status === 'unassigned' ) {
121+ conditions . push ( `(s.status = 'unassigned' OR s.id IS NULL)` )
122+ } else {
123+ conditions . push ( 's.status = $(status)' )
124+ params . status = opts . status
125+ }
126+ }
127+
128+ if ( opts . healthBand ) {
129+ // scorecard_score is 0–10; multiply by 10 to get 0–100 health score.
130+ // Packages with no linked repo (scorecard_score IS NULL) fall into 'critical'.
131+ if ( opts . healthBand === 'healthy' ) {
132+ conditions . push ( 'r_sc.scorecard_score >= 7.0' )
133+ } else if ( opts . healthBand === 'fair' ) {
134+ conditions . push ( 'r_sc.scorecard_score >= 5.0 AND r_sc.scorecard_score < 7.0' )
135+ } else if ( opts . healthBand === 'concerning' ) {
136+ conditions . push ( 'r_sc.scorecard_score >= 3.0 AND r_sc.scorecard_score < 5.0' )
137+ } else {
138+ // critical band includes no-repo packages (NULL scorecard)
139+ conditions . push ( '(r_sc.scorecard_score IS NULL OR r_sc.scorecard_score < 3.0)' )
140+ }
141+ }
142+
143+ if ( opts . vulnSeverity ) {
144+ if ( opts . vulnSeverity === 'any' ) {
145+ conditions . push ( 'ap_counts.cnt > 0' )
146+ } else if ( opts . vulnSeverity === 'high' ) {
147+ // high includes packages where worst severity is HIGH or CRITICAL
148+ conditions . push ( 'ap_severity.max_rank >= 3' )
149+ } else {
150+ // critical: worst severity is CRITICAL only
151+ conditions . push ( 'ap_severity.max_rank >= 4' )
152+ }
153+ }
154+
88155 if ( opts . staleOnly ) {
89156 conditions . push (
90157 `(p.latest_release_at IS NULL OR p.latest_release_at < NOW() - INTERVAL '${ STALE_MONTHS } months')` ,
@@ -102,16 +169,56 @@ export async function listPackagesForApi(
102169
103170 const where = `WHERE ${ conditions . join ( ' AND ' ) } `
104171
105- // health is a v2 field — fall back to name sort
106172 let sortExpr : string
107- if ( opts . sortBy === 'impact' ) sortExpr = 'p.impact'
108- else if ( opts . sortBy === 'openVulns' ) sortExpr = '"openVulns"'
109- else sortExpr = 'LOWER(p.name)'
173+ if ( opts . sortBy === 'impact' ) {
174+ sortExpr = 'p.impact'
175+ } else if ( opts . sortBy === 'openVulns' ) {
176+ sortExpr = '"openVulns"'
177+ } else if ( opts . sortBy === 'health' ) {
178+ sortExpr = 'r_sc.scorecard_score'
179+ } else if ( opts . sortBy === 'risk' ) {
180+ // Composite risk score: impact + health deficit + vuln exposure + bus factor + staleness
181+ sortExpr = `(
182+ COALESCE(p.impact, 0) * 100
183+ + (100.0 - COALESCE(r_sc.scorecard_score, 0) * 10) * 0.8
184+ + COALESCE(ap_severity.max_rank, 0) * 15
185+ + COALESCE(ap_counts.cnt, 0) * 4
186+ + CASE WHEN pm_counts.cnt = 1 THEN 20 ELSE 0 END
187+ + CASE WHEN (p.latest_release_at IS NULL OR p.latest_release_at < NOW() - INTERVAL '${ STALE_MONTHS } months') THEN 15 ELSE 0 END
188+ )`
189+ } else {
190+ sortExpr = 'LOWER(p.name)'
191+ }
110192 const sortDir = opts . sortDir === 'desc' ? 'DESC' : 'ASC'
111193
112194 // Separate paginated params from filter-only params used by the fallback COUNT query
113195 const queryParams = { ...params , limit : opts . pageSize , offset : ( opts . page - 1 ) * opts . pageSize }
114196
197+ // Shared LATERAL clauses — included in both the main query and the count fallback
198+ // so that WHERE conditions referencing them work in both paths.
199+ const laterals = `
200+ LEFT JOIN stewardships s ON s.package_id = p.id
201+ LEFT JOIN LATERAL (
202+ SELECT COUNT(*)::int AS cnt FROM advisory_packages WHERE package_id = p.id
203+ ) ap_counts ON true
204+ LEFT JOIN LATERAL (
205+ SELECT COUNT(*)::int AS cnt FROM package_maintainers pm WHERE pm.package_id = p.id
206+ ) pm_counts ON true
207+ LEFT JOIN LATERAL (
208+ SELECT ${ SEVERITY_RANK_EXPR } AS max_rank
209+ FROM advisory_packages ap
210+ JOIN advisories a ON a.id = ap.advisory_id
211+ WHERE ap.package_id = p.id
212+ ) ap_severity ON true
213+ LEFT JOIN LATERAL (
214+ SELECT r.scorecard_score
215+ FROM package_repos pr
216+ JOIN repos r ON r.id = pr.repo_id
217+ WHERE pr.package_id = p.id
218+ ORDER BY pr.confidence DESC
219+ LIMIT 1
220+ ) r_sc ON true`
221+
115222 const rows : PackageListRow [ ] = await qx . select (
116223 `
117224 SELECT
@@ -122,15 +229,10 @@ export async function listPackagesForApi(
122229 s.status AS "stewardshipStatus",
123230 COALESCE(ap_counts.cnt, 0) AS "openVulns",
124231 pm_counts.cnt AS "maintainerCount",
232+ r_sc.scorecard_score AS "scorecardScore",
125233 COUNT(*) OVER() AS total
126234 FROM packages p
127- LEFT JOIN stewardships s ON s.package_id = p.id
128- LEFT JOIN LATERAL (
129- SELECT COUNT(*)::int AS cnt FROM advisory_packages WHERE package_id = p.id
130- ) ap_counts ON true
131- LEFT JOIN LATERAL (
132- SELECT COUNT(*)::int AS cnt FROM package_maintainers pm WHERE pm.package_id = p.id
133- ) pm_counts ON true
235+ ${ laterals }
134236 ${ where }
135237 ORDER BY ${ sortExpr } ${ sortDir } NULLS LAST, p.purl ${ sortDir }
136238 LIMIT $(limit) OFFSET $(offset)
@@ -147,10 +249,7 @@ export async function listPackagesForApi(
147249 const countRow : { count : string } = await qx . selectOne (
148250 `SELECT COUNT(*)::text AS count
149251 FROM packages p
150- LEFT JOIN stewardships s ON s.package_id = p.id
151- LEFT JOIN LATERAL (
152- SELECT COUNT(*)::int AS cnt FROM package_maintainers pm WHERE pm.package_id = p.id
153- ) pm_counts ON true
252+ ${ laterals }
154253 ${ where } ` ,
155254 params ,
156255 )
0 commit comments