fix(r): treat a completed package scan with zero packages as proving pkg:: calls external

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).
This commit is contained in:
Mick Cooney 2026-09-30 17:13:33 +01:00
parent d2b3c2497d
commit 4bdacd539e
13 changed files with 126 additions and 8 deletions

View file

@ -708,7 +708,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
**R (experimental)** — `.r` / `.R` files, parsed with `@eagleoutice/tree-sitter-r`. Extracts functions (S3 methods appear as dotted-name functions such as `print.foo`), S4 classes (`setClass`, `setGeneric`, `setMethod`, slots), R6 classes with their methods and fields, R5 (`setRefClass`) methods, and roxygen2 `@param` types. The scope-resolution pipeline resolves `library()` / `require()` / `source()` imports to local packages and files, binds NAMESPACE `importFrom()` names to definitions in local packages, resolves `obj$method()` calls (receiver type inferred from `Type$new()`), follows calls inside native pipe (`|>`) chains, and links R6 `inherit=` / S4 `contains=` heritage. Export status is read from each local package's NAMESPACE (a `DESCRIPTION` marks the package): `export()`, `exportClasses()`, `exportMethods()`, `S3method()` and `exportPattern()` (multi-line directives, R string escapes and POSIX character classes handled) decide which symbols keep `isExported`; the language-level export check treats every symbol as public, and the NAMESPACE pass then marks the rest of a NAMESPACE-bearing package non-exported. Packages without a NAMESPACE stay fully public, and roxygen2 `@export` tags are not consulted. Known limits: a bare call to a function in another file of the same package binds through a workspace-wide unique-name fallback at confidence 0.5 (`global-name-fallback`), not package-namespace lookup, and an ambiguous name yields no edge; dotted (S3-style) names are never import-resolved and bind by qualified-name lookup at 0.35; a qualified call `pkg::name()` is decided by its qualifier: when `pkg` is a package of the repository and defines `name` exactly once, the call binds to that definition at confidence 0.85 (`import-resolved`); when `pkg` is certainly not a repository package (package discovery completed and found no such package), the call produces no edge; when `pkg` defines `name` more than once the call produces no edge; and when discovery may have missed a package (its depth and directory limits were reached and an unscanned subtree may hold an undiscovered package) the call keeps the name-based behaviour. Remaining gaps: a repository package that does not itself define `name` (for example a re-export) keeps the name-based behaviour; `pkg::name` used as a value rather than called (`lapply(x, pkg::name)`) is not resolved; a `::` to a name the package does not export still binds to its definition; and packages are discovered only to a bounded depth and directory count, so a package outside those limits is treated like an unknown one; `import()`, `importClassesFrom()` and `importMethodsFrom()` are not read; `exportPattern()` character classes use ASCII semantics; S4 `contains=` links only to classes defined in the repository or the `VIRTUAL` marker, so basic-type bases add no edge; imports of external (CRAN) packages produce no edges; an edit to NAMESPACE alone is not re-resolved by an incremental re-index, so run a full analyze after changing it; no framework or entry-point detection.
**R (experimental)** — `.r` / `.R` files, parsed with `@eagleoutice/tree-sitter-r`. Extracts functions (S3 methods appear as dotted-name functions such as `print.foo`), S4 classes (`setClass`, `setGeneric`, `setMethod`, slots), R6 classes with their methods and fields, R5 (`setRefClass`) methods, and roxygen2 `@param` types. The scope-resolution pipeline resolves `library()` / `require()` / `source()` imports to local packages and files, binds NAMESPACE `importFrom()` names to definitions in local packages, resolves `obj$method()` calls (receiver type inferred from `Type$new()`), follows calls inside native pipe (`|>`) chains, and links R6 `inherit=` / S4 `contains=` heritage. Export status is read from each local package's NAMESPACE (a `DESCRIPTION` marks the package): `export()`, `exportClasses()`, `exportMethods()`, `S3method()` and `exportPattern()` (multi-line directives, R string escapes and POSIX character classes handled) decide which symbols keep `isExported`; the language-level export check treats every symbol as public, and the NAMESPACE pass then marks the rest of a NAMESPACE-bearing package non-exported. Packages without a NAMESPACE stay fully public, and roxygen2 `@export` tags are not consulted. Known limits: a bare call to a function in another file of the same package binds through a workspace-wide unique-name fallback at confidence 0.5 (`global-name-fallback`), not package-namespace lookup, and an ambiguous name yields no edge; dotted (S3-style) names are never import-resolved and bind by qualified-name lookup at 0.35; a qualified call `pkg::name()` is decided by its qualifier: when `pkg` is a package of the repository and defines `name` exactly once, the call binds to that definition at confidence 0.85 (`import-resolved`); when `pkg` is certainly not a repository package (package discovery completed and found no such package, including a repository with no `DESCRIPTION` at all), the call produces no edge; when `pkg` defines `name` more than once the call produces no edge; and when discovery may have missed a package (its depth and directory limits were reached and an unscanned subtree may hold an undiscovered package) the call keeps the name-based behaviour. Remaining gaps: a repository package that does not itself define `name` (for example a re-export) keeps the name-based behaviour; `pkg::name` used as a value rather than called (`lapply(x, pkg::name)`) is not resolved; a `::` to a name the package does not export still binds to its definition; and packages are discovered only to a bounded depth and directory count, so a package outside those limits is treated like an unknown one; `import()`, `importClassesFrom()` and `importMethodsFrom()` are not read; `exportPattern()` character classes use ASCII semantics; S4 `contains=` links only to classes defined in the repository or the `VIRTUAL` marker, so basic-type bases add no edge; imports of external (CRAN) packages produce no edges; an edit to NAMESPACE alone is not re-resolved by an incremental re-index, so run a full analyze after changing it; no framework or entry-point detection.
**Control flow (CFG, opt-in `--pdg`)** — per-function control-flow graphs (`BasicBlock` nodes + `CFG` edges) feeding the PDG/taint substrate, currently **TypeScript & JavaScript** (#2081 M1); other languages planned. Off by default.

View file

@ -484,7 +484,7 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift,
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
**R (experimental)** — `.r` / `.R` files, parsed with `@eagleoutice/tree-sitter-r`. Extracts functions (S3 methods appear as dotted-name functions such as `print.foo`), S4 classes (`setClass`, `setGeneric`, `setMethod`, slots), R6 classes with their methods and fields, R5 (`setRefClass`) methods, and roxygen2 `@param` types. The scope-resolution pipeline resolves `library()` / `require()` / `source()` imports to local packages and files, binds NAMESPACE `importFrom()` names to definitions in local packages, resolves `obj$method()` calls (receiver type inferred from `Type$new()`), follows calls inside native pipe (`|>`) chains, and links R6 `inherit=` / S4 `contains=` heritage. Export status is read from each local package's NAMESPACE (a `DESCRIPTION` marks the package): `export()`, `exportClasses()`, `exportMethods()`, `S3method()` and `exportPattern()` (multi-line directives, R string escapes and POSIX character classes handled) decide which symbols keep `isExported`; the language-level export check treats every symbol as public, and the NAMESPACE pass then marks the rest of a NAMESPACE-bearing package non-exported. Packages without a NAMESPACE stay fully public, and roxygen2 `@export` tags are not consulted. Known limits: a bare call to a function in another file of the same package binds through a workspace-wide unique-name fallback at confidence 0.5 (`global-name-fallback`), not package-namespace lookup, and an ambiguous name yields no edge; dotted (S3-style) names are never import-resolved and bind by qualified-name lookup at 0.35; a qualified call `pkg::name()` is decided by its qualifier: when `pkg` is a package of the repository and defines `name` exactly once, the call binds to that definition at confidence 0.85 (`import-resolved`); when `pkg` is certainly not a repository package (package discovery completed and found no such package), the call produces no edge; when `pkg` defines `name` more than once the call produces no edge; and when discovery may have missed a package (its depth and directory limits were reached and an unscanned subtree may hold an undiscovered package) the call keeps the name-based behaviour. Remaining gaps: a repository package that does not itself define `name` (for example a re-export) keeps the name-based behaviour; `pkg::name` used as a value rather than called (`lapply(x, pkg::name)`) is not resolved; a `::` to a name the package does not export still binds to its definition; and packages are discovered only to a bounded depth and directory count, so a package outside those limits is treated like an unknown one; `import()`, `importClassesFrom()` and `importMethodsFrom()` are not read; `exportPattern()` character classes use ASCII semantics; S4 `contains=` links only to classes defined in the repository or the `VIRTUAL` marker, so basic-type bases add no edge; imports of external (CRAN) packages produce no edges; an edit to NAMESPACE alone is not re-resolved by an incremental re-index, so run a full analyze after changing it; no framework or entry-point detection.
**R (experimental)** — `.r` / `.R` files, parsed with `@eagleoutice/tree-sitter-r`. Extracts functions (S3 methods appear as dotted-name functions such as `print.foo`), S4 classes (`setClass`, `setGeneric`, `setMethod`, slots), R6 classes with their methods and fields, R5 (`setRefClass`) methods, and roxygen2 `@param` types. The scope-resolution pipeline resolves `library()` / `require()` / `source()` imports to local packages and files, binds NAMESPACE `importFrom()` names to definitions in local packages, resolves `obj$method()` calls (receiver type inferred from `Type$new()`), follows calls inside native pipe (`|>`) chains, and links R6 `inherit=` / S4 `contains=` heritage. Export status is read from each local package's NAMESPACE (a `DESCRIPTION` marks the package): `export()`, `exportClasses()`, `exportMethods()`, `S3method()` and `exportPattern()` (multi-line directives, R string escapes and POSIX character classes handled) decide which symbols keep `isExported`; the language-level export check treats every symbol as public, and the NAMESPACE pass then marks the rest of a NAMESPACE-bearing package non-exported. Packages without a NAMESPACE stay fully public, and roxygen2 `@export` tags are not consulted. Known limits: a bare call to a function in another file of the same package binds through a workspace-wide unique-name fallback at confidence 0.5 (`global-name-fallback`), not package-namespace lookup, and an ambiguous name yields no edge; dotted (S3-style) names are never import-resolved and bind by qualified-name lookup at 0.35; a qualified call `pkg::name()` is decided by its qualifier: when `pkg` is a package of the repository and defines `name` exactly once, the call binds to that definition at confidence 0.85 (`import-resolved`); when `pkg` is certainly not a repository package (package discovery completed and found no such package, including a repository with no `DESCRIPTION` at all), the call produces no edge; when `pkg` defines `name` more than once the call produces no edge; and when discovery may have missed a package (its depth and directory limits were reached and an unscanned subtree may hold an undiscovered package) the call keeps the name-based behaviour. Remaining gaps: a repository package that does not itself define `name` (for example a re-export) keeps the name-based behaviour; `pkg::name` used as a value rather than called (`lapply(x, pkg::name)`) is not resolved; a `::` to a name the package does not export still binds to its definition; and packages are discovered only to a bounded depth and directory count, so a package outside those limits is treated like an unknown one; `import()`, `importClassesFrom()` and `importMethodsFrom()` are not read; `exportPattern()` character classes use ASCII semantics; S4 `contains=` links only to classes defined in the repository or the `VIRTUAL` marker, so basic-type bases add no edge; imports of external (CRAN) packages produce no edges; an edit to NAMESPACE alone is not re-resolved by an incremental re-index, so run a full analyze after changing it; no framework or entry-point detection.
## Agent Skills

View file

@ -27,6 +27,8 @@ export interface RPackageConfig {
* only a same-name copy of an already-discovered package does NOT truncate.
* A package missing from {@link packages} is then not proven absent from the
* repo, so `rQualifierLocality` answers `unknown` rather than `external`.
* This holds for an empty {@link packages} map too: a complete scan that found
* no package at all proves every qualifier external.
* Optional so that hand-built configs (tests, callers that never scan) read
* as "complete".
*/
@ -96,7 +98,14 @@ export async function hidesUndiscoveredPackage(
return false;
}
export async function loadRPackageConfig(repoRoot: string): Promise<RPackageConfig | null> {
/**
* Discover the R packages of a repo. Always returns a config: a scan that finds
* no package yields an empty {@link RPackageConfig.packages} map, and its
* {@link RPackageConfig.truncated} flag says whether that result is complete
* (no `DESCRIPTION` anywhere, so no `pkg::fn()` qualifier names a local package)
* or merely what the bounded scan managed to see.
*/
export async function loadRPackageConfig(repoRoot: string): Promise<RPackageConfig> {
const packages = new Map<string, string>();
const namespaceInfoByPackageDir = new Map<string, RNamespaceInfo>();
const scanQueue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
@ -176,6 +185,5 @@ export async function loadRPackageConfig(repoRoot: string): Promise<RPackageConf
) {
truncated = true;
}
if (packages.size === 0) return null;
return { packages, namespaceInfoByPackageDir, truncated };
}

View file

@ -64,9 +64,11 @@ function isUnderPackageRDir(pkg: string, filePath: string): boolean {
* `<pkg>/R/` directory (a package whose `DESCRIPTION` discovery missed or
* that has none; root-level packages are found through `cfg` only);
* - `external`: not local, and `cfg` exists and its discovery was complete
* (`!cfg.truncated`), so `pkg` provably is not part of the repo;
* - `unknown`: not found, but discovery was truncated or no config could be
* built, so `pkg` may still be a local package that was not seen.
* (`!cfg.truncated`), so `pkg` provably is not part of the repo. This
* includes a complete scan that found no package at all (no `DESCRIPTION`
* anywhere): the config is then empty, not absent;
* - `unknown`: not found, but discovery was truncated or there is no config
* (hand-built callers), so `pkg` may still be a local package that was not seen.
*
* One predicate, so the populate step and the veto cannot disagree on "local".
*/

View file

@ -0,0 +1,3 @@
# No DESCRIPTION is discovered at or above depth 3, but deep/a/b/c/impl (below the
# depth limit) holds one, so package discovery cannot prove dplyr is external.
filter <- function(d) d

View file

@ -0,0 +1,3 @@
run_ext <- function(d) {
dplyr::filter(d)
}

View file

@ -0,0 +1,2 @@
Package: hiddenpkg
Version: 0.1

View file

@ -0,0 +1 @@
hidden_fn <- function() 1

View file

@ -0,0 +1,5 @@
# Local definitions that share their names with dplyr verbs. There is no
# DESCRIPTION anywhere in this repo, so no package is local.
filter <- function(d) d
mutate <- function(d) d

View file

@ -0,0 +1,3 @@
run_ext <- function(d) {
dplyr::filter(d)
}

View file

@ -0,0 +1,9 @@
select <- function(d) d
run_same <- function(d) {
dplyr::mutate(d)
}
run_decoy <- function(d) {
dplyr::select(d)
}

View file

@ -5,7 +5,10 @@
* qualifiers exist), `r-qualified-calls-truncated/` (discovery stops at depth 3 and a
* package nested deeper is invisible, so its locality is UNKNOWN) and
* `r-qualified-calls-deep-fixtures/` (a `tests/testthat/fixtures` directory below the depth
* limit holds no package, so discovery still counts as complete).
* limit holds no package, so discovery still counts as complete), `r-no-description/` (a
* repo with no DESCRIPTION: discovery completes with zero packages, so every qualifier is
* external) and `r-no-description-truncated/` (zero packages found, but a skipped subtree
* hides one, so locality is UNKNOWN).
*
* The qualifier names the package:
* - exactly one definition of the name in a local package -> precise binding (0.85);
@ -229,3 +232,40 @@ describe('R qualified calls: skipped directory without a package (r-qualified-ca
]);
});
});
describe('R qualified calls: no DESCRIPTION anywhere (r-no-description)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'r-no-description'), () => {});
}, 60000);
// Discovery completed and found zero packages, so no qualifier can name a local package.
it('drops dplyr::filter() instead of binding it to a same-named function elsewhere', () => {
expect(summaries(result, 'run_ext', 'R/b.R')).toEqual([]);
});
it('drops dplyr::mutate() called from a script instead of binding it to R/a.R', () => {
expect(summaries(result, 'run_same', 'scripts/run.R')).toEqual([]);
});
it('drops dplyr::select() instead of binding it to the same-file decoy', () => {
expect(summaries(result, 'run_decoy', 'scripts/run.R')).toEqual([]);
});
});
describe('R qualified calls: no package found and discovery truncated (r-no-description-truncated)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'r-no-description-truncated'), () => {});
}, 60000);
// Permanent guard: zero packages found, but a skipped subtree hides one, so the
// qualifier is not provably external and today's name guess must stay.
it('keeps the name-guess edge while discovery is truncated', () => {
expect(summaries(result, 'run_ext', 'R/b.R')).toEqual([
'filter:R/a.R:global-name-fallback:0.5',
]);
});
});

