Skip to content

Commit f905613

Browse files
committed
chore: harden production readiness
1 parent afba98d commit f905613

16 files changed

Lines changed: 422 additions & 76 deletions

.github/workflows/ci.yml

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,29 @@ jobs:
2424
# terminates an attribute) that node --check + vitest + eslint can't see.
2525
- name: HTML parse check
2626
run: npm run check:html
27-
- name: Format check (advisory)
27+
- name: Format check
2828
run: npm run format:check
29-
continue-on-error: true
3029
- name: Dependency audit
3130
run: npm audit --audit-level=high
3231

32+
mcp:
33+
runs-on: ubuntu-latest
34+
steps:
35+
- uses: actions/checkout@v5
36+
- uses: actions/setup-node@v5
37+
with:
38+
node-version: '20'
39+
cache: 'npm'
40+
cache-dependency-path: mcp/package-lock.json
41+
- run: npm ci
42+
working-directory: mcp
43+
- name: MCP syntax check
44+
run: find . -maxdepth 1 -name '*.js' -print0 | xargs -0 -n1 node --check
45+
working-directory: mcp
46+
- name: MCP dependency audit
47+
run: npm audit --audit-level=moderate
48+
working-directory: mcp
49+
3350
website:
3451
runs-on: ubuntu-latest
3552
steps:

