diff --git a/library-filters.js b/library-filters.js new file mode 100644 index 0000000..507d586 --- /dev/null +++ b/library-filters.js @@ -0,0 +1,77 @@ +// library-filters.js — the single source of truth for *which* library rows are +// visible, and in what order. Both the live render() and the export path +// (getVisibleRows) call this, so they can't drift apart. Pure: pass the rows, +// the filter state, and the lookup maps that live in the library module. + +import { sortRows, filterRows } from './library-data.js'; +import { computeScore } from './evaluations.js'; + +const FIT_ORDER = ['strong', 'solid', 'care', 'risky']; + +/** + * Filter + sort the library rows for display/export. + * @param {Array} allRows every library row + * @param {{ query?: string, sort?: string, collection?: string, decision?: string, lang?: string }} state + * @param {{ decisionMap?: Map, evalMap?: Map, rubric?: Array, collections?: Array, nlFilter?: object }} ctx + * @returns {Array} the visible rows, ordered + */ +export function applyFilters(allRows, state, ctx = {}) { + const { decisionMap, evalMap, rubric, collections, nlFilter } = ctx; + let rows = sortRows(filterRows(allRows, state), state.sort); + + // 'decided' sort uses decisionMap, which lives in the library module. + if (state.sort === 'decided' && decisionMap) { + rows = [...rows].sort((a, b) => { + const ta = Date.parse(decisionMap.get(a.repoId)?.savedAt) || 0; + const tb = Date.parse(decisionMap.get(b.repoId)?.savedAt) || 0; + return tb - ta || a.name.localeCompare(b.name); + }); + } + // 'delta' sort: repos with a fitDelta float up; improved before regressed. + if (state.sort === 'delta') { + rows = [...rows].sort((a, b) => { + const ad = a.fitDelta, bd = b.fitDelta; + if (ad && !bd) return -1; + if (!ad && bd) return 1; + if (ad && bd) { + const aImp = FIT_ORDER.indexOf(ad.to) < FIT_ORDER.indexOf(ad.from); + const bImp = FIT_ORDER.indexOf(bd.to) < FIT_ORDER.indexOf(bd.from); + if (aImp !== bImp) return aImp ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + } + // 'eval' sort: weighted rubric score, highest first. + if (state.sort === 'eval' && evalMap) { + rows = [...rows].sort((a, b) => { + const sa = computeScore(evalMap.get(a.repoId) ?? null, rubric) ?? -1; + const sb = computeScore(evalMap.get(b.repoId) ?? null, rubric) ?? -1; + return sb - sa || a.name.localeCompare(b.name); + }); + } + // Collection membership filter (kept out of the pure filterRows). + if (state.collection && collections) { + const active = collections.find((c) => c.id === state.collection); + const ids = new Set(active ? active.repoIds : []); + rows = rows.filter((r) => ids.has(r.repoId)); + } + // Decision filter: 'undecided' shows repos with no saved decision. + if (decisionMap && state.decision === 'undecided') { + rows = rows.filter((r) => !decisionMap.has(r.repoId)); + } else if (decisionMap && state.decision) { + rows = rows.filter((r) => decisionMap.get(r.repoId)?.decision === state.decision); + } + // Language filter. + if (state.lang) { + const lq = state.lang.toLowerCase(); + rows = rows.filter((r) => (r.language || r.languages?.[0]?.name || '').toLowerCase() === lq); + } + // NL filter: restrict to the AI-ranked id list, preserving the AI order. + if (nlFilter?.ids?.length) { + const idOrder = new Map(nlFilter.ids.map((id, i) => [id, i])); + rows = rows.filter((r) => idOrder.has(r.repoId)).sort((a, b) => idOrder.get(a.repoId) - idOrder.get(b.repoId)); + } else if (nlFilter && !nlFilter.ids?.length && !nlFilter.error) { + rows = []; // AI ran but found nothing + } + return rows; +} diff --git a/library.js b/library.js index 02cd62f..05fe4e1 100644 --- a/library.js +++ b/library.js @@ -16,6 +16,7 @@ import { initTheme } from './theme.js'; import { veeSvg } from './mascot.js'; import { initPalette } from './palette.js'; import { loadRubric, saveRubric, saveEval, clearEval, listEvals, computeScore, DEFAULT_RUBRIC } from './evaluations.js'; +import { applyFilters } from './library-filters.js'; // Honour the user's chosen theme on this standalone page (sets ). initTheme(); @@ -177,62 +178,7 @@ function card(r) { function render() { jkIdx = -1; const grid = document.getElementById('grid'); - let rows = sortRows(filterRows(allRows, state), state.sort); - // 'decided' sort uses decisionMap which lives here, not in library-data. - if (state.sort === 'decided') { - rows = [...rows].sort((a, b) => { - const ta = Date.parse(decisionMap.get(a.repoId)?.savedAt) || 0; - const tb = Date.parse(decisionMap.get(b.repoId)?.savedAt) || 0; - return tb - ta || a.name.localeCompare(b.name); - }); - } - if (state.sort === 'delta') { - // Repos with a fitDelta float to the top; among those, improved before regressed. - const FIT_ORDER = ['strong', 'solid', 'care', 'risky']; - rows = [...rows].sort((a, b) => { - const ad = a.fitDelta, bd = b.fitDelta; - if (ad && !bd) return -1; - if (!ad && bd) return 1; - if (ad && bd) { - const aImp = FIT_ORDER.indexOf(ad.to) < FIT_ORDER.indexOf(ad.from); - const bImp = FIT_ORDER.indexOf(bd.to) < FIT_ORDER.indexOf(bd.from); - if (aImp !== bImp) return aImp ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); - } - if (state.sort === 'eval') { - rows = [...rows].sort((a, b) => { - const sa = computeScore(evalMap.get(a.repoId) ?? null, rubric) ?? -1; - const sb = computeScore(evalMap.get(b.repoId) ?? null, rubric) ?? -1; - return sb - sa || a.name.localeCompare(b.name); - }); - } - // Collection filter is applied here (not in the pure filterRows) so library-data - // stays unaware of collections — the membership lives only in this module. - if (state.collection) { - const active = collections.find((c) => c.id === state.collection); - const ids = new Set(active ? active.repoIds : []); - rows = rows.filter((r) => ids.has(r.repoId)); - } - // Decision filter: same pattern — decisionMap lives here, not in library-data. - // Special sentinel 'undecided' shows repos without any saved decision. - if (state.decision === 'undecided') { - rows = rows.filter((r) => !decisionMap.has(r.repoId)); - } else if (state.decision) { - rows = rows.filter((r) => decisionMap.get(r.repoId)?.decision === state.decision); - } - if (state.lang) { - const lq = state.lang.toLowerCase(); - rows = rows.filter((r) => (r.language || r.languages?.[0]?.name || '').toLowerCase() === lq); - } - // NL filter: when active, restrict to the AI-ranked ID list (preserving AI order). - if (nlFilter?.ids?.length) { - const idOrder = new Map(nlFilter.ids.map((id, i) => [id, i])); - rows = rows.filter((r) => idOrder.has(r.repoId)).sort((a, b) => idOrder.get(a.repoId) - idOrder.get(b.repoId)); - } else if (nlFilter && !nlFilter.ids?.length && !nlFilter.error) { - rows = []; // AI ran but found nothing - } + const rows = applyFilters(allRows, state, { decisionMap, evalMap, rubric, collections, nlFilter }); document.getElementById('count').textContent = rows.length === allRows.length ? `${allRows.length} repos` : `${rows.length} of ${allRows.length}`; const pinnedRows = rows.filter((r) => pinned.has(r.repoId)); @@ -1832,56 +1778,7 @@ async function recommendFromLibrary(resultsEl) { } function getVisibleRows() { - let rows = sortRows(filterRows(allRows, state), state.sort); - if (state.sort === 'decided') { - rows = [...rows].sort((a, b) => { - const ta = Date.parse(decisionMap.get(a.repoId)?.savedAt) || 0; - const tb = Date.parse(decisionMap.get(b.repoId)?.savedAt) || 0; - return tb - ta || a.name.localeCompare(b.name); - }); - } - if (state.sort === 'delta') { - const FIT_ORDER = ['strong', 'solid', 'care', 'risky']; - rows = [...rows].sort((a, b) => { - const ad = a.fitDelta, bd = b.fitDelta; - if (ad && !bd) return -1; - if (!ad && bd) return 1; - if (ad && bd) { - const aImp = FIT_ORDER.indexOf(ad.to) < FIT_ORDER.indexOf(ad.from); - const bImp = FIT_ORDER.indexOf(bd.to) < FIT_ORDER.indexOf(bd.from); - if (aImp !== bImp) return aImp ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); - } - if (state.sort === 'eval') { - rows = [...rows].sort((a, b) => { - const sa = computeScore(evalMap.get(a.repoId) ?? null, rubric) ?? -1; - const sb = computeScore(evalMap.get(b.repoId) ?? null, rubric) ?? -1; - return sb - sa || a.name.localeCompare(b.name); - }); - } - if (state.collection) { - const active = collections.find((c) => c.id === state.collection); - const ids = new Set(active ? active.repoIds : []); - rows = rows.filter((r) => ids.has(r.repoId)); - } - if (state.decision === 'undecided') { - rows = rows.filter((r) => !decisionMap.has(r.repoId)); - } else if (state.decision) { - rows = rows.filter((r) => decisionMap.get(r.repoId)?.decision === state.decision); - } - if (state.lang) { - const lq = state.lang.toLowerCase(); - rows = rows.filter((r) => (r.language || r.languages?.[0]?.name || '').toLowerCase() === lq); - } - if (nlFilter?.ids?.length) { - const idSet = new Set(nlFilter.ids); - rows = rows.filter((r) => idSet.has(r.repoId)); - } else if (nlFilter && !nlFilter.ids?.length && !nlFilter.error) { - rows = []; - } - return rows; + return applyFilters(allRows, state, { decisionMap, evalMap, rubric, collections, nlFilter }); } function showQuickWins() { diff --git a/tests/evaluations.test.js b/tests/evaluations.test.js new file mode 100644 index 0000000..5011ba8 --- /dev/null +++ b/tests/evaluations.test.js @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { computeScore, DEFAULT_RUBRIC } from '../evaluations.js'; + +const rubric = [ + { id: 'docs', name: 'Documentation', weight: 1 }, + { id: 'types', name: 'Type safety', weight: 2 }, +]; + +describe('computeScore', () => { + it('returns null with no scored criteria', () => { + expect(computeScore(null, rubric)).toBeNull(); + expect(computeScore({}, rubric)).toBeNull(); + expect(computeScore({ scores: {} }, rubric)).toBeNull(); + }); + + it('returns null with an empty rubric', () => { + expect(computeScore({ scores: { docs: 5 } }, [])).toBeNull(); + expect(computeScore({ scores: { docs: 5 } }, null)).toBeNull(); + }); + + it('computes a weighted average', () => { + // docs=4 (w1), types=2 (w2) → (4·1 + 2·2) / (1+2) = 8/3 + expect(computeScore({ scores: { docs: 4, types: 2 } }, rubric)).toBeCloseTo(8 / 3); + }); + + it('ignores unscored (0) and out-of-range (>5) criteria', () => { + // types=0 is unscored → only docs=5 counts + expect(computeScore({ scores: { docs: 5, types: 0 } }, rubric)).toBe(5); + // docs=9 is out of range → only types=3 (w2) counts + expect(computeScore({ scores: { docs: 9, types: 3 } }, rubric)).toBe(3); + }); + + it('treats a missing weight as 1', () => { + expect(computeScore({ scores: { a: 2, b: 4 } }, [{ id: 'a' }, { id: 'b' }])).toBe(3); + }); + + it('scores against the default rubric', () => { + const all5 = { scores: { docs: 5, types: 5, maint: 5 } }; + expect(computeScore(all5, DEFAULT_RUBRIC)).toBe(5); + }); +}); diff --git a/tests/library-filters.test.js b/tests/library-filters.test.js new file mode 100644 index 0000000..4d7cdd0 --- /dev/null +++ b/tests/library-filters.test.js @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { applyFilters } from '../library-filters.js'; + +const mkRow = (repoId, over = {}) => ({ + repoId, + name: repoId.split('/').pop(), + fit: { level: 'solid' }, + fitDelta: null, + health: 50, + stars: 0, + capabilities: [], + languages: [{ name: 'JavaScript' }], + savedAt: '2026-01-01T00:00:00Z', + ...over, +}); + +const rows = [ + mkRow('a/one', { fit: { level: 'strong' } }), + mkRow('a/two', { fit: { level: 'risky' }, languages: [{ name: 'Python' }] }), + mkRow('a/three', { fit: { level: 'solid' } }), +]; +const base = { query: '', sort: 'name' }; // 'name' sorts by repoId + +describe('applyFilters', () => { + it('returns every row with an empty filter state', () => { + expect(applyFilters(rows, base, {}).map((r) => r.repoId)).toEqual(['a/one', 'a/three', 'a/two']); + }); + + it('narrows to a language', () => { + const out = applyFilters(rows, { ...base, lang: 'javascript' }, {}); + expect(out.map((r) => r.repoId).sort()).toEqual(['a/one', 'a/three']); + }); + + it('decision=undecided hides rows that have a saved decision', () => { + const decisionMap = new Map([['a/one', { decision: 'adopt', savedAt: '2026-01-01' }]]); + const out = applyFilters(rows, { ...base, decision: 'undecided' }, { decisionMap }); + expect(out.map((r) => r.repoId)).not.toContain('a/one'); + expect(out).toHaveLength(2); + }); + + it('decision= keeps only matching rows', () => { + const decisionMap = new Map([ + ['a/one', { decision: 'adopt' }], + ['a/two', { decision: 'reject' }], + ]); + const out = applyFilters(rows, { ...base, decision: 'adopt' }, { decisionMap }); + expect(out.map((r) => r.repoId)).toEqual(['a/one']); + }); + + it('collection filter keeps only members', () => { + const collections = [{ id: 'c1', repoIds: ['a/two'] }]; + const out = applyFilters(rows, { ...base, collection: 'c1' }, { collections }); + expect(out.map((r) => r.repoId)).toEqual(['a/two']); + }); + + // The exact divergence this module fixes: the export path used to filter by the + // NL ids but NOT re-order by the AI ranking. applyFilters preserves AI order. + it('NL filter restricts to the AI ids AND preserves the AI order', () => { + const out = applyFilters(rows, base, { nlFilter: { ids: ['a/three', 'a/one'] } }); + expect(out.map((r) => r.repoId)).toEqual(['a/three', 'a/one']); + }); + + it('NL filter with empty ids (AI found nothing) returns []', () => { + expect(applyFilters(rows, base, { nlFilter: { ids: [] } })).toEqual([]); + }); + + it('eval sort orders by weighted score, unscored last', () => { + const rubric = [{ id: 'docs', weight: 1 }]; + const evalMap = new Map([ + ['a/one', { scores: { docs: 2 } }], + ['a/three', { scores: { docs: 5 } }], + ]); + const out = applyFilters(rows, { ...base, sort: 'eval' }, { evalMap, rubric }); + expect(out[0].repoId).toBe('a/three'); // 5 > 2 > unscored(-1) + expect(out[out.length - 1].repoId).toBe('a/two'); // unscored sinks + }); +}); diff --git a/tests/oauth-pkce.test.js b/tests/oauth-pkce.test.js new file mode 100644 index 0000000..b6ddb44 --- /dev/null +++ b/tests/oauth-pkce.test.js @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { base64url, createPkcePair } from '../oauth-pkce.js'; + +const B64URL = /^[A-Za-z0-9_-]+$/; + +describe('base64url', () => { + it('encodes bytes to URL-safe base64 with padding stripped', () => { + expect(base64url(new Uint8Array([0, 0, 0]))).toBe('AAAA'); + expect(base64url(new Uint8Array([255, 255, 255]))).toBe('____'); // '/' → '_' + expect(base64url(new Uint8Array([0]))).toBe('AA'); // '==' padding removed + }); + it('never emits +, /, or =', () => { + const out = base64url(new Uint8Array([251, 239, 190, 255, 0, 16])); + expect(out).not.toMatch(/[+/=]/); + expect(out).toMatch(B64URL); + }); +}); + +describe('createPkcePair', () => { + it('returns url-safe verifier / challenge / state of the right sizes', async () => { + const { verifier, challenge, state } = await createPkcePair(); + for (const v of [verifier, challenge, state]) expect(v).toMatch(B64URL); + expect(verifier).toHaveLength(43); // 32 random bytes + expect(challenge).toHaveLength(43); // SHA-256 digest = 32 bytes + expect(state).toHaveLength(22); // 16 random bytes + }); + + it('produces a real S256 challenge = base64url(SHA-256(verifier))', async () => { + const { verifier, challenge } = await createPkcePair(); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + expect(challenge).toBe(base64url(new Uint8Array(digest))); + }); + + it('is random — two pairs differ', async () => { + const a = await createPkcePair(); + const b = await createPkcePair(); + expect(a.verifier).not.toBe(b.verifier); + expect(a.state).not.toBe(b.state); + }); +}); diff --git a/themes.css b/themes.css index ef96f4b..b010584 100644 --- a/themes.css +++ b/themes.css @@ -13,7 +13,7 @@ --text-strong: #ffffff; --text-body: #cbd5e1; --text-sub: #94a3b8; - --text-muted: #64748b; + --text-muted: #6d7c92; --text-faint: #475569; --text-fainter: #334155; @@ -71,7 +71,7 @@ --text-strong: #0f172a; --text-body: #334155; --text-sub: #475569; - --text-muted: #64748b; + --text-muted: #606f85; --text-faint: #94a3b8; --text-fainter: #cbd5e1; @@ -346,7 +346,7 @@ --text-strong: #e0def4; --text-body: #d4d2ee; --text-sub: #908caa; - --text-muted: #6e6a86; + --text-muted: #858299; --text-faint: #44415a; --text-fainter: #2a273f; @@ -371,7 +371,7 @@ --text-strong: #4c4f69; --text-body: #5c5f77; --text-sub: #5e6175; - --text-muted: #6c6f85; + --text-muted: #686b80; --text-faint: #9ca0b0; --text-fainter: #bcc0cc; @@ -396,7 +396,7 @@ --text-strong: #073642; --text-body: #50666e; --text-sub: #50666e; - --text-muted: #6c7c7e; + --text-muted: #637274; --text-faint: #93a1a1; --text-fainter: #c8c4ad;