Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions library-filters.js
Original file line number Diff line number Diff line change
@@ -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;
}
109 changes: 3 additions & 106 deletions library.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
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 <html data-theme>).
initTheme();
Expand Down Expand Up @@ -177,62 +178,7 @@
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));
Expand Down Expand Up @@ -626,7 +572,7 @@

const TABLE_ROWS = [
{ label: 'Fit', fn: r => `<span class="lc-chip fit-${esc(r.fit?.level ?? 'unrated')}">${esc(r.fit?.label ?? '—')}</span>` },
{ label: 'Fit delta', fn: r => r.fitDelta ? `<span class="lc-fit-delta ${FIT_ORDER.indexOf(r.fitDelta.to) < FIT_ORDER.indexOf(r.fitDelta.from) ? 'up' : 'down'}">${r.fitDelta.from} → ${r.fitDelta.to}</span>` : '—' },

Check failure on line 575 in library.js

View workflow job for this annotation

GitHub Actions / test

'FIT_ORDER' is not defined

Check failure on line 575 in library.js

View workflow job for this annotation

GitHub Actions / test

'FIT_ORDER' is not defined
{ label: 'Health', fn: r => r.health != null ? `${r.health}%` : '—' },
{ label: 'Stars', fn: r => r.stars != null ? (r.stars >= 1000 ? (r.stars / 1000).toFixed(1) + 'k' : String(r.stars)) : '—' },
{ label: 'Language', fn: r => esc(r.languages?.[0]?.name ?? '—') },
Expand Down Expand Up @@ -1832,56 +1778,7 @@
}

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() {
Expand Down Expand Up @@ -1998,7 +1895,7 @@
{ key: 'reject', label: 'Reject', color: '#ef4444' },
];
const veeHint = suggested
? `<button class="qdec-vee" data-d="${suggested}" title="Accept Vee's suggestion"><span class="qdec-vee-ic" aria-hidden="true">✦</span><span class="qdec-vee-tier">${esc(DECISION_META[suggested]?.label || suggested)}</span><span class="qdec-vee-why">${esc(fitLabel)}${health ? ` · ♥ ${health}` : ''}</span></button>`

Check failure on line 1898 in library.js

View workflow job for this annotation

GitHub Actions / test

Irregular whitespace not allowed
: '';
pop.innerHTML = `<p class="qdec-heading">${esc(repoId.replace(/^[^/]+\//, ''))}</p>` +
veeHint +
Expand Down
41 changes: 41 additions & 0 deletions tests/evaluations.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
77 changes: 77 additions & 0 deletions tests/library-filters.test.js
Original file line number Diff line number Diff line change
@@ -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=<value> 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
});
});
40 changes: 40 additions & 0 deletions tests/oauth-pkce.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading