@oss-scout/core
Advanced tools
@@ -20,3 +20,10 @@ /** | ||
| * `saveLocalState` + `checkpoint()` persist locally. | ||
| * | ||
| * Gist mode requires a token: unauthenticated gist bootstrap 401s and that | ||
| * error propagates by design, which crashed local-only commands (results | ||
| * clear, skip ops) for gist-preference users running without a token (#304). | ||
| * With no token, degrade to provided mode — changes land in the local file | ||
| * and the bootstrap-time merge syncs them to the gist on the next | ||
| * authenticated command (tombstones included, so deletions propagate too). | ||
| */ | ||
| export declare function buildCommandScout(state: ScoutState, token: string): Promise<OssScout>; |
| import { createScout } from "../scout.js"; | ||
| import { warn } from "../core/logger.js"; | ||
| /** | ||
@@ -11,6 +12,16 @@ * Build a scout for a CLI command from already-loaded local state and a token. | ||
| * `saveLocalState` + `checkpoint()` persist locally. | ||
| * | ||
| * Gist mode requires a token: unauthenticated gist bootstrap 401s and that | ||
| * error propagates by design, which crashed local-only commands (results | ||
| * clear, skip ops) for gist-preference users running without a token (#304). | ||
| * With no token, degrade to provided mode — changes land in the local file | ||
| * and the bootstrap-time merge syncs them to the gist on the next | ||
| * authenticated command (tombstones included, so deletions propagate too). | ||
| */ | ||
| export async function buildCommandScout(state, token) { | ||
| if (state.preferences.persistence === "gist") { | ||
| return createScout({ githubToken: token, persistence: "gist" }); | ||
| if (token) { | ||
| return createScout({ githubToken: token, persistence: "gist" }); | ||
| } | ||
| warn("command-scout", "No GitHub token available — changes will be saved locally and synced to the gist on the next authenticated command."); | ||
| } | ||
@@ -17,0 +28,0 @@ return createScout({ |
@@ -61,2 +61,9 @@ /** | ||
| const selected = parseCSV(input); | ||
| // Say which tokens were dropped instead of silently ignoring a typo — | ||
| // "smal" quietly meaning "all scopes" was indistinguishable from | ||
| // success (#314). | ||
| const unrecognized = selected.filter((s) => !options.includes(s)); | ||
| if (unrecognized.length > 0) { | ||
| console.error(` (ignoring unrecognized value(s): ${unrecognized.join(", ")} — valid: ${options.join(", ")})`); | ||
| } | ||
| return selected.filter((s) => options.includes(s)); | ||
@@ -88,2 +95,7 @@ } | ||
| const githubUsername = usernameInput || usernameDefault; | ||
| if (!githubUsername) { | ||
| // Saving an empty username used to dead-end the very next bootstrap | ||
| // with "Run `oss-scout setup` first" — a confusing loop (#314). | ||
| console.error(" ⚠ No GitHub username set — `oss-scout bootstrap` and personalized search won't work until you run `oss-scout config set githubUsername <login>`."); | ||
| } | ||
| // Languages | ||
@@ -105,5 +117,8 @@ const defaultLangs = "any (all languages)"; | ||
| : [...ALL_SCOPES]; | ||
| // Minimum stars | ||
| // Minimum stars — floor at 0 so a typo like "-5" can't persist (#314). | ||
| const minStarsInput = await ask(rl, "Minimum repo stars [50]: "); | ||
| const minStars = minStarsInput ? parseInt(minStarsInput, 10) : 50; | ||
| const minStarsParsed = minStarsInput ? parseInt(minStarsInput, 10) : 50; | ||
| const minStars = Number.isNaN(minStarsParsed) | ||
| ? 50 | ||
| : Math.max(0, minStarsParsed); | ||
| // Project categories | ||
@@ -125,3 +140,3 @@ const categoryOptions = ALL_CATEGORIES.join(", "); | ||
| projectCategories, | ||
| minStars: isNaN(minStars) ? 50 : minStars, | ||
| minStars, | ||
| slmTriageModel, | ||
@@ -128,0 +143,0 @@ }); |
@@ -42,2 +42,8 @@ /** | ||
| const starredRepos = []; | ||
| // Only counts what was actually saved: a mid-pagination failure used to | ||
| // report the partial in-memory length even though nothing persisted (#313). | ||
| let starredRepoSavedCount = 0; | ||
| // Repos whose score this run touched — the returned reposScoredCount used | ||
| // to be the whole pre-existing map, masking fully-failed runs (#313). | ||
| const reposScoredThisRun = new Set(); | ||
| try { | ||
@@ -59,2 +65,3 @@ let starredPage = 0; | ||
| scout.setStarredRepos(starredRepos); | ||
| starredRepoSavedCount = starredRepos.length; | ||
| } | ||
@@ -93,2 +100,3 @@ catch (err) { | ||
| }); | ||
| reposScoredThisRun.add(repo); | ||
| mergedPRCount++; | ||
@@ -133,2 +141,3 @@ } | ||
| }); | ||
| reposScoredThisRun.add(repo); | ||
| closedPRCount++; | ||
@@ -185,10 +194,8 @@ } | ||
| } | ||
| const state = scout.getState(); | ||
| const reposScoredCount = Object.keys(state.repoScores).length; | ||
| return { | ||
| starredRepoCount: starredRepos.length, | ||
| starredRepoCount: starredRepoSavedCount, | ||
| mergedPRCount, | ||
| closedPRCount, | ||
| openPRCount, | ||
| reposScoredCount, | ||
| reposScoredCount: reposScoredThisRun.size, | ||
| skippedDueToRateLimit: false, | ||
@@ -195,0 +202,0 @@ errors, |
@@ -107,2 +107,8 @@ /** | ||
| splitRatio?: number; | ||
| /** Repos the user excluded — anchors must honor them like search does (#307). */ | ||
| excludeRepos?: string[]; | ||
| /** Orgs the user excluded (#307). */ | ||
| excludeOrgs?: string[]; | ||
| /** Repos with anti-AI contribution policies (#307). */ | ||
| aiPolicyBlocklist?: string[]; | ||
| } | ||
@@ -130,2 +136,4 @@ /** | ||
| excludeOrgs?: string[]; | ||
| /** Repos with anti-AI contribution policies, filtered post-search (#307). */ | ||
| aiPolicyBlocklist?: string[]; | ||
| /** Override default split ratio. */ | ||
@@ -132,0 +140,0 @@ splitRatio?: number; |
@@ -16,3 +16,3 @@ /** | ||
| import { warn } from "./logger.js"; | ||
| import { sleep } from "./utils.js"; | ||
| import { sleep, extractRepoFromUrl } from "./utils.js"; | ||
| import { fetchRoadmapIssueRefs } from "./roadmap.js"; | ||
@@ -78,3 +78,7 @@ import { cachedSearchIssues } from "./search-phases.js"; | ||
| .sort((a, b) => b.viabilityScore - a.viabilityScore); | ||
| const targetQuick = Math.round(count * ratio); | ||
| // Clamp: the public library API validates nothing, and an out-of-range | ||
| // ratio made targetBigger negative and the result exceed count (#312). | ||
| // Same pattern as applyDiversityRatio. | ||
| const clampedRatio = Math.max(0, Math.min(1, ratio)); | ||
| const targetQuick = Math.round(count * clampedRatio); | ||
| const targetBigger = count - targetQuick; | ||
@@ -155,2 +159,19 @@ const quickTaken = Math.min(allQuick.length, targetQuick); | ||
| export const NO_RESULTS_MESSAGE = "No open feature opportunities in your anchor repos right now. Check back next week, or try `scout search` for fix-mode work."; | ||
| /** | ||
| * Case-insensitive repo/org exclusion shared by both feature paths (#307). | ||
| * The search pipeline has always honored excludeRepos/excludeOrgs/ | ||
| * aiPolicyBlocklist; feature discovery previously bypassed all three on the | ||
| * anchor path and the blocklist on the broad path. | ||
| */ | ||
| function buildRepoExclusionFilter(opts) { | ||
| const excluded = new Set([...(opts.excludeRepos ?? []), ...(opts.aiPolicyBlocklist ?? [])].map((r) => r.toLowerCase())); | ||
| const excludedOrgs = new Set((opts.excludeOrgs ?? []).map((o) => o.toLowerCase())); | ||
| return (repoFullName) => { | ||
| const lower = repoFullName.toLowerCase(); | ||
| if (excluded.has(lower)) | ||
| return false; | ||
| const org = lower.split("/")[0]; | ||
| return !(org && excludedOrgs.has(org)); | ||
| }; | ||
| } | ||
| function extractLabels(item) { | ||
@@ -230,3 +251,4 @@ if (!Array.isArray(item.labels)) | ||
| export async function discoverFeatures(opts) { | ||
| const anchorRepos = resolveAnchorRepos(opts.repoScores, opts.anchorThreshold); | ||
| const includeRepo = buildRepoExclusionFilter(opts); | ||
| const anchorRepos = resolveAnchorRepos(opts.repoScores, opts.anchorThreshold).filter(includeRepo); | ||
| if (anchorRepos.length === 0) { | ||
@@ -377,4 +399,12 @@ return { | ||
| } | ||
| // The queries already carry -repo:/-org: exclusions; the blocklist is | ||
| // filtered here instead so it can't blow GitHub's query-length/operator | ||
| // limits, and the post-filter also backstops the query path (#307). | ||
| const includeRepo = buildRepoExclusionFilter(opts); | ||
| items = merged | ||
| .filter((it) => !it.pull_request && !it.assignee && isFeatureIssue(it)) | ||
| .filter((it) => { | ||
| const repo = extractRepoFromUrl(it.html_url); | ||
| return repo === null || includeRepo(repo); | ||
| }) | ||
| .slice(0, maxToVet); | ||
@@ -381,0 +411,0 @@ } |
@@ -400,2 +400,15 @@ /** | ||
| const preferencesUpdatedAt = pickFresherTimestamp(localPrefsTs, remotePrefsTs); | ||
| // A resolved PR must never reappear as open. openPRs removals carry no | ||
| // tombstone, so the plain union resurrected PRs that sync had just | ||
| // recorded as merged/closed — in local mode this meant openPRs could | ||
| // never shrink and every sync re-checked every resolved PR forever | ||
| // (#303). Presence in the merged mergedPRs/closedPRs is authoritative | ||
| // removal; this also self-heals states corrupted before the fix. | ||
| const mergedPRsFinal = unionByUrl(local.mergedPRs, remote.mergedPRs); | ||
| const closedPRsFinal = unionByUrl(local.closedPRs, remote.closedPRs); | ||
| const resolvedUrls = new Set([ | ||
| ...mergedPRsFinal.map((p) => p.url), | ||
| ...closedPRsFinal.map((p) => p.url), | ||
| ]); | ||
| const openPRsFinal = unionByUrl(local.openPRs ?? [], remote.openPRs ?? []).filter((p) => !resolvedUrls.has(p.url)); | ||
| return { | ||
@@ -411,5 +424,5 @@ ...local, | ||
| starredReposLastFetched: pickFresherTimestamp(local.starredReposLastFetched, remote.starredReposLastFetched), | ||
| mergedPRs: unionByUrl(local.mergedPRs, remote.mergedPRs), | ||
| closedPRs: unionByUrl(local.closedPRs, remote.closedPRs), | ||
| openPRs: unionByUrl(local.openPRs ?? [], remote.openPRs ?? []), | ||
| mergedPRs: mergedPRsFinal, | ||
| closedPRs: closedPRsFinal, | ||
| openPRs: openPRsFinal, | ||
| savedResults: savedResultsFinal, | ||
@@ -416,0 +429,0 @@ skippedIssues, |
@@ -375,2 +375,5 @@ /** | ||
| let searchBudget = CRITICAL_BUDGET_THRESHOLD - 1; | ||
| // Tracked so the summary says "quota unknown" instead of presenting the | ||
| // fabricated fallback number as a real reading (#309). | ||
| let preflightFailed = false; | ||
| try { | ||
@@ -395,2 +398,3 @@ const rateLimit = await checkRateLimit(this.githubToken); | ||
| throw error; | ||
| preflightFailed = true; | ||
| tracker.init(CRITICAL_BUDGET_THRESHOLD, new Date(Date.now() + 60000).toISOString()); | ||
@@ -565,3 +569,5 @@ warn(MODULE, "Could not check rate limit — using conservative budget, skipping the starred phase:", errorMessage(error)); | ||
| const budgetNote = phasesSkippedForBudget | ||
| ? ` The starred-repo phase was skipped due to critically low API quota (${searchBudget} remaining); broad and maintained phases still ran on GraphQL.` | ||
| ? preflightFailed | ||
| ? " The starred-repo phase was skipped as a precaution because the rate-limit check failed (quota unknown); broad and maintained phases still ran on GraphQL." | ||
| : ` The starred-repo phase was skipped due to critically low API quota (${searchBudget} remaining); broad and maintained phases still ran on GraphQL.` | ||
| : ""; | ||
@@ -568,0 +574,0 @@ if (allCandidates.length === 0) { |
@@ -10,3 +10,3 @@ /** | ||
| import { parseGitHubUrl } from "./utils.js"; | ||
| import { ValidationError, errorMessage, isAuthError, isRateLimitError, } from "./errors.js"; | ||
| import { ValidationError, errorMessage, getHttpStatusCode, isAuthError, isRateLimitError, } from "./errors.js"; | ||
| import { debug, warn } from "./logger.js"; | ||
@@ -494,2 +494,10 @@ import { calculateRepoQualityBonus, calculateViabilityScore, } from "./issue-scoring.js"; | ||
| let firstAuthError = null; | ||
| // Bare (non-rate-limit) 403s are frequently repo-scoped — SAML-enforced | ||
| // orgs, fine-grained PATs without a grant for one repo — so they must | ||
| // not abort the batch and discard good results (#305). Skip further | ||
| // issues from the forbidden repo; only if every attempted vet was | ||
| // forbidden do we surface it as an auth failure (dead token). | ||
| let firstForbiddenError = null; | ||
| let forbiddenCount = 0; | ||
| const forbiddenRepos = new Set(); | ||
| // Dedup defensively: the pending map is keyed by URL, so a duplicate | ||
@@ -532,2 +540,8 @@ // input would overwrite the in-flight entry and its finally-cleanup | ||
| break; // stop scheduling once auth has failed | ||
| const parsedUrl = parseGitHubUrl(url); | ||
| const repoKey = parsedUrl ? `${parsedUrl.owner}/${parsedUrl.repo}` : null; | ||
| if (repoKey && forbiddenRepos.has(repoKey)) { | ||
| debug(MODULE, `Skipping ${url}: repo already returned 403`); | ||
| continue; | ||
| } | ||
| attemptedCount++; | ||
@@ -546,9 +560,19 @@ const core = prefetchFor(url); | ||
| .catch((error) => { | ||
| // Abort on anything resolveErrorCode calls AUTH_REQUIRED — 401 or a | ||
| // non-rate-limit 403 (SAML, token scope). Retrying the rest of the | ||
| // batch with the same token can only burn budget (#290). | ||
| if (isAuthError(error)) { | ||
| // A 401 is token-global: no other issue can succeed, so stop the | ||
| // batch (#290). | ||
| if (getHttpStatusCode(error) === 401) { | ||
| firstAuthError ??= error; | ||
| return; | ||
| } | ||
| // A bare 403 is treated as repo-scoped (#305): skip this repo's | ||
| // remaining issues but keep vetting the rest of the batch. | ||
| if (isAuthError(error)) { | ||
| firstForbiddenError ??= error; | ||
| forbiddenCount++; | ||
| failedVettingCount++; | ||
| if (repoKey) | ||
| forbiddenRepos.add(repoKey); | ||
| warn(MODULE, `Access forbidden for ${url} (SAML/token scope?) — skipping this repo:`, errorMessage(error)); | ||
| return; | ||
| } | ||
| failedVettingCount++; | ||
@@ -575,2 +599,10 @@ if (isRateLimitError(error)) { | ||
| } | ||
| // Every attempted vet was forbidden — that's not repo-scoped, the token | ||
| // is effectively dead for this search. Surface it as the auth error it | ||
| // is instead of an empty result (#305). | ||
| if (firstForbiddenError && | ||
| attemptedCount > 0 && | ||
| forbiddenCount === attemptedCount) { | ||
| throw firstForbiddenError; | ||
| } | ||
| const allFailed = failedVettingCount === attemptedCount && attemptedCount > 0; | ||
@@ -577,0 +609,0 @@ if (allFailed) { |
+13
-1
@@ -63,6 +63,18 @@ /** | ||
| const fullRefStripPattern = /\b[\w.-]+\/[\w.-]+#\d+\b/gi; | ||
| // Skip fenced code blocks and inline code spans: `color: #333` in a code | ||
| // sample is a hex color, not issue 333 (#315). A prose-level 3-digit | ||
| // color outside code remains indistinguishable — accepted residual. | ||
| let inFence = false; | ||
| for (const line of content.split("\n")) { | ||
| if (/^\s*(```|~~~)/.test(line)) { | ||
| inFence = !inFence; | ||
| continue; | ||
| } | ||
| if (inFence) | ||
| continue; | ||
| if (/^\s*#+\s/.test(line)) | ||
| continue; | ||
| const stripped = line.replace(fullRefStripPattern, ""); | ||
| const stripped = line | ||
| .replace(fullRefStripPattern, "") | ||
| .replace(/`[^`]*`/g, ""); | ||
| for (const m of stripped.matchAll(/(?:^|[^&\w])#(\d+)\b/g)) { | ||
@@ -69,0 +81,0 @@ const n = Number.parseInt(m[1], 10); |
@@ -81,2 +81,6 @@ import { ValidationError } from "./errors.js"; | ||
| // Single-label LAN hostnames ("ollama-box") and mDNS .local names. | ||
| // Known residual (#310): a single-label name can resolve off-host through | ||
| // a DNS search domain, and .local via a hostile mDNS responder — both | ||
| // require an attacker who can already write this config (local access), | ||
| // so they are accepted by design. | ||
| return !host.includes(".") || host.endsWith(".local"); | ||
@@ -123,2 +127,6 @@ } | ||
| signal: AbortSignal.timeout(timeoutMs), | ||
| // No redirect following: an allowed private host could otherwise | ||
| // 307/308-redirect the issue-text POST body to a public origin (#310). | ||
| // Ollama never redirects, so this costs nothing. | ||
| redirect: "error", | ||
| }); | ||
@@ -125,0 +133,0 @@ } |
@@ -5,5 +5,13 @@ /** | ||
| */ | ||
| /** Escape pipe and newline so a title can't break the markdown table. */ | ||
| /** | ||
| * Escape markdown so an attacker-authored issue title renders as inert text | ||
| * (#308). The digest is posted as a GitHub issue body by action.yml, so an | ||
| * unescaped title could plant live links, images, or HTML in the user's | ||
| * trusted digest. Newlines and pipes also keep the table intact. | ||
| */ | ||
| function cell(value) { | ||
| return value.replace(/\r?\n/g, " ").replace(/\|/g, "\\|").trim(); | ||
| return value | ||
| .replace(/\r?\n/g, " ") | ||
| .replace(/[\\`*_[\]()!<>~|]/g, "\\$&") | ||
| .trim(); | ||
| } | ||
@@ -23,3 +31,7 @@ /** | ||
| const issueLink = `[#${r.number}](${r.issueUrl})`; | ||
| return `| ${r.viabilityScore} | ${cell(r.repo)} | ${issueLink} | ${cell(r.recommendation)} | ${cell(r.title)} |`; | ||
| // Title rendered as a link to the real issue: GFM does not process | ||
| // autolinks inside link text, so a bare URL pasted into a title can't | ||
| // become a clickable phishing link either (#308). | ||
| const titleLink = `[${cell(r.title)}](${r.issueUrl})`; | ||
| return `| ${r.viabilityScore} | ${cell(r.repo)} | ${issueLink} | ${cell(r.recommendation)} | ${titleLink} |`; | ||
| }); | ||
@@ -26,0 +38,0 @@ return [ |
+4
-0
@@ -322,2 +322,3 @@ /** | ||
| excludeOrgs: this.state.preferences.excludeOrgs, | ||
| aiPolicyBlocklist: this.state.preferences.aiPolicyBlocklist, | ||
| splitRatio: options?.splitRatio ?? this.state.preferences.featuresSplitRatio, | ||
@@ -330,2 +331,5 @@ }) | ||
| count, | ||
| excludeRepos: this.state.preferences.excludeRepos, | ||
| excludeOrgs: this.state.preferences.excludeOrgs, | ||
| aiPolicyBlocklist: this.state.preferences.aiPolicyBlocklist, | ||
| anchorThreshold: options?.anchorThreshold ?? | ||
@@ -332,0 +336,0 @@ this.state.preferences.featuresAnchorThreshold, |
+1
-1
| { | ||
| "name": "@oss-scout/core", | ||
| "version": "1.5.1", | ||
| "version": "1.5.2", | ||
| "description": "Personalized GitHub issue finder with multi-strategy search, deep vetting, and viability scoring — CLI, library, MCP server, and Claude Code plugin", | ||
@@ -5,0 +5,0 @@ "type": "module", |
Sorry, the diff of this file is too big to display
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1138120
0.97%14689
1.18%