diff --git a/README.md b/README.md index 557eddd35..fcecf7e7e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/gitnexus/README.md b/gitnexus/README.md index 5733ab947..6d5ce3aab 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -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 diff --git a/gitnexus/src/core/ingestion/languages/r/package-config.ts b/gitnexus/src/core/ingestion/languages/r/package-config.ts index 41b3c895d..5c3d576d0 100644 --- a/gitnexus/src/core/ingestion/languages/r/package-config.ts +++ b/gitnexus/src/core/ingestion/languages/r/package-config.ts @@ -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 { +/** + * 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 { const packages = new Map(); const namespaceInfoByPackageDir = new Map(); const scanQueue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }]; @@ -176,6 +185,5 @@ export async function loadRPackageConfig(repoRoot: string): Promise/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". */ diff --git a/gitnexus/test/fixtures/lang-resolution/r-no-description-truncated/R/a.R b/gitnexus/test/fixtures/lang-resolution/r-no-description-truncated/R/a.R new file mode 100644 index 000000000..6361c801a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-no-description-truncated/R/a.R @@ -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 diff --git a/gitnexus/test/fixtures/lang-resolution/r-no-description-truncated/R/b.R b/gitnexus/test/fixtures/lang-resolution/r-no-description-truncated/R/b.R new file mode 100644 index 000000000..c70ed5269 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-no-description-truncated/R/b.R @@ -0,0 +1,3 @@ +run_ext <- function(d) { + dplyr::filter(d) +} diff --git a/gitnexus/test/fixtures/lang-resolution/r-no-description-truncated/deep/a/b/c/impl/DESCRIPTION b/gitnexus/test/fixtures/lang-resolution/r-no-description-truncated/deep/a/b/c/impl/DESCRIPTION new file mode 100644 index 000000000..32cd616da --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-no-description-truncated/deep/a/b/c/impl/DESCRIPTION @@ -0,0 +1,2 @@ +Package: hiddenpkg +Version: 0.1 diff --git a/gitnexus/test/fixtures/lang-resolution/r-no-description-truncated/deep/a/b/c/impl/R/x.R b/gitnexus/test/fixtures/lang-resolution/r-no-description-truncated/deep/a/b/c/impl/R/x.R new file mode 100644 index 000000000..3f887bd85 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-no-description-truncated/deep/a/b/c/impl/R/x.R @@ -0,0 +1 @@ +hidden_fn <- function() 1 diff --git a/gitnexus/test/fixtures/lang-resolution/r-no-description/R/a.R b/gitnexus/test/fixtures/lang-resolution/r-no-description/R/a.R new file mode 100644 index 000000000..7cfc0c730 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-no-description/R/a.R @@ -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 diff --git a/gitnexus/test/fixtures/lang-resolution/r-no-description/R/b.R b/gitnexus/test/fixtures/lang-resolution/r-no-description/R/b.R new file mode 100644 index 000000000..c70ed5269 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-no-description/R/b.R @@ -0,0 +1,3 @@ +run_ext <- function(d) { + dplyr::filter(d) +} diff --git a/gitnexus/test/fixtures/lang-resolution/r-no-description/scripts/run.R b/gitnexus/test/fixtures/lang-resolution/r-no-description/scripts/run.R new file mode 100644 index 000000000..901a3f3eb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-no-description/scripts/run.R @@ -0,0 +1,9 @@ +select <- function(d) d + +run_same <- function(d) { + dplyr::mutate(d) +} + +run_decoy <- function(d) { + dplyr::select(d) +} diff --git a/gitnexus/test/integration/resolvers/r-qualified-calls.test.ts b/gitnexus/test/integration/resolvers/r-qualified-calls.test.ts index a73aeac68..96f7c2a16 100644 --- a/gitnexus/test/integration/resolvers/r-qualified-calls.test.ts +++ b/gitnexus/test/integration/resolvers/r-qualified-calls.test.ts @@ -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', + ]); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/r/r-qualified-call.test.ts b/gitnexus/test/unit/scope-resolution/r/r-qualified-call.test.ts index 57e3746c9..2d2390c96 100644 --- a/gitnexus/test/unit/scope-resolution/r/r-qualified-call.test.ts +++ b/gitnexus/test/unit/scope-resolution/r/r-qualified-call.test.ts @@ -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) { + 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 /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.