@eagleoutice/flowr-dev
Version:
Static Dataflow Analyzer and Program Slicer for the R Programming Language
977 lines • 52.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.NoDisabledSources = exports.VersionSpace = exports.DefaultFixpointIterations = exports.DefaultExplodeLimit = exports.ProjectPackage = void 0;
exports.dateCutoff = dateCutoff;
exports.isoDay = isoDay;
exports.collectUsage = collectUsage;
exports.collectOrphanUsage = collectOrphanUsage;
exports.intersectSurvivors = intersectSurvivors;
exports.collectTransitiveConstraints = collectTransitiveConstraints;
exports.iterateToFixpoint = iterateToFixpoint;
exports.enforceArcConsistency = enforceArcConsistency;
exports.countRunnableCombinations = countRunnableCombinations;
exports.survivingEntries = survivingEntries;
exports.timelinePackageKey = timelinePackageKey;
exports.orderedCandidatesOf = orderedCandidatesOf;
exports.defaultTargets = defaultTargets;
exports.coInstallability = coInstallability;
exports.assignmentsOf = assignmentsOf;
exports.declaredDependenciesOf = declaredDependenciesOf;
exports.explodeDependencyVersions = explodeDependencyVersions;
/**
* The dependency version space: for each dependency, the set of signature-database versions that survive the
* project's constraints (declared ranges, transitive requirements, the base-R/assumed-R bound, a date cutoff, and
* how the code actually calls the package), and the explosion of that space into concrete per-dependency version
* assignments. This is a source-agnostic resolver over {@link PackageSignatureSource} and the dependencies context;
* the `guess-dep-versions` query presents it, but it is usable on its own (e.g. for compatibility-matrix tooling).
*/
const semver_1 = require("semver");
const r_version_1 = require("../util/r-version");
const prefix_1 = require("../util/prefix");
const r_base_primitives_generated_1 = require("../data/r-base-primitives.generated");
const reader_1 = require("./sigdb/reader");
const signature_match_1 = require("./sigdb/signature-match");
const sigdb_version_1 = require("./sigdb/sigdb-version");
const identifier_1 = require("../dataflow/environments/identifier");
const vertex_1 = require("../dataflow/graph/vertex");
const graph_1 = require("../dataflow/graph/graph");
const df_helper_1 = require("../dataflow/graph/df-helper");
const r_base_packages_1 = require("../util/r-base-packages");
const undefined_symbol_util_1 = require("../linter/rules/undefined-symbol-util");
const type_1 = require("../r-bridge/lang-4.x/ast/model/type");
const built_in_s_seven_new_generic_1 = require("../dataflow/internal/process/functions/call/built-in/built-in-s-seven-new-generic");
/** the pseudo-package standing for the analyzed project itself, never one of its own dependencies */
exports.ProjectPackage = 'current';
/** default safety cap on how many assignments {@link explodeDependencyVersions} yields */
/** the dependency kinds a load has to satisfy, as `Suggests`/`Enhances` are not enforced when a package is attached */
const LoadRelevantDeps = new Set([0 /* DepType.Depends */, 1 /* DepType.Imports */, 2 /* DepType.LinkingTo */]);
/** The requirements `pkg@version` places on other packages that a load has to satisfy (see {@link LoadRelevantDeps}). */
function loadRequirements(src, pkg, version) {
return (src?.dependencies(pkg, version) ?? []).filter(d => LoadRelevantDeps.has(d.type));
}
exports.DefaultExplodeLimit = 256;
/** the date cutoff (end of the named day/month/year) for a `YYYY.MM.DD` spec, or `undefined` if malformed */
function dateCutoff(spec) {
const window = (0, sigdb_version_1.parseDateWindow)(spec);
return window ? new Date(window.upper) : undefined;
}
/** an ISO `YYYY-MM-DD` day */
function isoDay(date) {
return date.toISOString().slice(0, 10);
}
/** memoizing resolver for one function's signature, one decode per (function, version) pair */
function makeFnResolver(src, name) {
const cache = new Map();
return (fn, version) => {
const key = version + '\0' + fn;
if (!cache.has(key)) {
cache.set(key, src?.functionByName(name, fn, version));
}
return cache.get(key);
};
}
/**
* Every S3 class the analyzed project's own NAMESPACE registers a method for (its `S3method(generic, class)`
* directives, flattened across every generic), deduplicated.
*/
function projectS3Classes(deps) {
const generics = deps.getDependency(exports.ProjectPackage)?.namespaceInfo?.exportS3Generics;
return new Set([...(generics?.values() ?? [])].flat());
}
/** builtins whose string-literal argument names an S3/S4 class in use, and which slot carries it */
const ClassUsageArgs = {
inherits: { positional: 1, named: 'what' },
is: { positional: 1, named: 'class2' },
as: { positional: 1, named: 'Class' },
new: { positional: 0, named: 'Class' },
structure: { named: 'class' }
};
function argStringLiteral(graph, arg) {
const id = graph_1.FunctionArgument.isNamed(arg) ? arg.valueId : graph_1.FunctionArgument.getId(arg);
if (id === undefined) {
return undefined;
}
const node = graph.idMap?.get(id);
return node?.type === type_1.RType.String ? node.content.str : undefined;
}
/** class names used as a string literal in the code (`inherits(x, "zoo")`, `new("Foo")`, ...); only direct literals, not variables or `c(...)` */
function collectCodeClassUses(graph) {
const classes = new Set();
for (const [id, vertex] of graph.verticesOfType(vertex_1.VertexType.FunctionCall)) {
const qualified = df_helper_1.Dataflow.qualify(id, graph, true);
const fn = qualified === undefined ? undefined : identifier_1.Identifier.getName(qualified);
const spec = fn === undefined ? undefined : ClassUsageArgs[fn];
if (spec === undefined) {
continue;
}
let positional = 0;
for (const arg of vertex.args) {
if (graph_1.FunctionArgument.isEmpty(arg)) {
continue;
}
const named = graph_1.FunctionArgument.isNamed(arg);
const target = named ? arg.name === spec.named : spec.positional === positional;
if (!named) {
positional++;
}
if (target) {
const literal = argStringLiteral(graph, arg);
if (literal !== undefined && literal.length > 0) {
classes.add(literal);
}
}
}
}
return classes;
}
/**
* Mark the package that OWNS a used S3/S4 class as used, from the project's NAMESPACE `S3method`/`exportClasses`
* registrations and from class-name literals in the code. The same-named constructor is recorded as a synthetic
* use so the version is bounded by its function-export history, not by the pre-NAMESPACE-biased `s3Classes` set.
*
* The owner is looked for among the packages already in play (called or declared). A NAMESPACE registration is
* deliberate wiring, so it may still introduce a package from outside that set, but only for a project that declares
* no dependencies at all. Class names collide heavily across CRAN: with declared dependencies present, the
* whole-database {@link PackageSignatureSource.classOwner} answer is decided by database order (attributing `POSIXlt`
* or `function` to whichever unrelated package comes first), so it is a collision far more often than a hidden
* dependency, and it costs reading every package in the database. A class-name literal in code is weaker still and
* may only ever refine a package already in play.
*/
function addClassOwnershipUsage(usage, deps, graph) {
const namespaceClasses = projectS3Classes(deps);
const codeClasses = collectCodeClassUses(graph);
if (namespaceClasses.size === 0 && codeClasses.size === 0) {
return;
}
const sources = deps.signatureSources();
const declared = deps.declaredPackageNames();
// `current` is the analyzed project itself, not a dependency (see `defaultTargets`)
const anchored = new Set([...usage.keys(), ...deps.getDependencies().map(d => d.name), ...declared].filter(n => n !== exports.ProjectPackage));
const owners = sources.map(src => (0, reader_1.classOwnerIndexFor)(src, anchored));
const mayIntroduce = declared.length === 0;
const attribute = (cls, fromNamespace) => {
for (const [i, src] of sources.entries()) {
const owner = owners[i].get(cls) ?? (fromNamespace && mayIntroduce ? src.classOwner(cls) : undefined);
if (owner === undefined) {
continue;
}
let pkgUsage = usage.get(owner);
if (pkgUsage === undefined) {
pkgUsage = new Map();
usage.set(owner, pkgUsage);
}
// narrow by the same-named constructor's presence only when it actually resolves (else just mark used)
if (!pkgUsage.has(cls) && src.functionByName(owner, cls) !== undefined) {
pkgUsage.set(cls, { named: new Map(), calls: new Map([['#0', []]]) });
}
return;
}
};
for (const cls of namespaceClasses) {
attribute(cls, true);
}
for (const cls of codeClasses) {
attribute(cls, false);
}
}
/**
* Whether an argument was synthesized by flowR rather than written in the code. Such an argument is not evidence of
* how the package is called, so a signature that lacks its parameter must not reject the version (S7's `new_class`
* has no `fun` parameter, yet flowR appends one to model the constructor it returns).
*/
function isSyntheticArgument(arg) {
return !graph_1.FunctionArgument.isEmpty(arg) && String(arg.nodeId).endsWith(built_in_s_seven_new_generic_1.S7SyntheticFunArgSuffix);
}
/** record one call of `fn` (made at `at`) into `pkgUsage`: union its named argument names and keep one representative per call shape */
function recordCallUsage(pkgUsage, fn, rawArgs, at) {
let entry = pkgUsage.get(fn);
if (entry === undefined) {
entry = { named: new Map(), calls: new Map() };
pkgUsage.set(fn, entry);
}
entry.at ??= at;
const args = rawArgs.some(isSyntheticArgument) ? rawArgs.filter(a => !isSyntheticArgument(a)) : rawArgs;
const named = [];
let positional = 0;
for (const arg of args) {
if (graph_1.FunctionArgument.isNamed(arg)) {
if (!entry.named.has(arg.name)) {
entry.named.set(arg.name, at);
}
named.push(arg.name);
}
else if (graph_1.FunctionArgument.isPositional(arg)) {
positional++;
}
}
// dedupe by call shape: named names + positional count
const key = named.sort().join(',') + '#' + positional;
if (!entry.calls.has(key)) {
entry.calls.set(key, [...args]);
}
}
/** scan the dataflow graph for every call that resolves (via {@link Dataflow.qualify}) to a package export */
function collectUsage(graph, deps) {
const usage = new Map();
for (const [id, vertex] of graph.verticesOfType(vertex_1.VertexType.FunctionCall)) {
// use canonical qualification to reconstruct pkg::fn identifier
const qualified = df_helper_1.Dataflow.qualify(id, graph, true);
if (qualified === undefined) {
continue;
}
const pkg = identifier_1.Identifier.getNamespace(qualified);
if (pkg === undefined) {
continue;
}
let pkgUsage = usage.get(pkg);
if (pkgUsage === undefined) {
pkgUsage = new Map();
usage.set(pkg, pkgUsage);
}
recordCallUsage(pkgUsage, identifier_1.Identifier.getName(qualified), vertex.args, id);
}
if (deps) {
addClassOwnershipUsage(usage, deps, graph);
}
return usage;
}
/**
* How many packages may export an orphan's name before it is dropped as too generic. Up to this many, the most
* downloaded one is taken (see {@link collectOrphanUsage}); beyond it the name carries no information.
*/
const MaxOrphanProviders = 5;
/**
* Fold the analyzed code's *orphan* calls into `usage` and report which functions implicated each **unknown**
* package. An orphan is a bare call (`ggplot()`) whose name is not bound to a local/parameter/closure/import
* definition and is not a default-attached base export, but is exported by exactly one signature-database package.
* Such a call never qualifies to `pkg::fn`, so {@link collectUsage} is blind to it (even when flowR models the
* function as a builtin, as it does for `ggplot`), yet it pins the package's version just as a qualified call
* would; folding it into `usage` makes the package a guess target. The returned map lists, per package the project
* does not already declare or load (`isKnown` is `false`), the orphan function names that pointed at it (e.g.
* `ggplot2` from `ggplot()`) -- a note for a downstream handler to attach the library, since the symbol would be
* undefined were the package not loaded. Disambiguation is `options.builtinLibraryOf` (flowR's curated map, e.g.
* `ggplot` to `ggplot2`, authoritative even when several packages re-export the name), then a package the project
* already declares or loads, and finally the most downloaded of at most {@link MaxOrphanProviders} exporters; a
* name beyond that many packages export says nothing about which one is meant and is skipped, as are quoted (NSE)
* uses and forward-referenced closures. The exporters that lost the pick are kept as
* {@link OrphanUsage.alternatives}, since the guess is exactly that -- a guess.
*/
function collectOrphanUsage(graph, deps, usage, isKnown, options = {}) {
const { self, builtinLibraryOf } = options;
const attributed = new Map();
const alternatives = new Map();
const alternativeUsage = new Map();
const scopeDefined = (0, undefined_symbol_util_1.collectScopeDefinedNames)(graph);
for (const [id, vertex] of graph.verticesOfType(vertex_1.VertexType.FunctionCall)) {
// an anonymous callee, or an already-qualified `pkg::fn` (handled by collectUsage)
if (vertex.origin === 'unnamed' || identifier_1.Identifier.getNamespace(vertex.name) !== undefined) {
continue;
}
const name = identifier_1.Identifier.getName(vertex.name);
// a call bound to a real definition (local, parameter, closure, or import) *is* that definition, not a package
// export; only an unresolved or builtin-modeled call can be an orphan (flowR models e.g. `ggplot` as a builtin)
if (df_helper_1.Dataflow.origin(graph, id)?.some(o => o.type === 2 /* OriginType.FunctionCallOrigin */) === true) {
continue;
}
// a bare name from a default-attached base package (or a base primitive) is defined without a library() call
const baseOwner = (0, r_base_packages_1.baseRExportOwner)(name);
if (baseOwner !== undefined && r_base_packages_1.AttachedBasePackages.includes(baseOwner)) {
continue;
}
// a quoted use, or a forward-referenced closure binding flowR did not statically link, is not a real orphan call
if ((0, undefined_symbol_util_1.isNonStandardEvaluated)(graph, id) || (0, undefined_symbol_util_1.isDefinedInEnclosingScope)(graph, scopeDefined, id, name)) {
continue;
}
// flowR's curated map disambiguates a name several packages export (ggplot to ggplot2); failing that, a
// package the project already declares or loads explains the call and needs no library attached (dplyr
// re-exports tidyselect's `everything`); failing that the most downloaded exporter wins, as
// `packagesExporting` hands them over in that order and a script calling a bare name almost always means
// the popular package. A name half of CRAN exports says nothing, so it is skipped.
const mapped = builtinLibraryOf?.(name);
const providers = deps.packagesExporting(name).filter(p => p !== self);
const known = providers.filter(isKnown);
const builtin = mapped !== undefined && mapped !== self;
const pkg = builtin ? mapped
: known.length === 1 ? known[0]
: known.length === 0 && providers.length > 0 && providers.length <= MaxOrphanProviders ? providers[0] : undefined;
if (pkg === undefined) {
continue;
}
recordCallUsage(atKey(usage, pkg, () => new Map()), name, vertex.args, id);
// a package the project already declares or loads is a normal target, not an orphan needing attachment
if (isKnown(pkg)) {
continue;
}
const reason = builtin ? 'builtin' : providers.length === 1 ? 'sole exporter' : 'most downloaded';
const calls = atKey(attributed, pkg, () => new Map());
if (!calls.has(name)) {
calls.set(name, { at: id, reason, exporters: providers.length });
}
// the exporters that lost, with the same calls recorded, so the guess can report what each of them would fit
const losers = providers.filter(p => p !== pkg);
alternatives.set(pkg, [...new Set([...alternatives.get(pkg) ?? [], ...losers])]);
for (const alt of losers) {
recordCallUsage(atKey(alternativeUsage, alt, () => new Map()), name, vertex.args, id);
}
}
return { attributed, alternatives, alternativeUsage };
}
/** the entry of `key`, inserting what `make` builds when the map has none yet */
function atKey(map, key, make) {
let found = map.get(key);
if (found === undefined) {
map.set(key, found = make());
}
return found;
}
/** the dated releases + base-R core releases + latest of a package, ascending by R-version order */
function versionTimeline(src, pkg) {
return (0, reader_1.availableVersionEntries)(src, pkg).map(e => ({ ver: e.version, ...(e.date ? { date: e.date } : {}) }));
}
/** the number of arguments a call actually supplies (empty positional slots do not count) */
function suppliedArgs(args) {
return args.reduce((n, a) => n + (graph_1.FunctionArgument.isEmpty(a) ? 0 : 1), 0);
}
/** whether a version's signatures accept how the code calls it; tracked names absent here are removals, untracked names are unknown primitives */
function isCompatible(getFn, version, usage, tracked) {
for (const [fn, use] of usage) {
const decoded = getFn(fn, version);
if (decoded === undefined) {
if (tracked.has(fn)) {
return false; // a tracked function removed in this version
}
continue; // an untracked primitive: unknown, not a removal
}
if (decoded.signature.length === 0) {
continue; // an empty capture is uninformative (a generic like `seq`, or a data gap): it cannot disprove a call
}
for (const args of use.calls.values()) {
// R's matching: exact, pmatch prefix, or positional; `...` absorbs the rest
const bound = (0, signature_match_1.matchArgumentsToSignature)(args, decoded.signature);
let placed = 0;
for (const ids of bound.values()) {
placed += ids.length;
}
if (placed < suppliedArgs(args)) {
return false;
}
}
}
return true;
}
/**
* Whether a named argument would bind to a parameter of `decoded` under R's matching (exact, pmatch, or `...`).
* `undefined` (the function is absent) is a no; an empty capture (a generic like `seq` whose formals became
* `UseMethod`, or a data gap) is uninformative and treated as accepting (so it never reports a false removal).
*/
function argumentSupported(decoded, arg) {
if (decoded === undefined) {
return false;
}
const names = decoded.signature.map(p => p.name);
return names.length === 0 || names.includes('...') || names.includes((0, prefix_1.findByPrefixIfUnique)(arg, names) ?? arg);
}
/** the earliest *carried* version in `timeline` (ascending) whose signature satisfies `predicate` */
function earliestSupporting(src, pkg, timeline, predicate) {
for (const entry of timeline) {
if (src.hasVersion(pkg, entry.ver) && predicate(entry.ver)) {
return entry.ver;
}
}
return undefined;
}
/** the latest *carried* version in `timeline` (ascending) whose signature satisfies `predicate` */
function latestSupporting(src, pkg, timeline, predicate) {
for (let i = timeline.length - 1; i >= 0; i--) {
if (src.hasVersion(pkg, timeline[i].ver) && predicate(timeline[i].ver)) {
return timeline[i].ver;
}
}
return undefined;
}
/** emit one signature bound: `>=v` only when `v` is after the floor, `<=v` only when before the ceiling */
function emitSignatureBound(observe, fn, v, op, ref, verb, from) {
if (v !== undefined && (op === '>=' ? r_version_1.RVersion.compare(v, ref) > 0 : r_version_1.RVersion.compare(v, ref) < 0)) {
const qualified = identifier_1.Identifier.toString(fn);
observe({ source: 'signature', origin: qualified, detail: `${qualified} ${verb} ${v}`, bound: `${op}${v}`, function: qualified, ...from });
}
}
/** emit signature bounds from a package's version history: when functions/parameters appear or vanish */
function addSignatureEvidence(observe, src, getFn, pkg, usage, timeline) {
const floor = earliestSupporting(src, pkg, timeline, () => true);
const ceiling = latestSupporting(src, pkg, timeline, () => true);
if (floor === undefined || ceiling === undefined) {
return; // no carried version to compare against
}
for (const [fn, use] of usage) {
const qualified = identifier_1.Identifier.make(fn, pkg);
const present = (v) => getFn(fn, v) !== undefined;
emitSignatureBound(observe, qualified, earliestSupporting(src, pkg, timeline, present), '>=', floor, 'exists only from', { at: use.at });
emitSignatureBound(observe, qualified, latestSupporting(src, pkg, timeline, present), '<=', ceiling, 'removed after', { at: use.at });
for (const [arg, at] of use.named) {
const supported = (v) => argumentSupported(getFn(fn, v), arg);
emitSignatureBound(observe, qualified, earliestSupporting(src, pkg, timeline, supported), '>=', floor, `has parameter '${arg}' only from`, { at, parameter: arg });
emitSignatureBound(observe, qualified, latestSupporting(src, pkg, timeline, supported), '<=', ceiling, `dropped parameter '${arg}' after`, { at, parameter: arg });
}
}
}
/** intersection of multiple survivor sets: versions that survive in every set */
function intersectSurvivors(survivorSets) {
if (survivorSets.length === 0) {
return [];
}
const [first, ...rest] = survivorSets;
const restVersions = rest.map(set => new Set(set.map(e => e.ver)));
return first.filter(e => restVersions.every(set => set.has(e.ver)));
}
/**
* Collapse alternative requirements that are all simple lower bounds (or all simple upper bounds) into the single
* weakest one, which *is* their union: `>= 3.5.0 || >= 4.0.0` is just `>= 3.5.0`. This filters identically and keeps
* a package with a long release history from reporting a dozen redundant alternatives.
*/
function collapseAlternatives(ranges) {
if (ranges.length < 2) {
return ranges;
}
// only single-comparator ranges are comparable this cheaply; anything compound (`>=1.0 <2.0`) keeps its alternatives
const simple = ranges.map(r => r.set.length === 1 && r.set[0].length === 1 ? r.set[0][0] : undefined);
if (simple.some(c => c === undefined || (c.operator !== '>=' && c.operator !== '>' && c.operator !== '<=' && c.operator !== '<'))) {
return ranges;
}
const lower = simple.every(c => c?.operator === '>=' || c?.operator === '>');
const upper = simple.every(c => c?.operator === '<=' || c?.operator === '<');
if (!lower && !upper) {
return ranges;
}
let best = 0;
for (let i = 1; i < ranges.length; i++) {
const cmp = r_version_1.RVersion.compare(simple[i]?.semver.version, simple[best]?.semver.version);
// the weakest bound wins; at the same version the inclusive one (`>=` over `>`) is the weaker
if (cmp === 0 ? simple[i]?.operator.length === 2 : (lower ? cmp < 0 : cmp > 0)) {
best = i;
}
}
return [ranges[best]];
}
/**
* The transitive constraints declared packages place on their own dependencies (one level deep). `versionsOf` gives
* the versions of each declaring package that are still in play. The requirements are read from all of them, so a
* constraint only ever filters when *every* one of them declares it (see {@link TransitiveConstraint}). Without it,
* the single {@link Package.resolvedVersion|resolved version} is used, which is by definition universal.
*/
function collectTransitiveConstraints(deps, sources, versionsOf) {
const out = new Map();
for (const pkg of deps.getDependencies()) {
// merged across sources: a single source may only carry part of the package's history, and a version it lacks
// would look like "declares nothing" and wrongly turn the requirement into a partial one
const src = (0, reader_1.sourceForPackage)(sources, pkg.name);
if (!src) {
continue;
}
const known = versionsOf?.(pkg.name);
// `undefined` asks the source for its own default version, so a package with no candidates still yields its requirements
const versions = known && known.length > 0 ? known : [pkg.resolvedVersion];
// per required package: the distinct requirements found, and from how many of `versions` they were read
const perDep = new Map();
for (const version of versions) {
const seen = new Set();
for (const dep of loadRequirements(src, pkg.name, version)) {
const range = dep.constraint ? r_version_1.RRange.parse(dep.constraint) : undefined;
if (!range || seen.has(dep.name)) {
continue;
}
seen.add(dep.name);
let entry = perDep.get(dep.name);
if (entry === undefined) {
entry = { ranges: new Map(), declaredBy: 0 };
perDep.set(dep.name, entry);
}
entry.ranges.set(range.raw, range);
entry.declaredBy++;
}
}
const only = versions.length === 1 ? versions[0] : undefined;
const from = `${pkg.name}${only ? ' ' + only : ''}`;
for (const [name, entry] of perDep) {
const arr = out.get(name) ?? [];
arr.push({ ranges: collapseAlternatives([...entry.ranges.values()]), from, universal: entry.declaredBy === versions.length });
out.set(name, arr);
}
}
return out;
}
/** the default bound for the fixpoint loops, overridable per query with {@link GuessDepVersionsQuery.maxIterations} */
exports.DefaultFixpointIterations = 8;
/** repeatedly run `step` until it reports no further change (returns `false`) or `maxIterations` is reached */
function iterateToFixpoint(maxIterations, step) {
for (let i = 0; i < maxIterations && step(); i++) { /* repeat until a step makes no change */ }
}
/**
* Whether a version's signatures accept how the code calls the package. The verdict depends only on the code and the
* database, never on the constraints, so it is decided once per version and reused across the fixpoint's passes.
*/
function makeSignatureFilter(src, pkg, getFn, timeline, usage) {
if (!src || !usage) {
return () => true;
}
// tracked names appear in some carried version; untracked names must not reject all versions
const tracked = new Set([...usage.keys()].filter(fn => timeline.some(e => getFn(fn, e.ver) !== undefined)));
const verdict = new Map();
return version => {
let ok = verdict.get(version);
if (ok === undefined) {
ok = !src.hasVersion(pkg, version) || isCompatible(getFn, version, usage, tracked);
verdict.set(version, ok);
}
return ok;
};
}
/**
* The version space of one analysis: the surviving versions of each dependency and the transitive constraints
* refined to a fixpoint. Holding the shared inputs here keeps the passes to a handful of arguments and lets each
* package's sigdb key, source and decoded signatures be resolved once. The passes revisit the same versions
* repeatedly, so that memoization is what makes the fixpoint affordable.
*/
class VersionSpace {
deps;
sources;
usage;
cutoff;
rVersion;
disabled;
resolved = new Map();
constructor({ deps, usage, cutoff, rVersion, disabled = exports.NoDisabledSources }) {
this.deps = deps;
this.sources = deps.signatureSources();
this.usage = usage;
this.cutoff = cutoff;
this.rVersion = rVersion;
this.disabled = disabled;
}
/** the sigdb package, source and signature resolver of a target, resolved once */
resolve(name) {
let entry = this.resolved.get(name);
if (entry === undefined) {
const key = timelinePackageKey(name, this.sources);
const src = (0, reader_1.sourceForPackage)(this.sources, key);
const getFn = makeFnResolver(src, key);
const timeline = src ? versionTimeline(src, key) : [];
const all = this.usage.get(name);
// base primitives are captured inconsistently, so their absence from a base package is a data gap, not a removal
const usage = all && src?.isBaseR(key) ? new Map([...all].filter(([fn]) => !r_base_primitives_generated_1.RBasePrimitives.has(fn))) : all;
entry = { key, src, getFn, timeline, usage, signatureOk: makeSignatureFilter(src, key, getFn, timeline, usage) };
this.resolved.set(name, entry);
}
return entry;
}
/** the versions of `name` surviving every constraint, see {@link survivingEntries} */
survivors(name, transitive, observe) {
return survivingEntries(this, name, transitive, observe);
}
/**
* The transitive constraints, refined to a fixpoint: each pass re-reads every declaring package's requirements
* from the versions of it that survived the previous pass, so two packages can tighten each other. Feeding back
* the whole surviving set (not one representative version) is what keeps a requirement that only *some* of those
* versions declare from filtering (see {@link TransitiveConstraint}).
*
* The first pass runs with *no* transitive constraint at all, so every later pass can only shrink the surviving
* sets and thus only tighten the constraints: the refinement is monotone and cannot oscillate, and stopping early
* at `maxIterations` leaves it on the permissive side rather than at an arbitrary point. A declaring package that
* is not among the `targets` is not being guessed, so its {@link Package.resolvedVersion} stands in.
*/
refineTransitive(targets, maxIterations = exports.DefaultFixpointIterations) {
if (this.disabled.has('transitive')) {
return collectTransitiveConstraints(this.deps, this.sources);
}
let transitive = new Map();
let previous = '';
iterateToFixpoint(maxIterations, () => {
const pass = new Map();
for (const name of targets) {
pass.set(name, this.survivors(name, transitive.get(name) ?? []).survivors.map(e => e.ver));
}
// the sets only ever shrink, so their sizes and endpoints identify the state
const signature = Array.from(pass, ([k, v]) => `${k}=${v.length}:${v[0] ?? ''}-${v[v.length - 1] ?? ''}`).sort().join(',');
if (signature === previous) {
return false;
}
previous = signature;
transitive = collectTransitiveConstraints(this.deps, this.sources, name => pass.get(name));
return true;
});
return transitive;
}
}
exports.VersionSpace = VersionSpace;
/** drop, to a fixpoint, the versions of each package that no co-guessed dependency can satisfy */
function enforceArcConsistency(space, initial, maxIterations = exports.DefaultFixpointIterations) {
const survivors = new Map(initial);
const blockers = new Map();
iterateToFixpoint(maxIterations, () => {
const versionsByName = new Map([...survivors].map(([name, entries]) => [name, entries.map(e => e.ver)]));
let changed = false;
for (const [name, entries] of survivors) {
const { src, key } = space.resolve(name);
if (src === undefined) {
continue;
}
const kept = entries.filter(v => versionMeetsPartners(src, key, v.ver, versionsByName, name, (partner, constraint) => {
const forPkg = blockers.get(name) ?? new Map();
forPkg.set(partner, constraint);
blockers.set(name, forPkg);
}));
if (kept.length !== entries.length) {
changed = true;
survivors.set(name, kept);
}
}
return changed;
});
return { survivors, blockers };
}
/** the parseable requirements each surviving version of `f` places on the other packages, resolved once */
function factorRequirements(space, f) {
const { src, key } = space.resolve(f.name);
return f.survivors.map(v => {
const out = new Map();
for (const dep of loadRequirements(src, key, v)) {
const range = dep.constraint ? r_version_1.RRange.parse(dep.constraint) : undefined;
if (range && !out.has(dep.name)) {
out.set(dep.name, range);
}
}
return out;
});
}
/** the coupling between two factors, or `undefined` when neither ever constrains the other */
function coupleFactors(a, b, factors, requirements) {
const onB = requirements[a].map(r => r.has(factors[b].name));
const onA = requirements[b].map(r => r.has(factors[a].name));
if (!onB.some(Boolean) && !onA.some(Boolean)) {
return undefined;
}
// a direction nobody requires is simply absent, not partial: only a direction some but not all versions declare is
const partial = (side) => side.some(Boolean) && !side.every(Boolean);
return { a, b, always: !partial(onB) && !partial(onA) };
}
/** `ok[i][j]`: version `i` of factor `a` runs with version `j` of factor `b` */
function compatibilityMatrix(c, factors, requirements) {
const [fa, fb] = [factors[c.a], factors[c.b]];
const onB = requirements[c.a].map(r => r.get(fb.name));
const onA = requirements[c.b].map(r => r.get(fa.name));
return fa.survivors.map((va, i) => fb.survivors.map((vb, j) => (onB[i] === undefined || r_version_1.RRange.satisfies(vb, onB[i])) && (onA[j] === undefined || r_version_1.RRange.satisfies(va, onA[j]))));
}
/**
* The runnable-combination count: how many version tuples satisfy the requirements the factors place on each other.
* A requirement need not hold for every version of the declaring package (`A 0.2.5` may pin `B` to `0.2.1` while
* `A 0.3.0` pins it to `0.3.2`), so the two are counted as *coupled* rather than as independent factors: a version
* of `A` only ever multiplies in the versions of `B` it actually admits.
*
* Counting all couplings exactly is #CSP-hard, so they are counted over a spanning forest of the coupling graph,
* preferring the couplings that always apply. That is exact whenever the graph has no cycle, which covers the common
* shared-hub shape. A coupling that would close a cycle is dropped and reported as uncounted, leaving the result an
* upper bound. Only the forest's couplings need their compatibility matrix, so the work stays linear in the factors.
*/
function countRunnableCombinations(space, factors) {
const requirements = factors.map(f => factorRequirements(space, f));
const found = [];
for (let a = 0; a < factors.length; a++) {
for (let b = a + 1; b < factors.length; b++) {
const coupling = coupleFactors(a, b, factors, requirements);
if (coupling) {
found.push(coupling);
}
}
}
const parent = factors.map((_, i) => i);
const find = (i) => parent[i] === i ? i : (parent[i] = find(parent[i]));
const tree = [];
const couplings = found.sort((x, y) => Number(y.always) - Number(x.always)).map(c => {
const [ra, rb] = [find(c.a), find(c.b)];
const counted = ra !== rb;
if (counted) {
parent[ra] = rb;
tree.push({ ...c, ok: compatibilityMatrix(c, factors, requirements) });
}
return { a: factors[c.a].name, b: factors[c.b].name, always: c.always, counted };
});
return { total: countOverForest(factors, tree), couplings };
}
/** the number of assignments satisfying every coupling of a forest, by rooting each tree and folding bottom-up */
function countOverForest(factors, tree) {
const neighbours = factors.map(() => []);
for (const c of tree) {
neighbours[c.a].push({ to: c.b, ok: c.ok, flipped: false });
neighbours[c.b].push({ to: c.a, ok: c.ok, flipped: true });
}
// weight[i] of a node: how many assignments of its subtree exist with that node fixed to version i
const visited = factors.map(() => false);
const subtree = (node, from) => {
visited[node] = true;
const weight = factors[node].survivors.map(() => 1);
for (const edge of neighbours[node]) {
if (edge.to === from || visited[edge.to]) {
continue;
}
const child = subtree(edge.to, node);
for (let i = 0; i < weight.length; i++) {
let sum = 0;
for (let j = 0; j < child.length; j++) {
sum += (edge.flipped ? edge.ok[j][i] : edge.ok[i][j]) ? child[j] : 0;
}
weight[i] *= sum;
}
}
return weight;
};
let total = 1;
for (let i = 0; i < factors.length; i++) {
if (!visited[i]) {
total *= subtree(i, -1).reduce((a, b) => a + b, 0);
}
}
return total;
}
/** whether every requirement `pkg@ver` places on a co-guessed dependency is met by one of that partner's surviving versions */
function versionMeetsPartners(src, pkg, ver, partnerSurvivors, selfName, onReject) {
for (const dep of loadRequirements(src, pkg, ver)) {
if (dep.name === selfName || !dep.constraint) {
continue;
}
const partner = partnerSurvivors.get(dep.name);
if (partner === undefined || partner.length === 0) {
continue;
}
const req = r_version_1.RRange.parse(dep.constraint);
if (req && !partner.some(v => r_version_1.RRange.satisfies(v, req))) {
onReject?.(dep.name, dep.constraint);
return false;
}
}
return true;
}
/** an empty set, shared so callers that do not disable anything need not allocate one */
exports.NoDisabledSources = new Set();
/**
* Filter a version timeline by the declared range, transitive constraints, base-R version bound, and date cutoff,
* emitting each constraint to `observe` (when given) as it is applied, so the filtering and its explanation cannot
* drift apart. A source disabled on `space` is skipped entirely: neither filtered on nor reported as evidence.
*/
function applyConstraints(space, name, timeline, inputs, observe) {
const { disabled, rVersion, cutoff } = space;
const { declaredRange, declaredConstraints, transitive, base } = inputs;
let t = timeline.slice();
if (!disabled.has('declared')) {
for (const c of declaredConstraints) {
observe?.({ source: 'declared', origin: 'project metadata', detail: `declared as ${c}`, bound: c });
}
t = declaredRange ? t.filter(e => r_version_1.RRange.satisfies(e.ver, declaredRange)) : t;
}
if (!disabled.has('transitive')) {
for (const c of transitive) {
const bound = r_version_1.RRange.formatAlternatives(c.ranges);
// a constraint only some versions of the origin declare cannot filter: another of its versions avoids it
const detail = c.universal ? `${c.from} requires ${name} ${bound}` : `some versions of ${c.from} require ${name} ${bound}`;
observe?.({ source: 'transitive', origin: c.from, detail, bound, ...(c.universal ? {} : { partial: true }) });
if (c.universal) {
t = t.filter(e => r_version_1.RRange.satisfiesAny(e.ver, c.ranges));
}
}
}
// a base package's version *is* the R version, so it is bounded by the assumed/declared R (only when that is known)
const rv = !disabled.has('base-r') && base && rVersion ? r_version_1.RVersion.parse(rVersion) : undefined;
if (rv) {
observe?.({ source: 'base-r', origin: `R ${rVersion}`, detail: `base package bounded by R ${rVersion}`, bound: `<=${rVersion}` });
t = t.filter(e => r_version_1.RVersion.compare(e.ver, rv.str) <= 0);
}
if (cutoff && !disabled.has('date')) {
observe?.({ source: 'date', origin: isoDay(cutoff), detail: `only releases up to ${isoDay(cutoff)}`, bound: `<=${isoDay(cutoff)}` });
// base R is stored undated, so fall back to its R release date; a dated release must predate the cutoff, and an
// undated one is dropped, except base R older than the release table (kept, as it predates any real cutoff)
t = t.filter(e => {
const date = e.date ?? (base ? (0, r_version_1.rReleaseDate)(e.ver) : undefined);
return date !== undefined ? date.getTime() <= cutoff.getTime() : base;
});
}
return t;
}
/**
* Apply every constraint (declared, transitive, base-R, date, then signature usage) to a package's timeline. When an
* `observe` callback is given, emits the provenance of each constraint (including the signature lower bounds).
* Prefer {@link VersionSpace.survivors}, which is the same call with the shared inputs already bound.
*/
function survivingEntries(space, name, transitive, observe) {
const { deps, disabled } = space;
// packageKey: sigdb package (R reuses base, others use themselves)
const { key: packageKey, src, getFn, timeline, usage, signatureOk } = space.resolve(name);
const declaredRange = disabled.has('declared') ? undefined : deps.inferredRange(name);
const declaredConstraints = disabled.has('declared') ? [] : (deps.getDependency(name)?.versionConstraints.map(c => c.raw) ?? []);
const effectiveTransitive = disabled.has('transitive') ? [] : transitive;
// contradiction is a constraint property, not an empty database
const unsatisfiable = constraintsContradict(declaredConstraints, declaredRange, effectiveTransitive);
if (!src) {
return { survivors: [], preSignature: [], getFn, declaredRange, declaredConstraints, base: false, unsatisfiable, total: 0, declared: 0, known: false };
}
const base = src.isBaseR(packageKey);
const total = timeline.length;
const known = total > 0;
const declared = declaredRange ? timeline.filter(e => r_version_1.RRange.satisfies(e.ver, declaredRange)).length : total;
// emit database coverage envelope as outer bounds
if (observe && timeline.length > 0 && !disabled.has('available')) {
observe({ source: 'available', origin: 'signature database', detail: `data available from ${timeline[0].ver}`, bound: `>=${timeline[0].ver}` });
observe({ source: 'available', origin: 'signature database', detail: `data available up to ${timeline[timeline.length - 1].ver}`, bound: `<=${timeline[timeline.length - 1].ver}` });
}
const preSignature = applyConstraints(space, name, timeline, { declaredRange, declaredConstraints, transitive: effectiveTransitive, base }, observe);
if (!usage || disabled.has('signature')) {
return { survivors: preSignature, preSignature, getFn, declaredRange, declaredConstraints, base, unsatisfiable, total, declared, known };
}
if (observe) {
addSignatureEvidence(observe, src, getFn, packageKey, usage, preSignature);
}
const survivors = preSignature.filter(e => signatureOk(e.ver));
return { survivors, preSignature, getFn, declaredRange, declaredConstraints, base, unsatisfiable, total, declared, known };
}
/** the sigdb package a target's version history is drawn from: `R` reuses `base` (their releases coincide), everything else is itself */
function timelinePackageKey(name, sources) {
return name === 'R' && !sources.some(s => s.has(name)) ? 'base' : name;
}
/** whether the (combined) declared and transitive constraints can be satisfied by no version at all */
function constraintsContradict(declaredConstraints, declaredRange, transitive) {
// declared constraints exist but do not combine into a satisfiable range
if (declaredConstraints.length > 0 && declaredRange === undefined) {
return true;
}
// only unambiguous requirements can contradict: a constraint with alternatives (or one only some versions declare) always has a way out
const ranges = [declaredRange, ...transitive.filter(t => t.universal && t.ranges.length === 1).map(t => t.ranges[0])].filter((r) => r !== undefined);
if (ranges.length === 0) {
return false;
}
const combined = r_version_1.RRange.intersect(ranges);
return combined === undefined || (0, semver_1.minVersion)(combined) === null;
}
/**
* Order a package's surviving versions by preference: an explicitly {@link VersionExplodeOptions.prefer|preferred}
* version first, then still-on-CRAN (non-archived) releases before archived ones, then by release (newest or oldest
* first per {@link VersionExplodeOptions.order}).
*/
function orderCandidates(src, name, survivors, prefer, order) {
const preferred = prefer !== undefined && survivors.some(e => e.ver === prefer) ? prefer : undefined;
// decide archived/non-CRAN status once per version (a cheap `noncran` check), not inside the O(n log n) comparator
const archived = new Set(survivors.filter(e => !src.isCranVersion(name, e.ver)).map(e => e.ver));
const rest = survivors.filter(e => e.ver !== preferred).sort((a, b) => (archived.has(a.ver) ? 1 : 0) - (archived.has(b.ver) ? 1 : 0) || (order === 'oldest' ? r_version_1.RVersion.compare(a.ver, b.ver) : r_version_1.RVersion.compare(b.ver, a.ver)));
return preferred !== undefined ? [preferred, ...rest.map(e => e.ver)] : rest.map(e => e.ver);
}
/** the ordered candidate list for one package, or `undefined` when nothing survives (shared by the query and the iterator) */
function orderedCandidatesOf(src, name, surviving, prefer, order) {
return src && surviving.survivors.length > 0 ? { pkg: name, versions: orderCandidates(src, name, surviving.survivors, prefer, order) } : undefined;
}
/** the default explosion targets: every declared and used dependency (excluding `current`, the analyzed package's own namespace) */
function defaultTargets(deps, usage) {
return [...new Set([...deps.getDependencies().map(d => d.name), ...usage.keys()])].filter(name => name !== exports.ProjectPackage);
}
/**
* Whether the chosen versions can be loaded together, checking every requirement the chosen versions declare
* against the version chosen for the required package (R itself against the chosen base-R version). A requirement
* on a package the assignment does not choose cannot be settled here and is reported as
* {@link VersionAssignment.unverified} instead of silently passing, as `library()` still enforces it.
*/
function coInstallability(versions, declares) {
const unverified = [];
for (const [pkg, version] of versions) {
for (const dep of declares(pkg, version) ?? []) {
if (dep.constraint === undefined || !LoadRelevantDeps.has(dep.type)) {
continue;
}
/* R's own version is the version of the base packages, which is what the assignment names it by */
const chosen = versions.get(dep.name) ?? (dep.name === 'R' ? versions.get('base') : undefined);
const range = r_version_1.RRange.parse(dep.constraint);
if (range === undefined) {
continue;
}
else if (chosen === undefined) {
unverified.push(`${pkg} ${version} requires ${dep.name} ${dep.constraint}`);
}
else if (!r_version_1.RRange.satisfies(chosen, range)) {
return { ok: false };
}
}
}
return { ok: true, ...(unverified.length > 0 ? { unverified } : {}) };
}
/**
* Lazily yield concrete version assignments (one version per package) in odometer order over the per-package lists.
* With `declares`, a combination whose versions cannot be loaded together is skipped rather than proposed; `limit`
* bounds the combinations *considered* either way, so fewer than `limit` assignments may come out.
*/
function* assignmentsOf(perPackage, limit, declares) {
if (perPackage.length === 0 || perPackage.some(p => p.versions.length === 0)) {
return;
}
const idx = perPackage.map(() => 0);
for (let count = 0; count < limit; count++) {
const versions = new Map();
for (let i = 0; i < perPackage.length; i++) {
versions.set(perPackage[i].pkg, perPackage[i].versions[idx[i]]);
}
const checked = declares === undefined ? undefined : coInstallability(versions, declares);
if (checked === undefined) {
yield { versions };
}
else if (checked.ok) {
yield { versions, ...(checked.unverified ? { unverified: checked.unverified } : {}) };
}
// advance the odometer: the last package varies fastest
let k = perPackage.length - 1;
for (; k >= 0; k--) {
if (++idx[k] < perPackage[k].versions.length) {
break;
}
idx[k] = 0;
}
if (k < 0) {
return; // wrapped around: the whole space is exhausted
}
}
}
/** the per-package ordered candidate lists for the constraint-space explosion (only packages with a surviving version) */
async function orderedCandidatesFor(analyzer, options) {
const ctx = analyzer.inspectContext();
const deps = ctx.deps;
const sources = deps.signatureSources();
if (sources.length === 0) {
return { candidates: [], declares: () => undefined };
}
const cutoff = options.date ? dateCutoff(options.date) : undefined;
// bound base packages by R only when genuinely known (a config pin, metadata, or detection); `auto` with nothing detected imposes no R ceiling
const rVersion = ctx.rVersionKnown ? (ctx.meta.getRVersion() ?? ctx.resolvedRVersion) : undefined;
const usage = collectUsage((await analyzer.dataflow()).graph, deps);
const targets = (options.packages && options.packages.length > 0 ? [...options.packages] : defaultTargets(deps, usage)).sort();
const space = new VersionSpace({ deps, usage, cutoff, rVersion });
const transitive = space.refineTransitive(targets);
const order = options.order ?? 'newest';
const out = [];
for (const name of targets) {
const surviving = space.survivors(name, transitive.get(name) ?? []);
const oc = orderedCandidatesOf(space.resolve(name).src, name, surviving, options.prefer?.[name], order);
if (oc) {
out.push(oc);
}
}
return { candidates: out, declares: declaredDependenciesOf(space) };
}
/** the {@link DependencyResolver} backed by the signature database a version space resolves each package in */
function declaredDependenciesOf(space) {
return (pkg, version) => space.resolve(pkg).src?.dependencies(pkg, version);
}
/**
* Explode the guessed constraint space into concrete, signature-database-available version assignments: a lazy
* iterator over one chosen version per resolvable dependency. Each package's versions are ordered by preference
* (an explicitly {@link VersionExplodeOptions.prefer|preferred} version, then non-archived releases, then newest or
* oldest first), so the first assignments are the most preferred. The iterator is bounded by
* {@link VersionExplodeOptions.limit} so an enormous product cannot run away.
*/
async function* explodeDependencyVersions(analyzer, options = {}) {
const { candidates, declares } = await orderedCandidatesFor(analyzer, options);
yield* assignmentsOf(candidates, options.limit ?? exports.DefaultExplodeLimit, declares);
}
//# sourceMappingURL=dependency-version-space.js.map