.github/workflows/release.yml

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,35 @@ jobs:
4242
- name: Package Chrome extension
4343
run: |
4444
VERSION=$(node -p "require('./manifest.json').version")
45+
ZIP="dist/repolens-v${VERSION}.zip"
46+
rm -rf dist repolens-release
4547
mkdir -p dist repolens-release
48+
4649
cp manifest.json README.md CHANGELOG.md repolens-release/
4750
cp batch.html library.html options.html output-tab.html share.html stack-tab.html whats-new.html repolens-release/
48-
cp ./*.js ./*.css repolens-release/
51+
cp ./*.css repolens-release/
52+
for file in ./*.js; do
53+
case "$(basename "$file")" in
54+
eslint.config.js|vitest.config.js) continue ;;
55+
esac
56+
cp "$file" repolens-release/
57+
done
4958
cp -R icons assets store migrate vendor repolens-release/
50-
(cd repolens-release && zip -r "../dist/repolens-v${VERSION}.zip" . -x '*.DS_Store')
59+
(cd repolens-release && zip -r "../${ZIP}" . -x '*.DS_Store')
60+
61+
unzip -Z1 "$ZIP" | sort > /tmp/repolens-zip-files.txt
62+
cat /tmp/repolens-zip-files.txt
63+
forbidden='(^|/)(node_modules|tests|coverage|website|mcp|docs|tools|\.github|\.git|\.verify|\.playwright-mcp)/|(^|/)(eslint\.config\.js|vitest\.config\.js|package-lock\.json|package\.json)$'
64+
if grep -E "$forbidden" /tmp/repolens-zip-files.txt; then
65+
echo "Release zip contains forbidden dev/test files" >&2
66+
exit 1
67+
fi
68+
for required in manifest.json background.js output-tab.html library.html whats-new.html store/idb.js icons/icon128.png; do
69+
if ! grep -Fxq "$required" /tmp/repolens-zip-files.txt; then
70+
echo "Release zip missing required file: $required" >&2
71+
exit 1
72+
fi
73+
done
5174
- uses: actions/upload-artifact@v4
5275
with:
5376
name: repolens-extension-zip

background.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1993,7 +1993,6 @@ async function callXAI(model = 'grok-4.3', prompt) {
19931993
throw new Error('xAI returned no text content');
19941994
}
19951995
const err = await res.json().catch(() => ({}));
1996-
console.warn('[RepoLens xAI]', endpoint, res.status, JSON.stringify(err));
19971996
lastErr = err.error?.message || 'xAI API error ' + res.status + ' at ' + endpoint;
19981997
if (res.status === 401 && isOAuth) {
19991998
await chrome.storage.local.remove(['xaiKey', 'xaiRefresh', 'xaiExpiry', 'xaiCredentials']);

backup.js

Lines changed: 104 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,101 @@ export const MAX_ROWS = {
2626
scenes: 2000,
2727
};
2828

29+
// String caps stop a hostile backup from smuggling a tiny row count with huge
30+
// values (e.g. a 1 MB repoId / note) that would pin the IDB lock or blow quota.
31+
export const MAX_STRING_LENGTHS = {
32+
id: 512,
33+
repoId: 256,
34+
label: 128,
35+
scalar: 20_000,
36+
};
37+
2938
// Per-repo snapshot ring-buffer cap — single source of truth in snapshots.js; each
3039
// imported snapshots row is trimmed to its most recent SNAP_CAP entries.
3140
const SNAP_CAP = SNAPSHOT_CAP;
3241

3342
const arr = (x) => (Array.isArray(x) ? x : []);
34-
const rowHasRepo = (r) => !!(r && r.id != null && r.payload && r.payload.repoId);
35-
const rowHasId = (r) => !!(r && r.id != null && r.payload != null);
36-
const edgeOk = (e) => !!(e && e.id != null && e.source != null && e.target != null && e.label);
37-
const cacheOk = (c) => !!(c && c.repoId && c.platform);
38-
const collectionOk = (c) => !!(c && c.id != null && c.payload && typeof c.payload.name === 'string');
39-
const decisionOk = (d) => !!(d && d.id != null && d.payload && d.payload.repoId && d.payload.decision);
40-
const snapshotOk = (r) => !!(r && r.id != null && r.repoId && Array.isArray(r.snaps));
41-
const sceneOk = (s) => !!(s && s.id && s.scope && Array.isArray(s.nodes) && Array.isArray(s.edges));
43+
const shortString = (x, max = MAX_STRING_LENGTHS.scalar) => typeof x !== 'string' || x.length <= max;
44+
const shortId = (x) => shortString(String(x ?? ''), MAX_STRING_LENGTHS.id);
45+
const shortRepoId = (x) => typeof x === 'string' && x.length > 0 && x.length <= MAX_STRING_LENGTHS.repoId;
46+
const shortLabel = (x) => typeof x === 'string' && x.length > 0 && x.length <= MAX_STRING_LENGTHS.label;
47+
function stringsWithin(value, max = MAX_STRING_LENGTHS.scalar) {
48+
if (typeof value === 'string') return value.length <= max;
49+
if (!value || typeof value !== 'object') return true;
50+
if (Array.isArray(value)) return value.every((v) => stringsWithin(v, max));
51+
return Object.values(value).every((v) => stringsWithin(v, max));
52+
}
53+
const rowHasRepo = (r) =>
54+
!!(
55+
r &&
56+
r.id != null &&
57+
shortId(r.id) &&
58+
r.payload &&
59+
shortRepoId(r.payload.repoId) &&
60+
stringsWithin(r.payload)
61+
);
62+
const rowHasId = (r) =>
63+
!!(r && r.id != null && shortId(r.id) && r.payload != null && stringsWithin(r.payload));
64+
const edgeOk = (e) =>
65+
!!(
66+
e &&
67+
e.id != null &&
68+
shortId(e.id) &&
69+
e.source != null &&
70+
shortId(e.source) &&
71+
e.target != null &&
72+
shortId(e.target) &&
73+
shortLabel(e.label) &&
74+
stringsWithin(e.properties || {})
75+
);
76+
const cacheOk = (c) =>
77+
!!(
78+
c &&
79+
shortRepoId(c.repoId) &&
80+
typeof c.platform === 'string' &&
81+
shortString(c.platform, 64) &&
82+
stringsWithin(c)
83+
);
84+
const collectionOk = (c) =>
85+
!!(
86+
c &&
87+
c.id != null &&
88+
shortId(c.id) &&
89+
c.payload &&
90+
typeof c.payload.name === 'string' &&
91+
shortString(c.payload.name, 120) &&
92+
stringsWithin(c.payload)
93+
);
94+
const decisionOk = (d) =>
95+
!!(
96+
d &&
97+
d.id != null &&
98+
shortId(d.id) &&
99+
d.payload &&
100+
shortRepoId(d.payload.repoId) &&
101+
d.payload.decision &&
102+
stringsWithin(d.payload)
103+
);
104+
const snapshotOk = (r) =>
105+
!!(
106+
r &&
107+
r.id != null &&
108+
shortId(r.id) &&
109+
shortRepoId(r.repoId) &&
110+
Array.isArray(r.snaps) &&
111+
stringsWithin(r.snaps)
112+
);
113+
const sceneOk = (s) =>
114+
!!(
115+
s &&
116+
s.id &&
117+
shortString(s.id, MAX_STRING_LENGTHS.id) &&
118+
s.scope &&
119+
shortString(s.scope, 64) &&
120+
Array.isArray(s.nodes) &&
121+
Array.isArray(s.edges) &&
122+
stringsWithin(s)
123+
);
42124

43125
/** Empty normalized shape — the safe fallback when a file can't be parsed. */
44126
function emptyValue() {
@@ -152,24 +234,22 @@ export function validateBackup(obj) {
152234
return kept;
153235
};
154236
const value = {
155-
repos: clamp('repos', arr(obj.repos).filter(rowHasRepo)),
156-
nodes: clamp('nodes', arr(obj.nodes).filter(rowHasId)),
157-
edges: clamp('edges', arr(obj.edges).filter(edgeOk)),
158-
cache: clamp('cache', arr(obj.cache).filter(cacheOk)),
159-
collections: clamp('collections', arr(obj.collections).filter(collectionOk)),
160-
decisions: clamp('decisions', arr(obj.decisions).filter(decisionOk)),
237+
repos: clamp('repos', filterWarn('repo', arr(obj.repos), rowHasRepo)),
238+
nodes: clamp('nodes', filterWarn('node', arr(obj.nodes), rowHasId)),
239+
edges: clamp('edges', filterWarn('edge', arr(obj.edges), edgeOk)),
240+
cache: clamp('cache', filterWarn('cache', arr(obj.cache), cacheOk)),
241+
collections: clamp('collections', filterWarn('collection', arr(obj.collections), collectionOk)),
242+
decisions: clamp('decisions', filterWarn('decision', arr(obj.decisions), decisionOk)),
161243
snapshots: clamp(
162244
'snapshots',
163-
arr(obj.snapshots)
164-
.filter(snapshotOk)
165-
.map((r) => ({
166-
...r,
167-
// Trim to the cap and coerce each snap's flags to an array — a corrupt/hostile
168-
// file may carry a non-array `flags` that would later throw in snapshotTrend.
169-
snaps: arr(r.snaps)
170-
.slice(-SNAP_CAP)
171-
.map((s) => (s && typeof s === 'object' ? { ...s, flags: arr(s.flags) } : s)),
172-
}))
245+
filterWarn('snapshot', arr(obj.snapshots), snapshotOk).map((r) => ({
246+
...r,
247+
// Trim to the cap and coerce each snap's flags to an array — a corrupt/hostile
248+
// file may carry a non-array `flags` that would later throw in snapshotTrend.
249+
snaps: arr(r.snaps)
250+
.slice(-SNAP_CAP)
251+
.map((s) => (s && typeof s === 'object' ? { ...s, flags: arr(s.flags) } : s)),
252+
}))
173253
),
174254
scenes: clamp('scenes', filterWarn('scene', arr(obj.scenes), sceneOk)),
175255
};

cache.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// not cached.
55

66
const PREFIX = 'rlcache:';
7+
const ASK_PREFIX = 'repolens_ask_';
78
const LENS_KEYS = ['deepDive', 'systems', 'ideate', 'prioritize', 'sktpg', 'versus', 'synergies'];
89

910
export function cacheKey(platform, repoId) {
@@ -72,3 +73,16 @@ export async function clearCache() {
7273
if (keys.length) await chrome.storage.local.remove(keys);
7374
return keys.length;
7475
}
76+
77+
export async function removeAskHistory(repoId) {
78+
const id = String(repoId || '');
79+
if (!id) return;
80+
await chrome.storage.local.remove(`${ASK_PREFIX}${id}`);
81+
}
82+
83+
export async function clearAskHistory() {
84+
const all = await chrome.storage.local.get(null);
85+
const keys = Object.keys(all).filter((k) => k.startsWith(ASK_PREFIX));
86+
if (keys.length) await chrome.storage.local.remove(keys);
87+
return keys.length;
88+
}

library-data.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,13 @@ export function sourceUrl(platform, repoId) {
188188
return `https://github.com/search?q=${encodeURIComponent(id)}&type=repositories`;
189189
}
190190

191+
/** Markdown-safe repo link using the platform-aware source URL. */
192+
export function repoMarkdownLink(row) {
193+
const repoId = String(row?.repoId || '');
194+
const safeLabel = repoId.replace(/\\/g, '\\\\').replace(/\[/g, '\\[').replace(/]/g, '\\]');
195+
return `[${safeLabel}](${sourceUrl(row?.platform || '', repoId)})`;
196+
}
197+
191198
/** Union two row lists by repoId — primary rows win, secondary fills the gaps.
192199
* Returns a NEW array; neither input is mutated. */
193200
export function mergeRows(primary, secondary) {

library.js

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,23 @@ import {
4444
nextColor,
4545
COLLECTION_COLORS,
4646
} from './collections.js';
47-
import { listCached, removeCached, openCachedAnalysis, importCache, clearCache } from './cache.js';
47+
import {
48+
listCached,
49+
removeCached,
50+
openCachedAnalysis,
51+
importCache,
52+
clearCache,
53+
removeAskHistory,
54+
clearAskHistory,
55+
} from './cache.js';
4856
import {
4957
libraryRow,
5058
sortRows,
5159
filterRows,
5260
allCapabilities,
5361
relativeTime,
5462
sourceUrl,
63+
repoMarkdownLink,
5564
mergeRows,
5665
libraryStats,
5766
} from './library-data.js';
@@ -736,6 +745,7 @@ async function removeRepo(repoId, btn) {
736745
}
737746
}
738747
await deleteRepo(repoId); // best-effort; never throws
748+
await removeAskHistory(repoId).catch(() => {});
739749
cacheByRepo.delete(repoId);
740750
allRows = allRows.filter((row) => row.repoId !== repoId);
741751
await pruneRepoFromCollections(repoId); // keep collection membership honest
@@ -1090,6 +1100,7 @@ async function deleteSelected() {
10901100
}
10911101
}
10921102
await deleteRepo(repoId); // best-effort; never throws
1103+
await removeAskHistory(repoId).catch(() => {});
10931104
cacheByRepo.delete(repoId);
10941105
}
10951106
const idSet = new Set(ids);
@@ -1432,6 +1443,7 @@ async function clearLibraryFlow(btn) {
14321443
setStatus('Clearing library…');
14331444
await clearLibrary();
14341445
await clearCache().catch(() => {});
1446+
await clearAskHistory().catch(() => {});
14351447
setStatus('Library cleared. Reloading…');
14361448
setTimeout(() => location.reload(), 600);
14371449
} catch (e) {
@@ -2308,7 +2320,7 @@ function exportDigest(format) {
23082320
]
23092321
.filter(Boolean)
23102322
.join(' · ');
2311-
return `- **[${r.repoId}](https://github.com/${r.repoId})**${decLabel(r.repoId)}${meta ? ` — ${meta}` : ''}\n ${r.blurb ? r.blurb.slice(0, 120) : ''}${noteText(r.repoId)}`;
2323+
return `- **${repoMarkdownLink(r)}**${decLabel(r.repoId)}${meta ? ` — ${meta}` : ''}\n ${r.blurb ? r.blurb.slice(0, 120) : ''}${noteText(r.repoId)}`;
23122324
};
23132325
const sections = Object.entries(groups)
23142326
.filter(([, rs]) => rs.length)
@@ -2551,7 +2563,7 @@ function exportVisible(_format) {
25512563
]
25522564
.filter(Boolean)
25532565
.join(' · ');
2554-
return `- **[${r.repoId}](https://github.com/${r.repoId})**${decLabel(r.repoId)}${meta ? ` — ${meta}` : ''}\n ${r.blurb ? r.blurb.slice(0, 120) : ''}${noteText(r.repoId)}`;
2566+
return `- **${repoMarkdownLink(r)}**${decLabel(r.repoId)}${meta ? ` — ${meta}` : ''}\n ${r.blurb ? r.blurb.slice(0, 120) : ''}${noteText(r.repoId)}`;
25552567
};
25562568
const filter = [
25572569
state.query && `query: "${state.query}"`,
@@ -2785,7 +2797,7 @@ document.addEventListener('keydown', (e) => {
27852797
if (e.key === 'o') {
27862798
e.preventDefault();
27872799
const row = allRows.find((r) => r.repoId === repoId);
2788-
if (row) window.open(sourceUrl(row), '_blank', 'noopener');
2800+
if (row) window.open(sourceUrl(row.platform || '', row.repoId), '_blank', 'noopener');
27892801
}
27902802
if (e.key === 'r') {
27912803
e.preventDefault();

manifest.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,6 @@
7676
"128": "icons/icon128.png"
7777
}
7878
},
79-
"web_accessible_resources": [{ "resources": ["whats-new.html"], "matches": ["<all_urls>"] }],
8079
"options_page": "options.html",
8180
"icons": {
8281
"16": "icons/icon16.png",

0 commit comments

Comments
 (0)