Files
msd-core/src/package-legitimacy.cts
Tom Boucher 11afca2968 feat(#656): Research module — content-addressed cache + provider seam + registry-API legitimacy (#664)
* feat(#656): add Research Store module (content-addressed cache, TTL staleness)

Content-addressed research cache behind a clock seam: researchKey (sha256, deterministic), putResearch/getResearch ({hit,stale}, never throws), ttlForSource (curated HIGH 30d / MED 7d / web LOW 1d), two-tier resolveStorePath (curated -> ~/.gsd/research-cache, web/synthesis -> project .planning/research/.cache). 28 behavioral + property tests; boundary coverage at ttl-1/ttl/ttl+1.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#656): add Research Provider module (waterfall + confidence + plan)

Single source of truth for the Balanced provider waterfall (docs Context7->Ref->Jina, web Exa+Tavily, fallback Perplexity/Brave, Firecrawl scrape-only). classifyConfidence stamps HIGH|MEDIUM|LOW by provider (never throws). providerAvailability maps config flags to usable providers. planResearch checks the Research Store (injected seam) and returns cache-hits + a per-question fetch plan, falling through the waterfall to the always-available websearch terminal. 22 behavioral + property tests.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#656): add Package Legitimacy module (registry-API verdicts, slopcheck optional)

Replaces the pip-install-or-degrade slopcheck prose gate with code: classifyPackage (pure, never throws) computes OK|SUS|SLOP from tunable thresholds (minAgeDays 30, minWeeklyDownloads 1000, requireRepo). checkPackages queries injectable npm/PyPI/crates registry adapters (real https with 5s timeout, degraded-not-thrown on failure); slopcheck is one optional adapter that can only escalate severity, never degrade to [ASSUMED]. 34 behavioral + property tests; boundary coverage on age and downloads (limit-1/limit/limit+1).

Known follow-up: real npm adapter must add api.npmjs.org last-week downloads fetch (currently null -> unknown-downloads). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#656): detect Tavily/Ref/Perplexity/Jina provider keys; complete npm downloads adapter

config: add tavily_search/ref_search/perplexity/jina availability flags (env var or ~/.gsd/<x>_api_key), mirroring brave_search/exa_search/firecrawl, so the Research Provider waterfall can gate them. package-legitimacy: real npm adapter now fetches api.npmjs.org last-week downloads (bounded, degraded-not-thrown) so weeklyDownloads is populated. +12 config tests; 34 legitimacy tests unchanged.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#656): expose Research seam via gsd-tools query (research-plan, research-store, package-legitimacy)

Routes the L2-hybrid surface so agents reach it as CLI: 'query research-store get/put' (cache, HOME-sandboxable), 'query research-plan --input' (cache-hits + fetch plan from planResearch), 'query package-legitimacy check --ecosystem' (async registry verdicts). Commands skip .planning root resolution and appear in top-level usage. 5 behavioral runGsdTools tests; command-contract unchanged (335).

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(#656): document Research module (CONTEXT predicates, ADR-0656, architecture, changeset)

Adds GSD-RESEARCH.* + DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT predicates to CONTEXT.md, ADR-0656 recording the L2-hybrid seam decision, a docs/ARCHITECTURE.md Research Module subsection, and an Added changeset fragment (pr:0, backfill on PR). Notes the #657 deferrals (agent collapse + install.js MCP mapping).

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#656): sync inventory for research modules

Regenerate INVENTORY-MANIFEST.json and bump docs/INVENTORY.md CLI Modules count 82->85 with rows for research-store/research-provider/package-legitimacy (DEFECT.INVENTORY-DRIFT).

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#656): eslint-ignore generated research .cjs artifacts (ADR-457)

research-store/research-provider/package-legitimacy .cjs are tsc-generated from src/*.cts, so they belong in the ESLint ignore block (lint the .cts source, not the emitted .cjs). Fixes tests/551-eslint-bin-lib-coverage.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#656): backfill changeset pr number to #664

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#656): satisfy eslint lint-tests gate

Fix 20 eslint errors in the new research files: use helpers.cleanup() instead of raw fs.rmSync() in tests (local/no-raw-rmsync-in-tests, Windows-EBUSY retry budget); drop redundant '| string' union members and unnecessary type assertions; deterministic object normalization in researchKey (no-base-to-string). Logic unchanged; 6180 tests still green.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): harden package legitimacy per review (W1/W2/I3/I4)

W1: httpsGet now reads statusCode; npm/PyPI/crates map 404 -> exists:false -> SLOP (registry-existence is the #1 slopsquatting defense; previously only npm caught it). Transport made injectable (_setHttpGet) for hermetic 404 tests. W2: suspicious-postinstall is now terminal SLOP independent of the optional slopcheck adapter, and the regex drops the bare https?:// arm (over-fired on esbuild/sharp/node-gyp) for shell-exec/download-exec signatures only. I3: checkPackages now threads version to registry.lookup and adapters verify that specific version exists. I4: moreServerVerdict -> moreSevereVerdict. +11 regression tests (all RED-first); 45 total green.

Addresses review by @davesienkowski on #664. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): research-store tier coherence + freshness + version TTL (W4/I1/I2/I4)

I1: tier now derives from source (curated -> user ~/.gsd, else -> project .planning), not kind, so put-tier and get-tier can't diverge; kind is a key component only. W4: getResearch searches both tiers and returns the freshest (non-stale preferred), never letting a stale curated entry shadow a fresh web one; blank version caps TTL at 1 day (no 30d on version-blind keys). I2: atomic platformWriteSync instead of raw fs.writeFileSync on the shared global path. I4: dropped the dead ttlForSource arm. CLI get now searches both tiers. +5 RED-first regression tests; 38 green.

Addresses review by @davesienkowski on #664. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): expose classifyConfidence as a CLI route, killing dead code (W3)

Adds 'gsd-tools query classify-confidence --provider X [--verified]' so research agents get the confidence tier FROM CODE (provider waterfall + verification lever) instead of asserting it in prose. classifyConfidence previously had no runtime caller. HIGH means 'trusted provider'; --verified raises web results to MEDIUM (verification semantics documented in ADR-0656). +4 behavioral tests.

Addresses review by @davesienkowski on #664 (W3). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): close Codex adversarial-review findings (path-traversal, version-age, malformed-cache)

HIGH: research key must be 64-hex sha256 (isValidResearchKey) + resolved-path containment check in put/get + CLI validation -> blocks '../../x' arbitrary-file-write. HIGH: package legitimacy now derives publishedAt from the REQUESTED version (npm time[version], PyPI releases[version] upload_time, crates versions[].created_at) so a new malicious version of an old package can't inherit old age and evade 'too-new'. MEDIUM: getResearch validates entry shape (finite fetched_at + positive ttl + required fields) -> malformed cache entry is a miss, not fresh-forever. +regression tests (RED-first); 111 green.

Codex adversarial review (required pre-PR gate). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): close code-review correctness findings

(1) package-legitimacy CLI now rejects unknown --flags instead of silently consuming the following package as a flag value; only --ecosystem takes a value. (2) crates recent_downloads (90-day) normalized to a weekly figure before the minWeeklyDownloads threshold (was ~13x too lenient). (3) research-plan --input validates parsed JSON is an object with an Array questions before destructuring -> clean usage error instead of an uncaught TypeError on null/bad input. (4) research-store put rejects a flag value that is itself a --flag (no more storing '--source' as content). (5) planResearch skips questions whose text is not a non-empty string instead of emitting question:undefined. +13 RED-first regression tests; 143 green.

Code-review gate. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(#657): extract researcher documentation_lookup to shared @-reference

6 researcher agents carried a near-duplicate <documentation_lookup> block; consolidate into gsd-core/references/research-documentation-lookup.md (@-included). Unifies the ctx7 CLI fallback to the safer 'command -v ctx7' guard (drops silent 'npx --yes ctx7@latest' execution in 5 agents). Behavior-preserving dedup; inventory 63->64 references. Phase A of the agent collapse.

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(#657): extract researcher philosophy + verification-protocol to shared @-references

philosophy and the pitfalls+pre-submission-checklist common-core were near-duplicated in project/phase researchers; consolidate into gsd-core/references/research-{philosophy,verification-protocol}.md (@-included). phase-researcher keeps its 3 extra checklist items inline. Pre-submission domains checklist made agent-agnostic so project-researcher doesn't lose features/architecture coverage. Write-contract intentionally left inline (bug-214 tests assert it verbatim). Inventory 64->66 refs. Behavior-preserving. Phase A.

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#657): wire gsd-phase-researcher to the Research seam (Phase B / S1)

The phase researcher now CALLS the code seam instead of carrying inline mechanics: provider waterfall -> 'gsd-tools query research-plan' (+ research-store put to cache digests); confidence-tier prose -> 'gsd-tools query classify-confidence'; slopcheck pip-install protocol -> 'gsd-tools query package-legitimacy check'. This makes the Research module a real runtime consumer (validates the seam end-to-end, addresses reviewer S1) and removes the duplicated waterfall/confidence/slopcheck prose. RESEARCH.md output contract, commit step, structured returns, and Phase-A @-includes unchanged. package-legitimacy-gate.test.cjs rewritten prose-grep -> behavioral (asserts the seam invocation).

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#657): wire gsd-project-researcher to the seam + add tavily/ref/jina MCP tools (Phase C.1)

project-researcher now calls gsd-tools query research-plan / classify-confidence (+ research-store put) instead of the inline provider waterfall + confidence-tier prose (mirrors the phase-researcher rewire; no package-legitimacy — phase-only). Output contract (STACK/FEATURES/ARCHITECTURE/PITFALLS/SUMMARY.md + sections, no-commit, structured returns, Phase-A @-includes) unchanged. Adds mcp__tavily/ref/jina__* to the project/phase/ui researcher tools frontmatter (Balanced provider set) so install.js MCP mapping (C.2) has a consumer.

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(#657): cover tavily/ref/jina MCP install handling + frontmatter parity guard (Phase C.2)

Investigation: exa/firecrawl have no explicit per-runtime tool-mapping — every mcp__<server>__* except context7 rides the generic passthrough (Copilot lowercases; OpenCode/Cursor/Windsurf/Augment keep as-is; Gemini auto-discovers). tavily/ref/jina are handled identically, no install path broken. Added 12 copilot-install passthrough tests + a mcp-tool-inheritance parity guard (tavily co-declared with exa, jina with firecrawl, ref present across the 3 web researchers) so the MCP set can't drift. No io.github registry ids invented (none sourceable in-repo); documented as a follow-up. 488 tests green.

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#657): profiles as source of truth for researcher agents + drift-guard (Phase C.3)

scripts/research-profiles.cjs declares each of the 7 researcher agents' identity + contract (name, description, color, tools, required @-includes, required gsd-tools seam calls, output-contract markers). scripts/gen-research-agents.cjs --check validates every committed agent against its profile; --write regenerates ONLY the frontmatter from profiles (body untouched) and is a verified no-op against the current agents (zero diff = fidelity). tests/research-agent-profiles.test.cjs is the DEFECT.GENERATIVE-FIX drift guard. Design note: profiles govern the generatable/contract surface rather than destructively regenerating the disparate operational prose bodies (those were deduped via @-includes in Phase A). scripts/ is not inventoried (no inventory change).

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#657): complete agent provider-dispatch + parity guard; align legitimacy field; validate profiles

Adversarial-review findings: (HIGH) the seam-wired agents' Step-C dispatch only mapped 6 providers, so a planResearch result of jina/ref/perplexity/brave (reachable via the waterfall fallbacks) had no handling -> agent stall; completed both agents' dispatch to all 9 PROVIDER_WATERFALL ids + a catch-all, and added a parity test asserting agent dispatch stays in sync with research-provider PROVIDER_WATERFALL (DEFECT.GENERATIVE-FIX). (MEDIUM) phase-researcher package-legitimacy JSON example used 'package' but the module returns 'name' -> aligned. (LOW) gen-research-agents checkAgent now returns a clear failure for a malformed profile instead of throwing. +parity/validation tests (RED-first).

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): make classifyConfidence verification-evidence-driven (W3)

Confidence conflated provider authority with claim verification — context7/ref
stamped HIGH purely by provider identity, and the only verification lever was a
self-set --verified flag. Split into two axes: provider authority (static) +
verification evidence (code-computed). HIGH now requires ground-truth
corroboration (legitimacyVerdict OK), independent of provider; authority alone
caps at MEDIUM; SLOP caps at LOW; the self-reported --verified is demoted to a
MEDIUM-only web lever. HIGH = corroborated-against-authoritative-source, not a
correctness guarantee. Adds --legitimacy-verdict to the classify-confidence CLI;
updates CONTEXT.md predicate + ADR-0656 (tier set unchanged, ADR-consistent).

Addresses davesienkowski's W3 review on #664.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#656): bind classify-confidence verdict to code, closing CLI self-grading

Adversarial review found the new --legitimacy-verdict flag was caller-supplied,
so an agent could self-assert OK->HIGH without any real legitimacy check —
reintroducing the exact self-grading hole W3 closes. Remove the free flag; the
CLI now computes the verdict via checkPackages only when --package/--ecosystem
is given (code-computed, not agent-asserted). Update the stale CLI test
(context7 alone -> MEDIUM) and extend the property test to vary legitimacyVerdict.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:58:48 -04:00

472 lines
16 KiB
TypeScript

/**
* Package Legitimacy Module
*
* Replaces the bolt-on prose slopcheck gate (which pip-installed `slopcheck`
* and degraded ALL packages to [ASSUMED] when pip failed) with registry-API
* verdicts computed in code.
*
* Public interface:
* DEFAULT_THRESHOLDS — baseline thresholds
* classifyPackage — pure function: signals → { verdict, reasons }
* checkPackages — async: resolves registry signals and classifies
* _setHttpGet — test seam: override the HTTP transport (pass null to restore)
*
* All network IO is injected via a `registry` client option so that tests
* never touch the real network (same seam pattern as clock injection).
*
* ADR-457 build-at-publish: authored as TypeScript .cts → emits .cjs via tsc.
*/
import * as https from 'node:https';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Verdict = 'OK' | 'SUS' | 'SLOP';
type Ecosystem = string;
interface Thresholds {
minAgeDays: number;
minWeeklyDownloads: number;
requireRepo: boolean;
}
interface PackageSignals {
exists: boolean | null | undefined;
publishedAt: string | null | undefined;
weeklyDownloads: number | null | undefined;
repoUrl: string | null | undefined;
deprecated: boolean | null | undefined;
postinstall: string | null | undefined;
ecosystem?: string | null | undefined;
}
interface ClassifyResult {
verdict: Verdict;
reasons: string[];
}
interface CheckResult {
name: string;
verdict: Verdict;
signals: PackageSignals;
reasons: string[];
}
interface RegistryClient {
lookup(ecosystem: Ecosystem, name: string, version?: string): Promise<PackageSignals>;
}
interface SlopcheckAdapter {
check(ecosystem: Ecosystem, name: string): Promise<Verdict | null>;
}
interface ClassifyOptions {
thresholds?: Thresholds;
clock?: { now(): number };
}
interface CheckPackagesInput {
ecosystem: Ecosystem;
packages: string[];
version?: string;
}
interface CheckPackagesOptions {
registry?: RegistryClient;
clock?: { now(): number };
thresholds?: Thresholds;
slopcheck?: SlopcheckAdapter | null;
}
/** Shape returned by the injectable HTTP transport */
interface HttpResponse {
statusCode: number;
body: string;
}
type HttpGetFn = (url: string, timeoutMs: number) => Promise<HttpResponse>;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_THRESHOLDS: Thresholds = {
minAgeDays: 30,
minWeeklyDownloads: 1000,
requireRepo: true,
};
// Matches common dangerous postinstall execution patterns.
// Deliberately EXCLUDES bare https?:// (over-fires on legit packages like
// esbuild/sharp/node-gyp that reference download URLs without executing them).
// Shell-execution / download-and-exec signatures only:
const SUSPICIOUS_POSTINSTALL_RE =
/(curl |wget |\|\s*(ba)?sh|bash -c|sh -c|node -e|eval|base64 -d|\/etc\/|\.\.\/|~\/|nc |>\s*\/)/i;
// ---------------------------------------------------------------------------
// Severity ordering for verdict merging (SLOP > SUS > OK)
// ---------------------------------------------------------------------------
const SEVERITY: Record<Verdict, number> = { OK: 0, SUS: 1, SLOP: 2 };
function moreSevereVerdict(a: Verdict, b: Verdict): Verdict {
return SEVERITY[a] >= SEVERITY[b] ? a : b;
}
// ---------------------------------------------------------------------------
// classifyPackage — pure, no IO
// ---------------------------------------------------------------------------
function classifyPackage(
signals: Partial<PackageSignals>,
{ thresholds = DEFAULT_THRESHOLDS, clock = Date }: ClassifyOptions = {}
): ClassifyResult {
const reasons: string[] = [];
// Terminal: package does not exist
if (signals.exists === false) {
return { verdict: 'SLOP', reasons: ['does-not-exist'] };
}
// Age check
if (signals.publishedAt == null) {
reasons.push('unknown-age');
} else {
const parsed = Date.parse(String(signals.publishedAt));
if (!Number.isFinite(parsed)) {
// Unparseable date — treat as unknown
reasons.push('unknown-age');
} else {
const ageDays = Math.floor((clock.now() - parsed) / 86_400_000);
if (ageDays < thresholds.minAgeDays) {
reasons.push('too-new');
}
}
}
// Downloads check
const downloads = signals.weeklyDownloads;
if (downloads == null) {
reasons.push('unknown-downloads');
} else if (typeof downloads !== 'number' || !Number.isFinite(downloads)) {
// Odd type / NaN — treat as unknown
reasons.push('unknown-downloads');
} else if (downloads < thresholds.minWeeklyDownloads) {
reasons.push('low-downloads');
}
// Repository check
if (thresholds.requireRepo && !signals.repoUrl) {
reasons.push('no-repository');
}
// Deprecated check
if (signals.deprecated === true) {
reasons.push('deprecated');
}
// Suspicious postinstall (npm only — but apply whenever postinstall is present)
if (signals.postinstall != null && typeof signals.postinstall === 'string') {
if (SUSPICIOUS_POSTINSTALL_RE.test(signals.postinstall)) {
reasons.push('suspicious-postinstall');
}
}
// Terminal: suspicious postinstall is a slopsquatting execution risk
if (reasons.includes('suspicious-postinstall')) {
return { verdict: 'SLOP', reasons };
}
const verdict: Verdict = reasons.length > 0 ? 'SUS' : 'OK';
return { verdict, reasons };
}
// ---------------------------------------------------------------------------
// Injectable HTTP transport (test seam — W1)
// ---------------------------------------------------------------------------
/** The real HTTPS transport — resolves { statusCode, body } */
function realHttpsGet(url: string, timeoutMs: number): Promise<HttpResponse> {
return new Promise((resolve, reject) => {
const req = https.get(
url,
{ headers: { 'User-Agent': 'gsd-core-package-legitimacy/1.0' } },
(res) => {
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () =>
resolve({
statusCode: res.statusCode ?? 0,
body: Buffer.concat(chunks).toString('utf8'),
})
);
res.on('error', reject);
}
);
req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`timeout after ${timeoutMs}ms`));
});
req.on('error', reject);
});
}
/** Module-level transport pointer — overrideable via _setHttpGet for tests */
let httpsGet: HttpGetFn = realHttpsGet;
/**
* Test seam: replace the HTTP transport. Pass null to restore the real transport.
* Tests call this before exercising a real-adapter code path; always restore in finally.
*/
function _setHttpGet(fn: HttpGetFn | null): void {
httpsGet = fn ?? realHttpsGet;
}
// ---------------------------------------------------------------------------
// Real registry adapters (not exercised by tests — tests inject fakes)
// ---------------------------------------------------------------------------
function degradedSignals(): PackageSignals {
return {
exists: null,
publishedAt: null,
weeklyDownloads: null,
repoUrl: null,
deprecated: false,
postinstall: null,
};
}
async function lookupNpm(name: string, version?: string): Promise<PackageSignals> {
try {
const resp = await httpsGet(`https://registry.npmjs.org/${encodeURIComponent(name)}`, 5000);
if (resp.statusCode === 404) return { ...degradedSignals(), exists: false };
if (resp.statusCode < 200 || resp.statusCode >= 300) return degradedSignals();
const data = JSON.parse(resp.body) as Record<string, unknown>;
if (data.error) return { ...degradedSignals(), exists: false };
const time = (data.time as Record<string, string> | undefined) ?? {};
const allVersions = (data.versions as Record<string, unknown> | undefined) ?? {};
// I3: when a specific version is requested, verify it exists
if (version !== undefined) {
if (!(version in allVersions)) {
return { ...degradedSignals(), exists: false };
}
}
const latestVersion = (data['dist-tags'] as Record<string, string> | undefined)?.latest ?? '';
const resolvedVersion = version !== undefined ? version : latestVersion;
const versionMeta = allVersions[resolvedVersion] ?? {};
const scripts =
((versionMeta as Record<string, unknown>).scripts as Record<string, string> | undefined) ??
{};
const postinstall = scripts.postinstall ?? null;
const repoField = (versionMeta as Record<string, unknown>).repository;
let repoUrl: string | null = null;
if (typeof repoField === 'string') repoUrl = repoField;
else if (repoField && typeof (repoField as Record<string, unknown>).url === 'string') {
repoUrl = (repoField as Record<string, string>).url;
}
const deprecated =
typeof (versionMeta as Record<string, unknown>).deprecated === 'string' ? true : false;
// Fetch weekly download count from the npm downloads API
let weeklyDownloads: number | null = null;
try {
const dlResp = await httpsGet(
`https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(name)}`,
5000
);
if (dlResp.statusCode >= 200 && dlResp.statusCode < 300) {
const dlData = JSON.parse(dlResp.body) as Record<string, unknown>;
if (typeof dlData.downloads === 'number') {
weeklyDownloads = dlData.downloads;
}
}
} catch {
// Degraded: leave weeklyDownloads as null, never throw
}
return {
exists: true,
publishedAt: time[resolvedVersion] ?? time.created ?? null,
weeklyDownloads,
repoUrl,
deprecated,
postinstall,
ecosystem: 'npm',
};
} catch {
return degradedSignals();
}
}
async function lookupPypi(name: string, version?: string): Promise<PackageSignals> {
try {
const resp = await httpsGet(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`, 5000);
if (resp.statusCode === 404) return { ...degradedSignals(), exists: false };
if (resp.statusCode < 200 || resp.statusCode >= 300) return degradedSignals();
const data = JSON.parse(resp.body) as Record<string, unknown>;
const info = (data.info as Record<string, unknown>) ?? {};
// I3: when a specific version is requested, verify it exists in releases
const releases = (data.releases as Record<string, unknown> | undefined) ?? {};
if (version !== undefined) {
if (!(version in releases)) {
return { ...degradedSignals(), exists: false };
}
}
// Finding 2: when version is provided, derive publishedAt from the
// version-specific release record rather than the package-level urls[] array
// (which reflects the latest release, not the requested version).
let uploadTime: string | null = null;
if (version !== undefined) {
const versionFiles = (releases[version] as Array<Record<string, unknown>> | undefined) ?? [];
uploadTime =
versionFiles.length > 0
? (versionFiles[0].upload_time_iso_8601 as string | undefined) ?? null
: null;
} else {
const urls = (data.urls as Array<Record<string, unknown>>) ?? [];
uploadTime =
urls.length > 0 ? (urls[0].upload_time_iso_8601 as string | undefined) ?? null : null;
}
const projectUrls = info.project_urls as Record<string, string> | undefined;
const repoUrl =
projectUrls?.['Source'] ??
projectUrls?.['Homepage'] ??
(info.home_page as string | undefined) ??
null;
return {
exists: true,
publishedAt: uploadTime,
weeklyDownloads: null, // PyPI weekly downloads require a separate API
repoUrl: repoUrl || null,
deprecated: false, // PyPI doesn't have a first-class deprecated field
postinstall: null, // Not applicable for PyPI
ecosystem: 'pypi',
};
} catch {
return degradedSignals();
}
}
async function lookupCrates(name: string, version?: string): Promise<PackageSignals> {
try {
const resp = await httpsGet(
`https://crates.io/api/v1/crates/${encodeURIComponent(name)}`,
5000
);
if (resp.statusCode === 404) return { ...degradedSignals(), exists: false };
if (resp.statusCode < 200 || resp.statusCode >= 300) return degradedSignals();
const data = JSON.parse(resp.body) as Record<string, unknown>;
const krate = (data.crate as Record<string, unknown>) ?? {};
// I3: when a specific version is requested, verify it exists in versions list
const versions = (data.versions as Array<Record<string, unknown>> | undefined) ?? [];
if (version !== undefined) {
const found = versions.some(
(v) => (v.num as string | undefined) === version
);
if (!found) {
return { ...degradedSignals(), exists: false };
}
}
const repoUrl = (krate.repository as string | undefined) ?? null;
// Finding 2: when version is provided, use the version-specific created_at
// rather than the package-level crate.created_at (first-ever publish date).
let created: string | null;
if (version !== undefined) {
const versionObj = versions.find((v) => (v.num as string | undefined) === version);
created = (versionObj?.created_at as string | undefined) ?? null;
} else {
created = (krate.created_at as string | undefined) ?? null;
}
// recent_downloads is a 90-day count; normalize to a weekly figure for comparison
// against minWeeklyDownloads (which is a weekly threshold).
const rawDownloads = krate.recent_downloads;
const downloads = (rawDownloads != null && typeof Number(rawDownloads) === 'number' && !isNaN(Number(rawDownloads)))
? Math.round(Number(rawDownloads) * 7 / 90)
: null;
return {
exists: true,
publishedAt: created,
weeklyDownloads: downloads,
repoUrl,
deprecated: false,
postinstall: null,
ecosystem: 'crates',
};
} catch {
return degradedSignals();
}
}
const realRegistry: RegistryClient = {
async lookup(ecosystem: Ecosystem, name: string, version?: string): Promise<PackageSignals> {
switch (ecosystem) {
case 'npm':
return lookupNpm(name, version);
case 'pypi':
return lookupPypi(name, version);
case 'crates':
return lookupCrates(name, version);
default:
return degradedSignals();
}
},
};
// ---------------------------------------------------------------------------
// checkPackages — orchestrates lookup + classify + slopcheck merge
// ---------------------------------------------------------------------------
async function checkPackages(
{ ecosystem, packages, version }: CheckPackagesInput,
{
registry = realRegistry,
clock = Date,
thresholds = DEFAULT_THRESHOLDS,
slopcheck = null,
}: CheckPackagesOptions = {}
): Promise<CheckResult[]> {
const results: CheckResult[] = [];
for (const name of packages) {
const signals = await registry.lookup(ecosystem, name, version);
const { verdict: registryVerdict, reasons } = classifyPackage(signals, { thresholds, clock });
let finalVerdict: Verdict = registryVerdict;
if (slopcheck != null) {
const slopVerdict = await slopcheck.check(ecosystem, name);
if (slopVerdict != null) {
finalVerdict = moreSevereVerdict(finalVerdict, slopVerdict);
}
}
results.push({ name, verdict: finalVerdict, signals, reasons });
}
return results;
}
// ---------------------------------------------------------------------------
// Module export (CommonJS interop — export = only, no other export keywords)
// ---------------------------------------------------------------------------
export = { DEFAULT_THRESHOLDS, classifyPackage, checkPackages, _setHttpGet };