An unreadable DESCRIPTION left RPackageConfig.truncated false, so a
qualifier naming the package it declares was classed external and its
call site dropped. The sibling hidesUndiscoveredPackage case already
treats an unreadable DESCRIPTION as unknown; do the same here and cover
it with a test.
Tighten three weak test assertions: the escaped-bracket export pattern
now asserts its exact match results, the S4 anchors test proves each
generic is defined exactly once, and the worker owner-absence test
fails on any defined ownerId, including an empty string.
extractS4Fields and getRTopLevelPropertyOwnerName named the owner of a
setClass/setRefClass slot from the first string-valued argument, so
setClass(contains = "Base", Class = "A", representation(x = "numeric"))
attached slot x to Base. Select the owner with isRNamingArgument(arg, 'Class'),
the same choice the scope query and definition hook make.
Both owner paths share one helper so the field metadata and the ownerNameHint
agree.
Each exportPattern matcher has its own work cap, but nothing bounded how many
patterns a repository may carry, so a NAMESPACE with 200 pathological patterns
cost about 77 s of analysis and one with tens of thousands of cheap patterns
cost minutes and gigabytes.
loadRPackageConfig now creates one SharedWorkBudget per call and a sub-budget
per package. Every matcher draws on its package's sub-budget and on the load's:
state visits spent matching (100M per load, 30M per package) and NFA states kept
by compiled patterns (500k per load, 100k per package). A pattern that does not
fit the state allowance is refused before its NFA is fully built, and the first
refusal closes the allowance so later patterns are refused without being parsed.
Once the matching budget is spent, refineRExportStatus and the R import resolver
stop calling the matchers. Refused and cut-short patterns take the existing
drop-and-warn path (export nothing), with at most 50 named per package and one
summary line for the rest. A hostile package can therefore not starve the
packages after it. Budgets count work units, so the outcome is deterministic.
Benign monorepos spend a negligible fraction of every budget.
The R scope query took the name of a setClass/setRefClass class and of a
source/library/require import from the call's first argument. A named first
argument was taken as the name (library(lib.loc = libO, pkgP) imported libO,
setClass(contains = "Base", Class = "A") declared Base), and a comment before
the first argument hid it (the import was lost, and a class declared after a
comment had no EXTENDS edge to VIRTUAL or to its base).
Capture every candidate argument and let the scope emitter keep only the one
that names the class or import: the argument spelled with the call's first
formal (Class, package, file), otherwise the first unnamed argument, with
comments and named arguments allowed before it. The selection is the one the
S4 definition captures already use, and reads each argument list once, so cost
stays linear in the argument count; a query-level prefix that skips leading
comments would be quadratic.
The parse-cache version bump already made for the unreleased S4 definition fix
covers the changed scope captures too, so no further bump is made.
The setClass/setRefClass, setGeneric and setMethod definition patterns matched
any string-valued argument of the call, so every extra string minted a node:
contains = "VIRTUAL" a Class VIRTUAL, valueClass = "numeric" a Function numeric,
and the class in setMethod("show", "Foo", ...) a Method Foo (with a
HAS_METHOD Foo -> Foo edge).
Keep only the argument that names the definition, chosen by the R provider's
shouldSkipDefinitionCapture hook: the argument spelled with the call's first
formal in any position (Class for setClass and setRefClass, name for
setGeneric, f for setMethod), otherwise the first unnamed argument, which
comments and named arguments may precede. A named formal stops a later unnamed
string from also being taken.
The choice is made in code because a query-level prefix that skips leading
comments and named arguments is quadratic in the argument count on every call
in the file. The code reads each argument list once, so cost stays linear, and
a bounded-time test guards it. The slots/fields/representation pattern is keyed
on named arguments only and is unchanged. The EXTENDS edge to VIRTUAL never
depended on the spurious node: scope resolution mints the single placeholder
Class itself.
SCHEMA_BUMP is incremented because the definitions are parse-time captures
replayed from the warm cache. Characterisation tests that pinned the spurious
Method and HAS_METHOD edge are corrected.
The S4/R5 contains= (single and c()), R6 inherit= (namespaced and bare)
and (comment) patterns capture @heritage*, @comment, which no code
reads: such a match has no @name and no @definition.*, so the worker
discards it. Inheritance edges come from the R scope query's
@reference.inherits.
Graph nodes, relationships, properties, resolution outcomes and the raw
worker output are byte-identical over the r-* fixtures and probe repos
before and after, so no re-analyse and no SCHEMA_BUMP are needed.
Also correct the R scope query header, which described the legacy table
as having proved the heritage shapes.
The tag name was never checked against the function's formals and the type was
bound in the scope that contains the assignment, so a documented parameter
typed that name in every function of the file. The type is now bound per
formal, inside the function that owns it, and only for a formal of the
function the roxygen block documents.
Also pin the line-start anchor with a case that fails without it, and a tag
name that merely starts with `param`.
A @param description is free prose, but the first word of any capitalised
description was bound as the parameter's type (`@param df Data frame ...`
gave `df: Data`). A type is now bound only when the description is the type
alone or starts with it in braces, and the type names an R6, S4 or RefClass
class defined in the same file. Tags are read only at the start of a roxygen
line and a description runs to the next tag.
Remove the unwired extractReturnType, which took the first word of @return the
same way.
The packageDirs loop inside rOwningPackageDir could only matter when one
package directory sits inside another package's R/ directory, and no test
could observe it. NAMESPACE ownership now reads packages and NAMESPACE
directories only; rFileBelongsToDiscoveredPackage adds packageDirs for the
<name>/R/ locality fallback, where the same-name-copy test pins it. A new
test checks that discovery populates packageDirs.
rQualifierLocality treated a qualifier as a local package when any parsed
file sat under a directory of that name followed by R/, even when discovery
had attributed the directory to a package with a different Package: name.
The directory name is now evidence only for files no discovered package owns.
refineRExportStatus matched a file against the package directory, so
functions in tests/, vignettes/ and inst/ were marked not exported although
R never loads them into the namespace, and a nested package without a
NAMESPACE inherited the enclosing package's. Ownership now comes from the
nearest discovered package whose R/ directory holds the file
(rOwningPackageDir), over every directory discovery read a DESCRIPTION in.
library() of a root-level package resolved to nothing because its directory
'' is falsy in resolveRImportInternal; it now resolves to the package's R/
files like a nested package.
Installed libraries and check output hold one DESCRIPTION per third-party
package. Discovery treated them as repository content: a renv project
library below the depth limit flagged discovery as truncated, which made
every pkg::fn() qualifier unknown, and a shallow copy became a local
package with no parsed files.
The main walk and the hidden-package scan now share one predicate that
skips a directory whose name is exactly renv, packrat or revdep, or ends in
.Rcheck after at least one character. Look-alike names are still searched.
Among same-depth directories declaring one package name, the winner was
decided by breadth-first walk order, which compares path segment by
segment (a/x before a-b/x). Compare the whole relative path by code unit
instead (a-b/x before a/x), so the winner no longer depends on how the
walk happens to meet the directories, and update the comments to say so.
Package discovery used the filesystem's directory listing order, so the
200-directory cap visited different directories, and a package name
declared in several directories resolved to a different directory, on
different filesystems. Read every directory in code-unit name order and
keep the first package seen for a name: the shallowest directory, then the
path that sorts first. A root-level package is no longer replaced by a
nested copy of the same name.
The linear-time exportPattern matcher refused anything over 1,024 states or
4,096 characters, so a legitimate `^(f1|...|f80)$` or `^[[:alpha:]]{1,2000}$`
compiled to nothing and every name in the package read as unexported, with no
diagnostic.
Raise the caps to 16,384 states and characters, and bound the hostile case by a
per-matcher work budget instead: a matcher counts the states it visits and, once
40 million are spent (about 0.4 s), answers false for names it has not decided
and reports itself exhausted. Alternations of literals are built as a trie so
alternatives sharing a prefix share states, the start closure is looked up by
code unit, closures are iterative, and verdicts are memoised per distinct name.
Every non-empty exportPattern that is dropped, and every matcher that runs out
of budget, now produces one warning per pattern per package naming the package
directory, the first 60 characters of the pattern and the reason. The dropped
patterns are also kept on RNamespaceInfo.droppedExportPatterns.
exportPattern() text comes from the analysed repository and was compiled
to a backtracking RegExp, so a pattern such as ^(a+)+$ took 1.6 s on a
27-character name and minutes on a slightly longer one, hanging analyze.
R itself evaluates these with the non-backtracking TRE.
Compile the pattern to a Thompson NFA and simulate it instead, so a match
costs O(name length x NFA size) for any pattern. Constructs with no
linear-time algorithm (back-references, look-around) and patterns that
expand beyond a fixed state cap are dropped like an uncompilable pattern.
Verdicts for ordinary patterns are unchanged, checked against the
previous RegExp on a generated corpus.
Rewrite comment and describe-title text that pointed at labels defined
nowhere in the repository and history-narrative wording that referred to
earlier work, stating each rule directly. No behaviour change; test counts
are unchanged.
Rewrite references to planning notes (section numbers, item labels, review
rounds, scenario/unit numbering) as self-contained statements of the rule or
trade-off. Comment, fixture comment and test-description text only; no
behaviour change.
Replace issue-number references in comments, test names and descriptions with
self-contained wording, drop merge-coordination notes from the SCHEMA_BUMP
history, and rename a fixture file to qualified_use.R. Comment,
description and file-name changes only.
loadRPackageConfig returned null when discovery found no package, and
rQualifierLocality maps a null config to unknown, so in a repo with no
DESCRIPTION anywhere dplyr::filter() still bound to a local filter through
the name guess. Return an empty config instead: its truncated flag now
distinguishes a complete scan (external) from one that may have missed a
package (unknown, name guess kept).
Discovery set RPackageConfig.truncated whenever it skipped any directory
(depth-3 limit) or hit the 200-directory cap, so nearly every real repo
(tests/testthat/fixtures, inst/extdata, ...) counted as truncated. Qualified
calls to outside packages were then classified unknown and kept, leaving the
dplyr::filter() bound to a local filter() case unfixed.
Skipped directories and the cap leftover queue are now collected and scanned
by hidesUndiscoveredPackage (iterative DFS, global 5000-directory budget,
skipping node_modules, .git, .Rproj.user). truncated is set only when that
scan finds a DESCRIPTION whose Package: name is not already discovered,
exceeds the budget, or hits an unreadable path; an unreadable directory in
the main walk still truncates.
The scope query now captures the qualifier of `pkg::fn()` / `pkg:::fn()`; use it.
- qualified-call.ts (new): parseRQualifier, a tri-state rQualifierLocality
(local / external / unknown) and populateRQualifiedCalls, wired after the
NAMESPACE import synthesis in R's populateWorkspaceReferences.
* external package (discovery complete): the site is dropped, so no tier
can guess an edge for it;
* local package with exactly one top-level definition of the name: bound
precisely (0.85) through resolveQualifiedFreeCall, keyed by file + start
position;
* local package with two or more definitions: the site is dropped;
* local package with none (re-export) or unknown locality: unchanged.
- package-config.ts: RPackageConfig.truncated is set when the directory cap,
the depth limit or an unreadable directory left discovery incomplete, so a
package is only called external when the scan was complete.
- isRGlobalNameFallbackPlausible: a candidate inside the named local package
is allowed, one elsewhere is vetoed when the package defines the name.
- Flip the tests that pinned the pre-fix name-only results and drop the
temporary pins and it.fails markers of the step-1 tests.
No shared-core changes.
Capture the whole namespace_operator of pkg::fn() and pkg:::fn() calls as
@reference.qualified-name so the qualifier reaches site.rawQualifiedName and
survives the parse cache. On its own this changes no graph output: no R
consumer reads the qualifier yet.
SCHEMA_BUMP is incremented because warm ParsedFiles lack the new capture.
The node-property ownerId spread in the parse worker was added with R
support and is never read for non-R languages; upstream's worker never
wrote it onto definition nodes. R never had it either (its owner is
attached post-parse). Symbol ownerId and HAS_METHOD/HAS_PROPERTY edges
are unchanged.
Move R's deferred-owner hint out of the shared parse worker into R's
definitionPropertiesExtractor (rDefinitionProperties), and remove the
worker-side hint block, the node/symbol ownerNameHint spreads,
ParsedSymbol.ownerNameHint and the R extractor imports. Delete the dead
hasRoxygenExport branch from rExportChecker (both branches returned true).