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.0
to
1.5.1
+19
-12
dist/cli.js

@@ -13,2 +13,3 @@ #!/usr/bin/env node

import { CONCRETE_STRATEGIES, SearchStrategySchema } from "./core/schemas.js";
import { parseStrictInt } from "./commands/validation.js";
function handleCommandError(err, options) {

@@ -122,4 +123,4 @@ if (options.json) {

const { runSearch } = await import("./commands/search.js");
const maxResults = count ? parseInt(count, 10) : 10;
if (isNaN(maxResults) || maxResults < 1) {
const maxResults = count ? parseStrictInt(count, "count") : 10;
if (maxResults < 1) {
throw new ValidationError("count must be a positive integer");

@@ -202,4 +203,4 @@ }

const { runFeatures } = await import("./commands/features.js");
const maxResults = count ? parseInt(count, 10) : 10;
if (isNaN(maxResults) || maxResults < 1 || maxResults > 50) {
const maxResults = count ? parseStrictInt(count, "count") : 10;
if (maxResults < 1 || maxResults > 50) {
throw new ValidationError("count must be an integer between 1 and 50");

@@ -209,4 +210,4 @@ }

if (options.anchorThreshold !== undefined) {
const parsed = parseInt(options.anchorThreshold, 10);
if (isNaN(parsed) || parsed < 1 || parsed > 50) {
const parsed = parseStrictInt(options.anchorThreshold, "--anchor-threshold");
if (parsed < 1 || parsed > 50) {
throw new ValidationError("--anchor-threshold must be an integer between 1 and 50");

@@ -218,3 +219,4 @@ }

if (options.splitRatio !== undefined) {
const parsed = Number.parseFloat(options.splitRatio);
// Number() rejects trailing garbage ("0.6abc"), unlike parseFloat.
const parsed = Number(options.splitRatio);
if (isNaN(parsed) || parsed < 0 || parsed > 1) {

@@ -333,8 +335,13 @@ throw new ValidationError("--split-ratio must be a number between 0 and 1");

.option("--prune", "Remove unavailable issues from saved results")
.option("--concurrency <n>", "Max concurrent API requests (default: 5)", parseInt)
.option("--concurrency <n>", "Max concurrent API requests (default: 5)")
.option("--json", "Output as JSON")
.action(async (options) => runAction(options, async () => {
if (options.concurrency !== undefined &&
(isNaN(options.concurrency) || options.concurrency < 1)) {
throw new ValidationError("--concurrency must be a positive integer");
let concurrency;
if (options.concurrency !== undefined) {
// Parsed in the action (not a commander argParser) so a bad value
// honors the --json error contract instead of a raw parse error.
concurrency = parseStrictInt(options.concurrency, "--concurrency");
if (concurrency < 1) {
throw new ValidationError("--concurrency must be a positive integer");
}
}

@@ -346,3 +353,3 @@ const { runVetList } = await import("./commands/vet-list.js");

prune: options.prune,
concurrency: options.concurrency,
concurrency,
});

@@ -349,0 +356,0 @@ if (options.json) {

@@ -19,2 +19,8 @@ /**

export declare function runResults(options?: ResultsOptions): Promise<SavedCandidate[]>;
/**
* Clear all saved results via the scout so deletion tombstones are recorded
* (#117). Writing an empty list straight to the local file skipped the
* tombstones, and the next gist merge (union by URL) resurrected every
* "cleared" result from the remote copy (#276). Mirrors runSkipClear.
*/
export declare function runResultsClear(): Promise<void>;
/**
* Results command — display and manage saved search results.
*/
import { loadLocalState, saveLocalState } from "../core/local-state.js";
import { loadLocalState } from "../core/local-state.js";
import { ValidationError } from "../core/errors.js";
import { withScout } from "./with-scout.js";
/**

@@ -36,6 +37,12 @@ * Return saved results, optionally narrowed to "new" ones. `--since` takes an

}
/**
* Clear all saved results via the scout so deletion tombstones are recorded
* (#117). Writing an empty list straight to the local file skipped the
* tombstones, and the next gist merge (union by URL) resurrected every
* "cleared" result from the remote copy (#276). Mirrors runSkipClear.
*/
export async function runResultsClear() {
const state = loadLocalState();
state.savedResults = [];
saveLocalState(state);
await withScout(undefined, (scout) => {
scout.clearResults();
}, { requireToken: false, persist: true });
}

@@ -8,4 +8,9 @@ /**

export async function runSync(options) {
// syncOpenPRs checkpoints itself, so withScout doesn't need to persist.
return withScout(options?.state, (scout) => scout.syncOpenPRs());
// syncOpenPRs checkpoints itself, but in the CLI's non-gist mode the scout
// is built with `persistence: "provided"`, where checkpoint() is a no-op —
// only withScout's persist epilogue writes ~/.oss-scout/state.json. Without
// it, sync reported success while discarding every update (#275).
return withScout(options?.state, (scout) => scout.syncOpenPRs(), {
persist: true,
});
}

@@ -6,2 +6,8 @@ /**

export declare function validateGitHubUrl(url: string, pattern: RegExp, entityType: "issue"): void;
/**
* Parse a strictly-decimal integer CLI argument. Bare parseInt accepts
* trailing garbage ("50O" → 50), silently running with a different value
* than the user typed (#291).
*/
export declare function parseStrictInt(value: string, label: string): number;
export declare function validateUrl(url: string): string;

@@ -12,2 +12,14 @@ /**

}
/**
* Parse a strictly-decimal integer CLI argument. Bare parseInt accepts
* trailing garbage ("50O" → 50), silently running with a different value
* than the user typed (#291).
*/
export function parseStrictInt(value, label) {
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) {
throw new ValidationError(`${label} must be an integer (got "${value}")`);
}
return parseInt(trimmed, 10);
}
export function validateUrl(url) {

@@ -14,0 +26,0 @@ if (url.length > MAX_URL_LENGTH) {

@@ -39,2 +39,9 @@ /**

export declare function getHttpStatusCode(error: unknown): number | undefined;
/**
* True for errors resolveErrorCode classifies as AUTH_REQUIRED: a 401, or a
* 403 that is not a rate-limit/abuse condition (fine-grained-token "Resource
* not accessible", SAML enforcement, ...). Retrying other work with the same
* token cannot succeed, so batch loops should abort on these (#290).
*/
export declare function isAuthError(error: unknown): boolean;
export declare function isRateLimitError(error: unknown): boolean;

@@ -41,0 +48,0 @@ /**

@@ -53,2 +53,14 @@ /**

}
/**
* True for errors resolveErrorCode classifies as AUTH_REQUIRED: a 401, or a
* 403 that is not a rate-limit/abuse condition (fine-grained-token "Resource
* not accessible", SAML enforcement, ...). Retrying other work with the same
* token cannot succeed, so batch loops should abort on these (#290).
*/
export function isAuthError(error) {
const status = getHttpStatusCode(error);
if (status === 401)
return true;
return status === 403 && !isRateLimitError(error);
}
export function isRateLimitError(error) {

@@ -55,0 +67,0 @@ const status = getHttpStatusCode(error);

@@ -79,2 +79,9 @@ /**

private bootstrapFromCache;
/**
* "missing" (no file/content) and "invalid" (content that fails parse or
* schema validation) are distinct outcomes: an invalid remote may be a
* corrupted-but-recoverable state or a newer state version written by a
* newer binary, so callers must not treat it as "nothing there" and
* overwrite it (#286).
*/
private fetchGistState;

@@ -81,0 +88,0 @@ /**

@@ -89,8 +89,23 @@ /**

// local snapshot, the prior best-effort behavior.
//
// Known limitation: the Gists API has no conditional update, so this
// read-merge-write has a small race window — two machines pushing
// near-simultaneously can drop additions made between the loser's fetch
// and write (tombstones protect only deletions). Union-merge keeps the
// window's blast radius to the most recent additions.
let toWrite = state;
try {
const remote = await this.fetchGistState(this.gistId);
if (remote) {
toWrite = mergeStates(state, remote);
if (remote.kind === "ok") {
toWrite = mergeStates(state, remote.state);
}
else if (remote.kind === "invalid") {
// The remote holds content this binary can't validate — possibly a
// newer state version or recoverable corruption. Overwriting would
// destroy it; mirror the bootstrap guard ("using local cache to
// avoid data loss") and report sync failure instead (#286).
this.writeCache(state);
warn(MODULE, "Remote gist content failed validation — not overwriting it. Changes saved locally; gist sync skipped.");
return false;
}
}

@@ -138,5 +153,5 @@ catch (err) {

const fetched = await this.fetchGistState(cachedId);
if (fetched) {
if (fetched.kind === "ok") {
this.gistId = cachedId;
const state = this.mergeCacheInto(fetched);
const state = this.mergeCacheInto(fetched.state);
this.writeCache(state);

@@ -165,4 +180,4 @@ return { gistId: cachedId, state, created: false };

const fetched = await this.fetchGistState(search.id);
if (fetched) {
const state = this.mergeCacheInto(fetched);
if (fetched.kind === "ok") {
const state = this.mergeCacheInto(fetched.state);
this.writeCache(state);

@@ -217,2 +232,9 @@ return { gistId: search.id, state, created: false };

// ── Gist API operations ──────────────────────────────────────────────
/**
* "missing" (no file/content) and "invalid" (content that fails parse or
* schema validation) are distinct outcomes: an invalid remote may be a
* corrupted-but-recoverable state or a newer state version written by a
* newer binary, so callers must not treat it as "nothing there" and
* overwrite it (#286).
*/
async fetchGistState(gistId) {

@@ -222,10 +244,10 @@ const { data } = await this.octokit.gists.get({ gist_id: gistId });

if (!file?.content)
return null;
return { kind: "missing" };
try {
const parsed = JSON.parse(file.content);
return parseScoutState(parsed);
return { kind: "ok", state: parseScoutState(parsed) };
}
catch (err) {
warn(MODULE, `Gist content failed validation: ${errorMessage(err)}`);
return null;
return { kind: "invalid" };
}

@@ -343,4 +365,7 @@ }

return [...byUrl.values()].filter((t) => {
// An unparseable removedAt is dropped: keeping it (the old behavior)
// made it immortal — it never aged past the TTL and, compared lexically
// in applyTombstones, suppressed its URL on every merge forever (#292).
const ts = new Date(t.removedAt).getTime();
return !Number.isFinite(ts) || ts >= cutoff;
return Number.isFinite(ts) && ts >= cutoff;
});

@@ -425,3 +450,5 @@ }

function mergeRepoScores(local, remote) {
const merged = { ...local };
// Null prototype: repo names are parsed record keys, so a "__proto__" key
// must land as an own property, not a prototype assignment.
const merged = Object.assign(Object.create(null), local);
for (const [repo, remoteScore] of Object.entries(remote)) {

@@ -428,0 +455,0 @@ const localScore = merged[repo];

@@ -77,3 +77,9 @@ /**

return false;
// An unparseable updated_at (the adapters map a missing timestamp to
// "") makes daysBetween return NaN, and NaN > maxAgeDays is false — so
// undated issues sailed through the staleness filter (#289). Treat an
// unknown age as failing it.
const updatedAt = new Date(item.updated_at);
if (Number.isNaN(updatedAt.getTime()))
return false;
const ageDays = daysBetween(updatedAt, config.now);

@@ -366,3 +372,6 @@ if (ageDays > config.maxAgeDays)

const tracker = this.budgetTracker;
let searchBudget = LOW_BUDGET_THRESHOLD - 1;
// Fallback below the critical threshold so a failed preflight actually
// skips the starred phase — the old fallback of 19 passed the gate and
// made the "conservative budget" warning a lie (#288).
let searchBudget = CRITICAL_BUDGET_THRESHOLD - 1;
try {

@@ -388,8 +397,13 @@ const rateLimit = await checkRateLimit(this.githubToken);

tracker.init(CRITICAL_BUDGET_THRESHOLD, new Date(Date.now() + 60000).toISOString());
warn(MODULE, "Could not check rate limit — using conservative budget, skipping heavy phases:", errorMessage(error));
warn(MODULE, "Could not check rate limit — using conservative budget, skipping the starred phase:", errorMessage(error));
}
if (searchBudget <= 0) {
// An exhausted REST Search bucket is no reason to abort the run:
// Phase 0/1 read the much larger Core API bucket and broad/maintained
// run on GraphQL (#284). The starred-phase gate below already skips the
// one budget-gated phase, and the tracker paces the remaining
// merge-stat search calls until the quota resets.
this.rateLimitWarning =
"GitHub search API quota exhausted. Try again after the rate limit resets.";
return { candidates: [], strategiesUsed: [] };
"GitHub search API quota exhausted — skipping the starred phase; other phases don't use it.";
warn(MODULE, this.rateLimitWarning);
}

@@ -396,0 +410,0 @@ // Derive search context

@@ -248,2 +248,17 @@ /**

}
// commentCount comes from a prefetch that may be minutes old. If the
// computed last page came back full, comments may have crossed a page
// boundary since — exactly the busy issues where fresh claims land — so
// fetch one more page for the true tail (#293).
const lastFetched = recentComments.length % PER_PAGE;
if (recentComments.length > 0 && lastFetched === 0) {
const response = await octokit.issues.listComments({
owner,
repo,
issue_number: issueNumber,
per_page: PER_PAGE,
page: lastPage + 1,
});
recentComments.push(...response.data);
}
for (const comment of recentComments) {

@@ -250,0 +265,0 @@ if (commentClaimsIssue(comment.body || "")) {

@@ -10,3 +10,3 @@ /**

import { parseGitHubUrl } from "./utils.js";
import { ValidationError, errorMessage, getHttpStatusCode, isRateLimitError, } from "./errors.js";
import { ValidationError, errorMessage, isAuthError, isRateLimitError, } from "./errors.js";
import { debug, warn } from "./logger.js";

@@ -544,3 +544,6 @@ import { calculateRepoQualityBonus, calculateViabilityScore, } from "./issue-scoring.js";

.catch((error) => {
if (getHttpStatusCode(error) === 401) {
// 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)) {
firstAuthError ??= error;

@@ -547,0 +550,0 @@ return;

@@ -12,2 +12,3 @@ /**

import { ValidationError } from "./errors.js";
import { assertValidTriageHost } from "./slm-triage.js";
export const FIELD_CONFIGS = {

@@ -138,2 +139,7 @@ githubUsername: { type: "string" },

case "string":
// Issue text is POSTed to slmTriageHost during vetting, so it must
// stay a local/private address (#300). Validated here (not in the
// schema) so states persisted before this rule still parse.
if (key === "slmTriageHost")
assertValidTriageHost(value);
prefs[key] = value;

@@ -140,0 +146,0 @@ break;

@@ -10,3 +10,3 @@ /**

import { warn } from "./logger.js";
import { getHttpCache, cachedRequest, cachedTimeBased } from "./http-cache.js";
import { getHttpCache, cachedRequest, cachedTimeBased, versionedCacheKey, } from "./http-cache.js";
import { probeRepoFile } from "./probe-repo-file.js";

@@ -94,3 +94,7 @@ import { getSearchBudgetTracker, } from "./search-budget.js";

const cache = getHttpCache();
const healthCacheKey = `health:${owner}/${repo}`;
// Versioned (#158): the cached ProjectHealth is read back with an unchecked
// cast, and its shape has changed before (#248 added recentMergedPRCount) —
// a stale-shaped entry degraded every approve to needs_review for the
// cache TTL (#285).
const healthCacheKey = versionedCacheKey(`health:${owner}/${repo}`);
try {

@@ -97,0 +101,0 @@ return await cachedTimeBased(cache, healthCacheKey, HEALTH_CACHE_TTL_MS, async () => {

@@ -26,2 +26,9 @@ /**

/**
* Slots handed out by waitForBudget() but not yet recorded. Without this,
* concurrent callers (vetting runs 3 tasks) could all pass waitForBudget()
* on an effective budget of 1 before any recordCall() landed, overshooting
* the external quota into real 429s (#287).
*/
private reservedCalls;
/**
* Initialize with pre-flight rate limit data from GitHub.

@@ -31,3 +38,5 @@ */

/**
* Record that a Search API call was just made.
* Record that a Search API call was just made. Releases the reservation
* taken by the preceding waitForBudget() (every call site pairs them, with
* recordCall in a finally).
*/

@@ -60,3 +69,5 @@ recordCall(): void;

/**
* Wait if necessary to stay within the Search API rate limit.
* Wait if necessary to stay within the Search API rate limit, then reserve
* one call slot. The reservation is released by the paired recordCall(), so
* concurrent callers cannot all claim the same last remaining slot (#287).
* If the sliding window is at capacity, sleeps until the oldest

@@ -63,0 +74,0 @@ * call ages out of the window.

@@ -36,2 +36,9 @@ /**

/**
* Slots handed out by waitForBudget() but not yet recorded. Without this,
* concurrent callers (vetting runs 3 tasks) could all pass waitForBudget()
* on an effective budget of 1 before any recordCall() landed, overshooting
* the external quota into real 429s (#287).
*/
reservedCalls = 0;
/**
* Initialize with pre-flight rate limit data from GitHub.

@@ -45,8 +52,13 @@ */

this.callsSinceReset = 0;
this.reservedCalls = 0;
debug(MODULE, `Initialized: ${remaining} remaining, resets at ${new Date(this.resetAt).toLocaleTimeString()}`);
}
/**
* Record that a Search API call was just made.
* Record that a Search API call was just made. Releases the reservation
* taken by the preceding waitForBudget() (every call site pairs them, with
* recordCall in a finally).
*/
recordCall() {
if (this.reservedCalls > 0)
this.reservedCalls--;
this.callTimestamps.push(Date.now());

@@ -99,5 +111,6 @@ this.totalCalls++;

// Use the stricter of: local window limit vs. known remaining quota
// minus calls made since the last reset
const localBudget = EFFECTIVE_BUDGET - this.callTimestamps.length;
const externalBudget = this.knownRemaining - this.callsSinceReset;
// minus calls made since the last reset. Outstanding reservations count
// against both so concurrent waiters can't share one remaining slot.
const localBudget = EFFECTIVE_BUDGET - this.callTimestamps.length - this.reservedCalls;
const externalBudget = this.knownRemaining - this.callsSinceReset - this.reservedCalls;
return Math.max(0, Math.min(localBudget, externalBudget));

@@ -113,3 +126,5 @@ }

/**
* Wait if necessary to stay within the Search API rate limit.
* Wait if necessary to stay within the Search API rate limit, then reserve
* one call slot. The reservation is released by the paired recordCall(), so
* concurrent callers cannot all claim the same last remaining slot (#287).
* If the sliding window is at capacity, sleeps until the oldest

@@ -124,2 +139,3 @@ * call ages out of the window.

if (this.getEffectiveBudget() > 0) {
this.reservedCalls++;
return; // Budget available, no wait needed

@@ -130,2 +146,8 @@ }

if (!oldestInWindow) {
if (this.reservedCalls > 0) {
// Budget is consumed only by outstanding reservations; their calls
// will land shortly. Poll rather than waiting for the quota reset.
await sleep(250);
continue;
}
// No calls in window — the external quota is exhausted. Wait for

@@ -139,2 +161,3 @@ // GitHub's reset when we know it, otherwise proceed (#119).

}
this.reservedCalls++;
return;

@@ -141,0 +164,0 @@ }

@@ -58,2 +58,14 @@ /**

/**
* Guard for user-configured Ollama hosts (#300): http(s) scheme and a
* local/private address only. Issue titles/bodies are POSTed to this host
* during vetting, so an arbitrary public URL would let a config change
* beacon fetched content off-machine.
*/
export declare function isAllowedTriageHost(raw: string): boolean;
/**
* Throw a ValidationError unless the value is empty (use the default) or an
* allowed local/private Ollama host. Called by config-set (CLI + MCP).
*/
export declare function assertValidTriageHost(value: string): void;
/**
* Run an SLM triage classification. Returns `null` on any failure path

@@ -60,0 +72,0 @@ * — caller treats `null` as "no SLM signal available".

@@ -0,1 +1,4 @@

import { ValidationError } from "./errors.js";
import { warn } from "./logger.js";
const MODULE = "slm-triage";
/** Default Ollama HTTP endpoint when not overridden. */

@@ -43,2 +46,49 @@ const DEFAULT_OLLAMA_HOST = "http://127.0.0.1:11434";

/**
* Guard for user-configured Ollama hosts (#300): http(s) scheme and a
* local/private address only. Issue titles/bodies are POSTed to this host
* during vetting, so an arbitrary public URL would let a config change
* beacon fetched content off-machine.
*/
export function isAllowedTriageHost(raw) {
let url;
try {
url = new URL(raw);
}
catch {
return false;
}
if (url.protocol !== "http:" && url.protocol !== "https:")
return false;
const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
if (host === "localhost" || host === "::1")
return true;
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4) {
const a = Number(v4[1]);
const b = Number(v4[2]);
if (a === 127 || a === 10)
return true;
if (a === 192 && b === 168)
return true;
if (a === 172 && b >= 16 && b <= 31)
return true;
return false;
}
if (host.includes(":")) {
// IPv6 literals: unique-local (fc00::/7) and link-local (fe80::/10).
return (host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80"));
}
// Single-label LAN hostnames ("ollama-box") and mDNS .local names.
return !host.includes(".") || host.endsWith(".local");
}
/**
* Throw a ValidationError unless the value is empty (use the default) or an
* allowed local/private Ollama host. Called by config-set (CLI + MCP).
*/
export function assertValidTriageHost(value) {
if (value === "" || isAllowedTriageHost(value))
return;
throw new ValidationError(`slmTriageHost must be an http(s) URL pointing at localhost or a private/LAN address (got "${value}"). Example: http://192.168.1.20:11434`);
}
/**
* Run an SLM triage classification. Returns `null` on any failure path

@@ -51,2 +101,9 @@ * — caller treats `null` as "no SLM signal available".

const host = options.host ?? DEFAULT_OLLAMA_HOST;
// Enforced at use time too, covering hosts written straight into
// state.json without going through config-set (#300). Fail open like
// every other triage failure path.
if (!isAllowedTriageHost(host)) {
warn(MODULE, `slmTriageHost "${host}" is not a local/private address; skipping SLM triage`);
return null;
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;

@@ -53,0 +110,0 @@ const fetchFn = options.fetchImpl ?? fetch;

@@ -856,2 +856,7 @@ /**

try {
// Reload-and-merge before writing: a long-lived scout (the MCP
// server runs for days) would otherwise last-writer-wins clobber
// state.json changes made by the CLI since boot (#294). The
// tombstone-aware merge preserves both sides, mirroring gist mode.
this.state = mergeStates(this.state, loadLocalState());
saveLocalState(this.state);

@@ -858,0 +863,0 @@ }

{
"name": "@oss-scout/core",
"version": "1.5.0",
"version": "1.5.1",
"description": "Personalized GitHub issue finder with multi-strategy search, deep vetting, and viability scoring — CLI, library, MCP server, and Claude Code plugin",

@@ -11,8 +11,8 @@ "type": "module",

".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./types": {
"import": "./dist/core/types.js",
"types": "./dist/core/types.d.ts"
"types": "./dist/core/types.d.ts",
"import": "./dist/core/types.js"
}

@@ -23,3 +23,2 @@ },

"!dist/**/*.map",
"!dist/core/test-utils.*",
"!dist/eval/**"

@@ -71,3 +70,3 @@ ],

"build": "tsc",
"bundle": "esbuild src/cli.ts --bundle --platform=node --target=node20 --format=cjs --minify --sourcemap --outfile=dist/cli.bundle.cjs",
"bundle": "esbuild src/cli.ts --bundle --platform=node --target=node20 --format=cjs --minify --outfile=dist/cli.bundle.cjs",
"start": "tsx src/cli.ts",

@@ -74,0 +73,0 @@ "typecheck": "tsc --noEmit",

Sorry, the diff of this file is too big to display