Sign In

@oss-scout/core

Package Overview
Dependencies
Maintainers
1
Versions
29
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@oss-scout/core - npm Package Compare versions

Comparing version
1.5.1
to
1.5.2
+7
-0
dist/commands/command-scout.d.ts

@@ -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>;
+12
-1
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 @@ });

+11
-4

@@ -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) {

@@ -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 [

@@ -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,

{
"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