View file

@ -186,6 +186,48 @@ describe('loadRPackageConfig: discovery completeness on the qualified-call fixtu
});
});
describe('loadRPackageConfig: zero packages found', () => {
async function withTree(build: (root: string) => Promise<void>) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'r-no-description-'));
try {
await build(root);
return await loadRPackageConfig(root);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
}
it('returns a complete empty config when discovery finds no package', async () => {
const cfg = await loadRPackageConfig(path.join(FIXTURES, 'r-no-description'));
expect(cfg).not.toBeNull();
expect(cfg?.packages.size).toBe(0);
expect(cfg?.truncated).toBe(false);
});
it('keeps truncated set when no package is found but a skipped subtree hides one', async () => {
const cfg = await loadRPackageConfig(path.join(FIXTURES, 'r-no-description-truncated'));
expect(cfg?.packages.size).toBe(0);
expect(cfg?.truncated).toBe(true);
});
it('returns a complete empty config for an R-only temporary tree without DESCRIPTION', async () => {
const cfg = await withTree(async (root) => {
await fs.mkdir(path.join(root, 'R'));
await fs.writeFile(path.join(root, 'R', 'a.R'), 'f <- function() 1\n');
});
expect(cfg?.truncated).toBe(false);
});
it('rQualifierLocality: complete empty config proves external; truncated stays unknown', () => {
const empty = config({}, false);
expect(rQualifierLocality('dplyr', empty, new Set(['R/a.R']))).toBe('external');
expect(rQualifierLocality('dplyr', config({}, true), new Set(['R/a.R']))).toBe('unknown');
expect(rQualifierLocality('dplyr', null, new Set(['R/a.R']))).toBe('unknown');
// a <pkg>/R/ path still makes the package local without a DESCRIPTION
expect(rQualifierLocality('pathpkg', empty, new Set(['pathpkg/R/a.R']))).toBe('local');
});
});
describe('loadRPackageConfig: truncation only when a skipped subtree hides an undiscovered package', () => {
// Layout: root/DESCRIPTION (rootpkg). `skipDir` is the first directory below the depth-3
// limit: root(0) > a(1) > b(2) > c(3) lists d, which is skipped.