mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-15 09:11:33 +00:00
Merge 9385736f6a into 450702e10c
This commit is contained in:
commit
3a94d54408
1118 changed files with 38269 additions and 86 deletions
1
.github/dependabot.yml
vendored
1
.github/dependabot.yml
vendored
|
|
@ -76,6 +76,7 @@ updates:
|
|||
tree-sitter-grammars:
|
||||
patterns:
|
||||
- tree-sitter-*
|
||||
- '@eagleoutice/tree-sitter-*'
|
||||
exclude-patterns:
|
||||
- tree-sitter
|
||||
- tree-sitter-cli
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ GRAMMARS: dict[str, tuple[str, str, str]] = {
|
|||
"tree-sitter-python": ("tree-sitter/tree-sitter-python", "master", "src/parser.c"),
|
||||
"tree-sitter-ruby": ("tree-sitter/tree-sitter-ruby", "master", "src/parser.c"),
|
||||
"tree-sitter-rust": ("tree-sitter/tree-sitter-rust", "master", "src/parser.c"),
|
||||
# Scoped npm package: the only grammar whose name does not start with "tree-sitter-".
|
||||
"@eagleoutice/tree-sitter-r": ("r-lib/tree-sitter-r", "main", "src/parser.c"),
|
||||
"tree-sitter-swift": ("alex-pinkus/tree-sitter-swift", "main", "src/parser.c"),
|
||||
"tree-sitter-typescript": ("tree-sitter/tree-sitter-typescript", "master", "typescript/src/parser.c"),
|
||||
# Vendored parsers — kept here so the upstream coords for drift
|
||||
|
|
@ -162,13 +164,15 @@ def read_pinned_grammar_versions() -> dict[str, str]:
|
|||
|
||||
Looks at both runtime and optional dependencies. Returns the raw range
|
||||
string (e.g. '0.21.4', '^0.23.0', 'file:./vendor/...') so the report can
|
||||
expose how flexible each pin is.
|
||||
expose how flexible each pin is. A grammar is recognised by its package
|
||||
basename, so a scoped package (``@scope/tree-sitter-x``) is included, keyed
|
||||
by its full name.
|
||||
"""
|
||||
pkg = _load_package_json()
|
||||
pinned: dict[str, str] = {}
|
||||
for section in ("dependencies", "optionalDependencies"):
|
||||
for name, spec in (pkg.get(section) or {}).items():
|
||||
if name.startswith("tree-sitter-"):
|
||||
if name.rsplit("/", 1)[-1].startswith("tree-sitter-"):
|
||||
pinned[name] = spec
|
||||
return pinned
|
||||
|
||||
|
|
@ -182,7 +186,7 @@ def read_patched_peers() -> list[tuple[str, str, str]]:
|
|||
"""
|
||||
script = GITNEXUS_DIR / "scripts" / "prepare-tree-sitter-bundle.cjs"
|
||||
return re.findall(
|
||||
r"\['(tree-sitter[\w-]*)@([\d.]+)',\s*'([^']+)'\]",
|
||||
r"\['((?:@[\w.-]+/)?tree-sitter[\w-]*)@([\d.]+)',\s*'([^']+)'\]",
|
||||
script.read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
|
|
@ -196,7 +200,8 @@ def npm_view_json(pkg: str) -> dict | None:
|
|||
"""
|
||||
if OFFLINE:
|
||||
return None
|
||||
url = f"https://registry.npmjs.org/{pkg}/latest"
|
||||
# A scoped name (`@scope/name`) is one path segment with its slash encoded.
|
||||
url = f"https://registry.npmjs.org/{urllib.parse.quote(pkg, safe='@')}/latest"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
|
|
|
|||
|
|
@ -38,8 +38,10 @@ _spec.loader.exec_module(readiness) # type: ignore[union-attr]
|
|||
# The exact row-diff regex the workflow's change-detection bot uses
|
||||
# (.github/workflows/tree-sitter-upgrade-readiness.yml) — byte-identical so a matrix
|
||||
# format change that would silently break change-detection fails here. Group 2 is
|
||||
# ONLY the Status cell ([^|]+? before the final `|$`).
|
||||
_ROW_DIFF_RE = re.compile(r"\| `(tree-sitter-[^`]+)` \|.*\| ([^|]+?) \|$", re.M)
|
||||
# ONLY the Status cell ([^|]+? before the final `|$`). Group 1 is the grammar
|
||||
# name, optionally scoped (`@eagleoutice/tree-sitter-r`).
|
||||
_R_GRAMMAR = "@eagleoutice/tree-sitter-r"
|
||||
_ROW_DIFF_RE = re.compile(r"\| `((?:@[^/`]+/)?tree-sitter-[^`]+)` \|.*\| ([^|]+?) \|$", re.M)
|
||||
# Mirrors the scheduled issue-update summary extraction in
|
||||
# tree-sitter-upgrade-readiness.yml. If the report prose changes again, the issue
|
||||
# comment should not silently degrade to "?/? ready. ? blocker(s)".
|
||||
|
|
@ -216,6 +218,93 @@ class AssertCurrent(TestCase):
|
|||
self.assertIn("outside current runtime range", buf.getvalue())
|
||||
|
||||
|
||||
class AssertCurrentCoversR(TestCase):
|
||||
"""R is an npm grammar under a scoped name; --assert-current must check its
|
||||
installed ABI against the current runtime's range like any other grammar."""
|
||||
|
||||
def test_out_of_range_r_abi_fails_the_gate_and_names_r(self):
|
||||
real = readiness.extract_language_version
|
||||
|
||||
def fake(parser_c):
|
||||
return 99 if "tree-sitter-r" in parser_c.parts else real(parser_c)
|
||||
|
||||
import urllib.request
|
||||
buf = io.StringIO()
|
||||
with mock.patch.object(readiness, "extract_language_version", side_effect=fake), \
|
||||
mock.patch.object(urllib.request, "urlopen", side_effect=AssertionError("network")), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
code = readiness.assert_current()
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn(f"{_R_GRAMMAR}: installed ABI 99 outside current runtime range", buf.getvalue())
|
||||
|
||||
|
||||
class ScopedGrammarName(TestCase):
|
||||
"""The R grammar's package name is scoped (`@eagleoutice/tree-sitter-r`); every
|
||||
place that recognises a grammar by a `tree-sitter-` prefix must cope with it."""
|
||||
|
||||
def test_pinned_versions_include_the_scoped_grammar(self):
|
||||
pkg = json.loads((_REPO_ROOT / "gitnexus" / "package.json").read_text())
|
||||
pinned = readiness.read_pinned_grammar_versions()
|
||||
self.assertEqual(pinned.get(_R_GRAMMAR), pkg["dependencies"][_R_GRAMMAR])
|
||||
# Selection is by grammar basename: the runtime and non-grammar scoped
|
||||
# dependencies are still excluded.
|
||||
self.assertNotIn("tree-sitter", pinned)
|
||||
self.assertNotIn("@ladybugdb/core", pinned)
|
||||
|
||||
def test_r_row_shows_its_pin_and_is_a_blocker_not_a_bump_candidate(self):
|
||||
# Production-faithful R metadata: npm latest 1.1.2 (what package.json pins) with peer ^0.21.0 — blocks 0.25 and is NOT behind latest.
|
||||
def fake_npm(pkg: str):
|
||||
if pkg == _R_GRAMMAR:
|
||||
return {"version": "1.1.2", "peerDependencies": {"tree-sitter": "^0.21.0"}}
|
||||
return {"version": "9.9.9", "peerDependencies": {"tree-sitter": "^0.25.0"}}
|
||||
|
||||
def fake_fetch(url: str, timeout: int = 8):
|
||||
if "parser.c" in url and "alex-pinkus" not in url:
|
||||
return "#define LANGUAGE_VERSION 14\n"
|
||||
if "/commits/" in url:
|
||||
return json.dumps({"sha": "0123456789abcdef"})
|
||||
return None
|
||||
|
||||
buf = io.StringIO()
|
||||
with mock.patch.object(readiness, "npm_view_json", side_effect=fake_npm), \
|
||||
mock.patch.object(readiness, "fetch_text", side_effect=fake_fetch), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
code = readiness.main()
|
||||
report = buf.getvalue()
|
||||
row = next(l for l in report.splitlines() if l.startswith(f"| `{_R_GRAMMAR}` |"))
|
||||
cells = [c.strip() for c in row.strip().strip("|").split("|")]
|
||||
self.assertEqual(cells[1], "1.1.2") # Pinned
|
||||
self.assertEqual(cells[-1], "Peer patch required")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn(f"`{_R_GRAMMAR}@1.1.2` — peer `^0.21.0`", report) # Blocked bucket
|
||||
# On latest already: not a bump candidate (pre-fix the lost pin made it
|
||||
# one). Other grammars are mocked at 9.9.9 and so do appear in that list;
|
||||
# only R's own line is asserted absent.
|
||||
self.assertNotIn(f"- `{_R_GRAMMAR}`: ", report)
|
||||
self.assertNotIn(f"`{_R_GRAMMAR}` — pinned", report)
|
||||
|
||||
def test_row_diff_regex_captures_scoped_and_unscoped_names(self):
|
||||
rows = (
|
||||
"| `@eagleoutice/tree-sitter-r` | ^1.1.2 | 1.1.2 | ^0.21.0 | **No** | 14 | 14 | Peer patch required |\n"
|
||||
"| `tree-sitter-go` | ^0.23.0 | 0.25.0 | ^0.25.0 | Yes | 14 | 14 | Ready |\n"
|
||||
)
|
||||
self.assertEqual(
|
||||
dict(_ROW_DIFF_RE.findall(rows)),
|
||||
{"@eagleoutice/tree-sitter-r": "Peer patch required", "tree-sitter-go": "Ready"},
|
||||
)
|
||||
|
||||
def test_row_diff_regex_mirror_matches_the_workflow(self):
|
||||
# The workflow's change-detection regex is the one that matters; the
|
||||
# mirror above is only worth anything while it is the same expression.
|
||||
workflow = (
|
||||
_REPO_ROOT / ".github" / "workflows" / "tree-sitter-upgrade-readiness.yml"
|
||||
).read_text(encoding="utf-8")
|
||||
match = re.search(r"md\.matchAll\(/(.+)/gm\)", workflow)
|
||||
self.assertIsNotNone(match, "row-diff regex not found in the workflow")
|
||||
# JS regex literals escape `/`; Python patterns do not.
|
||||
self.assertEqual(match.group(1).replace("\\/", "/"), _ROW_DIFF_RE.pattern)
|
||||
|
||||
|
||||
class FetchHelperReadPhaseErrors(TestCase):
|
||||
"""Read-phase transport failures — raised by resp.read() AFTER urlopen has
|
||||
returned (ConnectionResetError, ssl.SSLError, socket.timeout,
|
||||
|
|
@ -272,6 +361,50 @@ class FetchHelperReadPhaseErrors(TestCase):
|
|||
self.assertIsNone(readiness.npm_view_json("tree-sitter-anything"))
|
||||
|
||||
|
||||
class NpmRegistryUrl(TestCase):
|
||||
"""The registry names a scoped package as one path segment with the slash
|
||||
encoded (``@scope%2Fname``). The unencoded form is served today, but the
|
||||
encoded one is the documented spelling, so the script does not rely on that."""
|
||||
|
||||
@staticmethod
|
||||
def _requested_urls(pkg: str) -> list[str]:
|
||||
import urllib.request
|
||||
|
||||
seen: list[str] = []
|
||||
|
||||
class _Resp:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def read(self, *a, **k):
|
||||
return b"{}"
|
||||
|
||||
def _fake_urlopen(req, *a, **k):
|
||||
seen.append(req.full_url)
|
||||
return _Resp()
|
||||
|
||||
with mock.patch.object(readiness, "OFFLINE", False), mock.patch.object(
|
||||
urllib.request, "urlopen", side_effect=_fake_urlopen
|
||||
):
|
||||
readiness.npm_view_json(pkg)
|
||||
return seen
|
||||
|
||||
def test_scoped_package_slash_is_encoded(self):
|
||||
self.assertEqual(
|
||||
self._requested_urls(_R_GRAMMAR),
|
||||
["https://registry.npmjs.org/@eagleoutice%2Ftree-sitter-r/latest"],
|
||||
)
|
||||
|
||||
def test_unscoped_package_is_unchanged(self):
|
||||
self.assertEqual(
|
||||
self._requested_urls("tree-sitter-go"),
|
||||
["https://registry.npmjs.org/tree-sitter-go/latest"],
|
||||
)
|
||||
|
||||
|
||||
class ReportRendering(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
|
|
@ -326,6 +459,17 @@ class ReportRendering(TestCase):
|
|||
self.assertNotIn("Could not check", self.report)
|
||||
self.assertNotIn("fetch failed", self.report)
|
||||
|
||||
def test_r_grammar_is_listed_npm_queried_and_diffable(self):
|
||||
# R is an npm dependency, so it takes the npm path (queried, not vendored)
|
||||
# and its row must be captured by the change-detection regex despite the
|
||||
# scoped package name.
|
||||
self.assertEqual(
|
||||
readiness.GRAMMARS.get(_R_GRAMMAR),
|
||||
("r-lib/tree-sitter-r", "main", "src/parser.c"),
|
||||
)
|
||||
self.assertIn(_R_GRAMMAR, _render_report.last_npm_calls)
|
||||
self.assertIn(_R_GRAMMAR, self.rows)
|
||||
|
||||
def test_held_swift_renders_held_and_keeps_exit_nonzero(self):
|
||||
# Status is the last matrix cell (the row-diff regex captures the whole
|
||||
# tail, not just status, so read the cell directly).
|
||||
|
|
@ -361,17 +505,19 @@ class ReportRendering(TestCase):
|
|||
self.assertIsNotNone(ready)
|
||||
self.assertIsNotNone(blockers)
|
||||
# Counts are derived from _render_report()'s mock corpus (all npm peer
|
||||
# deps mocked permissive): of the 10 npm-installed grammars, 9 render
|
||||
# deps mocked permissive): of the 11 npm-installed grammars, 10 render
|
||||
# Ready and 1 — tree-sitter-cpp — is the intentional pin (#1242), so it is
|
||||
# not counted ready. The remaining workarounds are that intentional pin
|
||||
# and Swift's declaration-recovery hold.
|
||||
self.assertEqual(ready.groups(), ("9", "10"))
|
||||
self.assertEqual(ready.groups(), ("10", "11"))
|
||||
self.assertEqual(blockers.group(1), "2")
|
||||
|
||||
def test_lists_every_pack_time_peer_patch_with_its_drop_condition(self):
|
||||
patched = readiness.read_patched_peers()
|
||||
# Guards the AUDITED_PEERS regex: the bundle script patches 8 manifests today.
|
||||
self.assertGreaterEqual(len(patched), 8)
|
||||
# Guards the AUDITED_PEERS regex: the bundle script patches 9 manifests
|
||||
# today, including the scoped R grammar.
|
||||
self.assertGreaterEqual(len(patched), 9)
|
||||
self.assertIn((_R_GRAMMAR, "1.1.2", "^0.21.0"), patched)
|
||||
self.assertIn(f"## Pack-time peer patches ({len(patched)})", self.report)
|
||||
for name, version, _ in patched:
|
||||
self.assertIn(f"- `{name}@{version}`", self.report)
|
||||
|
|
@ -382,9 +528,11 @@ class ReportRendering(TestCase):
|
|||
self.assertIn("required while intentionally pinned", line("tree-sitter-cpp@0.23.4"))
|
||||
self.assertIn("transitive", line("tree-sitter-javascript@0.23.1"))
|
||||
self.assertIn("transitive", line("tree-sitter-c@0.23.6"))
|
||||
# The scoped R grammar is a direct pin, so its patch is listed like any other.
|
||||
self.assertIn("can drop: bump to `9.9.9`", line(f"{_R_GRAMMAR}@1.1.2"))
|
||||
|
||||
def test_target_runtime_is_the_bundled_pin(self):
|
||||
pkg = json.loads((_REPO_ROOT / "gitnexus" / "package.json").read_text())
|
||||
pkg = json.loads((_REPO_ROOT / "gitnexus" / "package.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(readiness.TARGET_RUNTIME, pkg["dependencies"]["tree-sitter"])
|
||||
self.assertIn(f"Bundled runtime `tree-sitter@{readiness.TARGET_RUNTIME}`", self.report)
|
||||
|
||||
|
|
|
|||
|
|
@ -176,7 +176,8 @@ jobs:
|
|||
// `|$`), so change-detection fires on status transitions, not on
|
||||
// unrelated cell drift (e.g. an upstream-ABI bump). Mirror this in
|
||||
// _ROW_DIFF_RE in test_check_tree_sitter_upgrade_readiness.py.
|
||||
for (const m of md.matchAll(/\| `(tree-sitter-[^`]+)` \|.*\| ([^|]+?) \|$/gm)) {
|
||||
// The grammar name may be scoped (`@eagleoutice/tree-sitter-r`).
|
||||
for (const m of md.matchAll(/\| `((?:@[^\/`]+\/)?tree-sitter-[^`]+)` \|.*\| ([^|]+?) \|$/gm)) {
|
||||
map[m[1]] = m[2].trim();
|
||||
}
|
||||
return map;
|
||||
|
|
|
|||
|
|
@ -6,3 +6,6 @@ c9fdab17f25ebaf332fba6e6ba55ee328f20fe66:README.md:curl-auth-header:348
|
|||
bf4fe7666212c1e2a82aea8309095f63e4b23dcc:gitnexus/tools/mutation/runner/protocol.ts:generic-api-key:11
|
||||
# The previous wording of the comment above quoted that key name and was itself flagged; no credential was present.
|
||||
02989dd688f863248993122879818ffc4cc95e11:.gitleaksignore:generic-api-key:5
|
||||
# Rcpp's public generator-token UUID in an R rename fixture (generated-rcpp-wrapper); no credential was present.
|
||||
ab0a3acf132b3a97f78d39a97988fb5b325ad3fc:gitnexus/test/fixtures/rename-integration/r/generated-rcpp-wrapper/before/R/RcppExports.R:generic-api-key:2
|
||||
ab0a3acf132b3a97f78d39a97988fb5b325ad3fc:gitnexus/test/fixtures/rename-integration/r/generated-rcpp-wrapper/after/R/RcppExports.R:generic-api-key:2
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<!-- version: 1.18.0 -->
|
||||
<!-- Last updated: 2026-10-17 -->
|
||||
<!-- version: 1.20.0 -->
|
||||
<!-- Last updated: 2026-10-25 -->
|
||||
|
||||
Last reviewed: 2026-10-17
|
||||
|
||||
|
|
@ -41,6 +41,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
|
|||
|
||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**
|
||||
- **Objective-C provider work:** read **[docs/languages/objective-c-provider.md](docs/languages/objective-c-provider.md)** before changing Objective-C parsing or resolution.
|
||||
- **R provider work:** read **[docs/languages/r-provider.md](docs/languages/r-provider.md)** and **[docs/languages/r-ingestion-gaps.md](docs/languages/r-ingestion-gaps.md)** before changing R parsing or resolution.
|
||||
- **Call & inheritance resolution (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. All languages resolve calls and inheritance through the scope-resolution pipeline (`Registry.lookup`, `preEmitInheritanceEdges`, `emitHeritageEdges`, `buildMro` → `MethodDispatchIndex`). **Shared code in `gitnexus/src/core/ingestion/` must not name languages** — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks. A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. (The legacy call-resolution DAG + `@heritage` capture path were removed in RING4-1 #942.)
|
||||
- **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated.
|
||||
- **GitNexus:** standard skills in `.claude/skills/gitnexus-*/`; MCP rules in `gitnexus:start` block below.
|
||||
|
|
@ -92,6 +93,8 @@ mirror. `gitnexus/test/unit/shipped-skills-sync.test.ts` guards the copies. Toke
|
|||
|
||||
| Date | Version | Change |
|
||||
| ---------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-10-25 | 1.20.0 | Added the R ingestion gaps inventory (`docs/languages/r-ingestion-gaps.md`) next to the R provider guide as a required reference before changing R parsing or resolution. |
|
||||
| 2026-10-17 | 1.19.0 | Added the R provider guide as the required reference before changing R parsing or resolution. |
|
||||
| 2026-10-17 | 1.18.0 | Require the reviewed preview's single-use `plan_token` when applying semantic rename (#2973). |
|
||||
| 2026-09-24 | 1.17.0 | Clones with the same `origin` URL now share a store automatically; `--no-share` records a lasting opt-out (#3352). |
|
||||
| 2026-09-24 | 1.16.0 | Documented the shared worktree index store (`<GITNEXUS_HOME>/stores/`, `analyze --share-with`, `GITNEXUS_SHARED_STORE=off`) in the storage notes (#3352). |
|
||||
|
|
|
|||
|
|
@ -372,6 +372,8 @@ A provider can explicitly restrict its edge mode. COBOL currently uses
|
|||
and scope resolution contributes procedure-pointer calls. Registration alone is
|
||||
not a claim that every language feature uses identical resolution rules.
|
||||
|
||||
Shared code does not name languages. When a language needs an extension point that does not exist yet, it is added as an optional `LanguageProvider` hook that no other language has to implement (R's `postParse` and `resolveMemberOwnerNode`; see the hook table below).
|
||||
|
||||
### Pipeline stages
|
||||
|
||||
```
|
||||
|
|
@ -568,7 +570,7 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
|
|||
|
||||
## Language-agnostic graph feeding
|
||||
|
||||
18 languages → single unified graph. Four abstraction layers:
|
||||
19 languages → single unified graph. Four abstraction layers:
|
||||
|
||||
```
|
||||
Unified Graph Schema (44 node types, 21 relationship types)
|
||||
|
|
@ -594,8 +596,10 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields
|
|||
| `mroStrategy` | `first-wins` / `c3` / `none` |
|
||||
| `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) |
|
||||
| `definitionPropertiesExtractor` | Optional language-owned hook for structured, clone-safe definition metadata. Shared ingestion persists these properties opaquely; the owning provider supplies the extraction semantics. |
|
||||
| `postParse` | Optional. Whole-graph pass a language runs once after every chunk is merged and before scope resolution (deferred owner resolution, manifest-driven export refinement). Called once per analyze, awaited, only when the language has parsed files; a throw aborts the parse phase. Incremental analyse writes back only changed files, so a mutation of an unchanged file's nodes is not persisted; a hook whose result depends on manifests that are not parsed sources needs `--force` after such an edit. R: deferred `setMethod`/R6 owners + NAMESPACE exports. |
|
||||
| `resolveMemberOwnerNode` | Optional. For languages whose members are owned by a call/assignment rather than an enclosing container node (R: R6/RefClass): returns the node the method and field extractors read members from, or null. Consulted by the enclosing-owner slot after the container walk and `resolveFileTypeOwner` (same slot; Zig file-owner is the precedent). Not an S4 mechanism: R's `setMethod(...)` returns null (its owner is a string-literal class name, not a node); S4 ownership arrives only through the `ownerNameHint` → `postParse` path. |
|
||||
|
||||
18 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
|
||||
19 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
|
||||
|
||||
### Unified capture tags
|
||||
|
||||
|
|
@ -794,4 +798,5 @@ Node IDs use arity suffix (`#<paramCount>`): `Method:file:Class.method#1` vs `#2
|
|||
- [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents
|
||||
- [TESTING.md](TESTING.md) — how to run tests
|
||||
- [docs/languages/objective-c-provider.md](docs/languages/objective-c-provider.md) — Objective-C provider behavior and limits
|
||||
- [docs/languages/r-provider.md](docs/languages/r-provider.md) — R provider behavior and limits
|
||||
- `AGENTS.md` / `CLAUDE.md` — agent workflows and tool usage
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ The CLI pins and bundles the native `tree-sitter` runtime and its npm grammars.
|
|||
Several compatible grammars still declare older runtime peer ranges. Root npm
|
||||
`overrides` do not propagate to consumers, and older npm versions validate bundled
|
||||
peers against their original ranges even when an override is configured. The
|
||||
lockfile therefore pins the same eight audited peers to exactly `0.25.1`.
|
||||
lockfile therefore pins the same nine audited peers to exactly `0.25.1`.
|
||||
`gitnexus/scripts/prepare-tree-sitter-bundle.cjs` runs during `postinstall`
|
||||
and `prepack` to keep installed dependency manifests consistent with that lockfile
|
||||
and the published bundle. It leaves grammar sources unchanged and replaces the
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -117,6 +117,18 @@ Ruby, Rust, Swift, TypeScript, Vue and Zig have registered providers. Support
|
|||
varies by construct; see the [language inventories](docs/languages/README.md)
|
||||
for measured behavior, exclusions and remaining gaps.
|
||||
|
||||
R (experimental) also has a registered provider (`.r` / `.R`, parsed with
|
||||
`@eagleoutice/tree-sitter-r`): functions, S4, R6 and RefClass classes with their
|
||||
methods and fields, `library()` / `require()` / `source()` imports to local
|
||||
packages and files, `obj$method()` and `pkg::fn()` calls, R6 `inherit=` / S4
|
||||
`contains=` heritage and NAMESPACE-driven export status. Calls through function
|
||||
values, `do.call()`, infix operators and S3 dispatch are not linked; `.Rmd`,
|
||||
`box::use()` and external (CRAN) packages are not supported. With `--pdg` it
|
||||
builds a control-flow graph (control dependence only, no reaching definitions),
|
||||
and `rename` handles plain functions while refusing classes, members, S3 methods,
|
||||
exported package functions and names spelled as strings. See
|
||||
[docs/languages/r-provider.md](docs/languages/r-provider.md) for coverage and limits.
|
||||
|
||||
## Documentation
|
||||
|
||||
<a id="cli-reference"></a>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Language Ingestion Gap Inventories
|
||||
|
||||
These inventories are the starting point for language-specific plans. There is one
|
||||
`<language>-ingestion-gaps.md` file for each of the 18 languages in
|
||||
`<language>-ingestion-gaps.md` file for each of the 19 languages in
|
||||
[SCOPE_RESOLVERS](../../gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts).
|
||||
Names match provider directories; `jvm` is shared infrastructure, not another language.
|
||||
|
||||
|
|
@ -29,6 +29,7 @@ receiver examples do not establish whole-language, CFG/PDG or rename completenes
|
|||
| [Objective-C](objective-c-ingestion-gaps.md) | Add receiver measurements without weakening dynamic-dispatch boundaries. |
|
||||
| [PHP](php-ingestion-gaps.md) | Retain PHPDoc container types and establish namespace owners. |
|
||||
| [Python](python-ingestion-gaps.md) | Audit constructor-field and module ownership coverage. |
|
||||
| [R](r-ingestion-gaps.md) | Type receivers beyond same-function constructors; revisit R-02 and R-12. |
|
||||
| [Ruby](ruby-ingestion-gaps.md) | Isolate YARD parameter bindings before adding recall. |
|
||||
| [Rust](rust-ingestion-gaps.md) | Measure conservative smart-pointer limits before widening them. |
|
||||
| [Swift](swift-ingestion-gaps.md) | Type indexed receivers, then revalidate historical reports. |
|
||||
|
|
@ -55,8 +56,8 @@ The receiver harness has separate states:
|
|||
- **N/A:** the harness declares this spelling inapplicable to its measurement. Read its reason before interpreting language support.
|
||||
- **GRAMMAR-UNAVAILABLE:** the parser did not load. No behavior was measured; this is not a product-gap verdict.
|
||||
|
||||
An all-N/A row (C or COBOL) does not mean complete ingestion. Vue now has an executable SFC row. Zig and
|
||||
Objective-C have dedicated tests but no row in this shape arm. A fully resolving
|
||||
An all-N/A row (C or COBOL) does not mean complete ingestion. Vue now has an executable SFC row. Zig,
|
||||
Objective-C and R have dedicated tests but no row in this shape arm (R: the receiver corpus linked from its inventory). A fully resolving
|
||||
row also proves only its fixed examples. The baseline's count arm includes
|
||||
external and unknown receivers, so total drops are not an actionable bug count.
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ pin that distinction.
|
|||
## Providers with expanded lexical support
|
||||
|
||||
These seven entries are part of the
|
||||
[18-provider registry](../../gitnexus/src/core/ingestion/languages/index.ts).
|
||||
[19-provider registry](../../gitnexus/src/core/ingestion/languages/index.ts).
|
||||
The evidence links cover exact targets and negative cases, not just edge counts.
|
||||
|
||||
| Provider | Supported behavior | Source and regression evidence |
|
||||
|
|
@ -225,7 +225,7 @@ activate before their declarations.
|
|||
|
||||
## Retained provider behavior
|
||||
|
||||
The other eleven providers retain their existing import/loading models. This
|
||||
The other twelve providers retain their existing import/loading models. This
|
||||
work does not reinterpret every dependency construct as a local value import.
|
||||
|
||||
| Provider | Retained model and boundary | Evidence |
|
||||
|
|
@ -241,6 +241,7 @@ work does not reinterpret every dependency construct as a local value import.
|
|||
| C# | Namespace/file `using` directives remain imports; resource `using` statements are not import directives. | [Directive query](../../gitnexus/src/core/ingestion/languages/csharp/query.ts), [import tests](../../gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts) |
|
||||
| Swift | Imports and target/module visibility retain provider ownership. | [Ownership](../../gitnexus/src/core/ingestion/languages/swift/simple-hooks.ts), [import decomposition](../../gitnexus/test/unit/scope-resolution/swift/import-decomposer.test.ts), [target siblings](../../gitnexus/test/unit/scope-resolution/swift/target-siblings.test.ts) |
|
||||
| Dart | Library imports retain existing package identity and visibility rules. | [Ownership](../../gitnexus/src/core/ingestion/languages/dart/simple-hooks.ts), [library/package regressions](../../gitnexus/test/integration/resolvers/dart.test.ts) |
|
||||
| R | `library()`/`require()` of a local package attach its exported names (every top-level name when it has no NAMESPACE; wildcard loading, filtered by NAMESPACE, and not told apart inside a function); `source()` binds no name; NAMESPACE `importFrom()` names bind to definitions in local packages; `pkg::fn()` is decided by its qualifier. No function-local loading model. | [Provider guide](r-provider.md), [Import resolver](../../gitnexus/src/core/ingestion/languages/r/import-target.ts), [Ownership](../../gitnexus/src/core/ingestion/languages/r/simple-hooks.ts), [library masking](../../gitnexus/test/integration/resolvers/r-library-masking.test.ts), [importFrom binding](../../gitnexus/test/unit/scope-resolution/r/r-namespace-imports.test.ts) |
|
||||
|
||||
## Taint provenance
|
||||
|
||||
|
|
|
|||
180
docs/languages/r-ingestion-gaps.md
Normal file
180
docs/languages/r-ingestion-gaps.md
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
# R Ingestion Gaps
|
||||
|
||||
Audited on 2026-10-24 with freshly built production sources at `a9b112ae5`, and
|
||||
re-checked on 2026-10-25 against the audit fixes (the rename guards landed at `ab0a3acf1`; the
|
||||
closure commit after them changed only tests, docs and behaviour-neutral code), with the
|
||||
accompanying receiver corpus and regression tests. See the [measurement audit](receiver-resolution-audit.md)
|
||||
for the benchmark commands, target checks, exclusions and test limits. R has no row in
|
||||
the receiver shape arm of that benchmark (no `bench/receiver-resolution` entry was added, as for
|
||||
Zig and Objective-C), so every state below comes from the dedicated corpus and
|
||||
regression tests named in each item, not from `baseline.json`. The benchmark's count arm
|
||||
still records R call drops (21 `receiver-unresolved` drops attributed to `.R` files in its baseline),
|
||||
but that is a count, not a state per shape. Broader ingestion is not
|
||||
exhaustively assessed. The supported behaviour is described in the [R provider guide](r-provider.md).
|
||||
|
||||
## Receiver shape snapshot
|
||||
|
||||
States below come from the [receiver corpus test](../../gitnexus/test/integration/resolvers/r-receiver-corpus.test.ts),
|
||||
a table of call shapes, each with a caller, an exact expected target (or no edge) and the
|
||||
outcome recorded at the call. Its fixtures are `r-receiver-corpus/` (no `DESCRIPTION`, so
|
||||
the unique-name fallback is live; R6 `User` and `Address` both define `save`, and free
|
||||
functions `save`, `greet`, `climb_up` and `nothere` are the decoys) and
|
||||
`r-receiver-corpus-qualified/` (a package that re-exports another package's `fmt`, and a
|
||||
caller-local `fmt` as the decoy). A row that resolves asserts the exact node id (for
|
||||
example `Method:R/models.R:User.save#0`) and that no other edge leaves the caller; a row without
|
||||
an edge asserts that the caller has no edge at all (so also none to a decoy) and
|
||||
names the positive control that resolves nearby. Two repository-wide checks add that no edge
|
||||
reaches a decoy and that the table lists every edge and every dropped site.
|
||||
|
||||
In the harness vocabulary of the [README](README.md#reading-the-evidence):
|
||||
|
||||
- **RESOLVES** (exact target, no decoy edge): a receiver typed by a constructor whose every binding in its frame agrees
|
||||
(`u <- User$new(); u$save()` in a function, or at module level of the same file for that file's functions), including when `Address` shares the method
|
||||
name `save` with `User` and with a free function; typed siblings in either order; `self$m()`
|
||||
inside an R6 method for an own method and for one inherited from a parent (the corpus has the
|
||||
parent in the same file; `r-self-binding` covers another file), also after a write through
|
||||
`self` (`self$visits <- 1`), and for a parent's public method when the class has a private
|
||||
one of that name (`self` is the public interface); `pkg::fmt()` for a package that defines the function, and for a package that re-exports it from a
|
||||
second package (one hop), each beside a caller-local `fmt`.
|
||||
- **VISIBLE-GAP, a recorded drop** (no edge, with an outcome that counts toward the `impact`
|
||||
lower bound): `self$m()` where neither the class nor a parent defines `m` as a public member,
|
||||
a private method included (`self$secret()`; `member-lookup-ambiguous`, the shared pass's
|
||||
closest label), `self$m()` in a free function
|
||||
(`receiver-owned-but-unbound`), a receiver whose name has a disagreeing binding (a second class, an untyped
|
||||
value, a loop variable, a formal, `rm(x)`, a `<<-` elsewhere in the file: `receiver-unresolved`), a private member
|
||||
through a typed receiver (`u$secret()`, `member-lookup-ambiguous`), and a chain with a variable at its base: `a$b$save()`,
|
||||
`User$new()$save()`, `self$repo$save()` (`receiver-unresolved`). These are
|
||||
deliberately unresolved: a decoy edge would be wrong, and the right answer needs types that
|
||||
R6 does not declare (R-21).
|
||||
- **INVISIBLE-GAP** (no edge and no recorded outcome, so drop counts cannot reveal it): an
|
||||
untyped local (`u <- make_user(); u$save()`), an untyped parameter, an untyped sibling of a
|
||||
typed function that reuses the parameter name (the typed function does not lend its type, in
|
||||
either order), a roxygen-typed parameter (`#' @param repo User`, `User` a class of the same
|
||||
file), the S4 spelling `u <- new("User")`, `super$greet()`, `private$secret()`, a RefClass
|
||||
method calling a sibling by bare name, `u@save()` (not a member call), calls of an S4 generic, generator object or `new("Pt")`, and
|
||||
chains whose base is not a variable (`get_user()$save()`, `pkg::User$new()$save()`,
|
||||
`x[[1]]$f$save()`). The untyped controls are intended to stay unresolved; the rest are
|
||||
shapes a later slice could resolve (R-18 to R-21, R-23) or that mint no site today (R-06).
|
||||
A RefClass `.self$m()` call is a recorded drop (`receiver-unresolved`), and a bare call
|
||||
of a name that is both a RefClass method and a free function is ambiguous: no edge to either.
|
||||
- Qualified sites that are dropped (`ext::fmt()` for a certainly external package,
|
||||
`invisiblepkg::fmt()` when package discovery was truncated) get no edge, never the
|
||||
caller-local `fmt`, and record no outcome (R-04).
|
||||
|
||||
Of 49 corpus cells, 13 resolve, 17 are deliberately unresolved and 19 are `deferred` rows
|
||||
(absence pinned so that a later feature shows up as a deliberate change); every row
|
||||
without an edge names a resolving control. Nothing in the corpus reaches a decoy.
|
||||
|
||||
## Incorrect results in the supported slice
|
||||
|
||||
Each item is a wrong, lost or over-broad result inside what the provider claims to
|
||||
support, with the test that pins today's behaviour. Pinned does not mean accepted:
|
||||
a change in these results should flip the named test deliberately.
|
||||
|
||||
| ID | Evidence | Gap or boundary | Pinned by | Next planning step and acceptance |
|
||||
| ---- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| R-01 | Negative control / deliberate limit | An assignment of a function-capable value to a name (`helper <- identity`) makes that name a lexical name of the whole function body. The rule is flow-insensitive: a call written before the assignment, or under an `if` that may skip it, loses its edge to the outer `helper`. Lost result, chosen over a wrong edge. | `r-assignment-locals.test.ts` (d4, d5); `r-lexical-names.test.ts` (flow-insensitive); rename fixtures `assign-local-call-before`, `assign-local-conditional` | Order the binding against the call with the function's control flow (the R CFG exists) before reading it as a lexical name. Keep the rename guard in step: it reads the same assignments through one shared predicate. |
|
||||
| R-02 | Measured gap | Bindings the provider does not read still resolve to the outer function (wrong target): `assign("helper", ...)`, `delayedAssign()`, `list2env()` and the values of `with(list(helper = ...), helper(x))`. A top-level assignment in the caller's own file (`helper <- identity`, `helper <- 5`) leaves the unique-name guess (0.5) on another file's `helper`. `local({ helper <- ... })` inside a function is read as part of the function body, so the call is unresolved, not wrong; the braces of `local()`, `with()` and `within()` are also block scopes, so a function defined inside them is visible there only (`r-block-scopes.test.ts`). | `r-assignment-locals.test.ts` (d19 to d23, and `r-assignment-locals-module` d10, d14); `r-lexical-names.test.ts` (does not model assign(), delayedAssign(), list2env() or with()) | Model the string-named forms only where the name is a literal; otherwise leave the call unresolved. Module-level: read a top-level assignment in the caller's file as a name claim for calls in that file. |
|
||||
| R-03 | Recorded gap | `source("../exp/R/a.R")` followed by `hidden()` gets no edge when `hidden` is a private name of a package: R puts a sourced file's names in the global environment, but the name guess is vetoed for callers outside the package tree and `source()` binds no names. | `r-qualified-exports.test.ts` (R-03 source() of a private package file) | Let a caller with a `source()` import whose target is the candidate's file reach it through the guess. Test with a script that sources a package file, and keep the veto for callers that do not. |
|
||||
| R-04 | Recorded gap | A qualified site dropped for a certainly external package, an unknown-locality qualifier, a duplicate definition or a name the package does not export records no resolution outcome: the drop runs before outcomes exist and no shared reason fits. Impact lower bounds miss these calls. | `r-receiver-corpus.test.ts` (qualified describes: no outcome); `r-qualified-exports.test.ts` (R-04 dropped qualified site records no outcome, in `main` and `decoy`) | Add a shared suppression reason for a qualified site with no precise target, then record it from `populateRQualifiedCalls`. That is a shared-code change. |
|
||||
| R-05 | Recorded gap | In a replacement chain every call is recorded as its own setter, so the getter half R also evaluates (`bar(x)` in `foo(bar(x)) <- 1`) has no edge (a missing edge, not a wrong one). A setter defined by string (`"foo<-" <- function(...)`), `setReplaceMethod()` and `setGeneric("foo<-")` define no node, so the call has no edge, not even to the getter. | `r-replacement-calls.test.ts` (foo(bar(x)) <- 1; setters with no definition node stay edge-free) | Emit the getter site as well as the setter site once two sites can share an anchor, and give string-defined setters a definition node. Keep the "never the getter for a setter call" control. |
|
||||
| R-06 | Measured gap | A chain whose base is not a plain variable (`get_user()$save()`, `pkg::Cls$new()$m()`, `x[[1]]$f$m()`, `(x)$f$save()`, or a chain the codec cannot encode) mints no site: no edge and no outcome, INVISIBLE to drop counts. | `r-chained-receivers.test.ts` (no variable base mints no site); `r-receiver-chain.test.ts` (no variable base; longer than the codec accepts; unencodable name); `r-receiver-corpus.test.ts` (`get_user()$save()`, `pkg::User$new()$save()`, `x[[1]]$f$save()`) | Decide what the shared emitter does with a call-node receiver and no chain, then mint an unresolved-receiver site for these bases. Assert the drop outcome and keep the free-function and class-method decoys unreached. |
|
||||
| R-07 | Measured gap | The `library()` hook has no importer. A `library(ownpkg)` from inside the package (`tests/` included) attaches only the exports, so a private name loses its 0.85 `import-resolved` edge and falls to the 0.5 guess (the guess is allowed for callers inside the package tree). | `r-qualified-exports.test.ts` (exp/tests: library(exp); hidden()) | Give the wildcard-expansion hook the importing file, then exempt callers inside the package. Needs a shared signature change. |
|
||||
| R-08 | Negative control / deliberate limit | A `pkg::name()` whose qualifier locality is unknown (package discovery was cut short, or no package configuration was recorded) is dropped and loses the old 0.5 guess. A bare call of a name that is not proven private (unreadable NAMESPACE, a cut-short `exportPattern()`) keeps the 0.5 guess. | `r-qualified-calls.test.ts` (discovery truncated); `r-qualified-visibility.test.ts` (drops every qualified site when locality cannot be decided); `r-receiver-corpus.test.ts` (`invisiblepkg::fmt()`); `r-qualified-exports.test.ts` (unr: bare unr_cond() keeps the 0.5 guess) | Raise the depth and directory bounds of package discovery or make the truncation observable; decide whether an unreadable NAMESPACE should veto the guess. |
|
||||
| R-09 | Recorded boundary | Chained receivers get their unresolved outcome from the shared codec's `truncated` receiver-chain marker, which the shared fold's own comment calls defence in depth that no producer mints. R relies on it, against that producer contract. The marker is not load-bearing end to end today: a mutant that mints `truncated: false` fails only the unit test, and the outcomes and edges of the `r-chained-receivers` fixture do not change. | `r-receiver-chain.test.ts` (the shared fold refuses a truncated chain; returns undefined before reading any index; R mints the marker) | Replace the marker with a supported producer API, or have the shared comment name R's use. Either way the unit test must keep failing if upstream starts folding or refusing a truncated chain, and an end-to-end cell should be added once the marker decides an outcome. |
|
||||
| R-10 | Recorded boundary | Package-tree membership uses the longest package directory that prefixes the file. For a root-level package (a `DESCRIPTION` at the repository root) every file outside a nested package counts as inside, so the private-name exemption (`pkg::name`, the name guess) is broad there. The same exemption is over-broad for `inst/` and `vignettes/`: they run against the installed package and see its exports only, so a vignette that calls a private name keeps a guessed edge (audit case v1; not measured as a restriction). | `r-qualified-exports.test.ts` (root: a script outside every nested package; a nested package is outside); `r-qualified-visibility.test.ts` (rEnclosingPackageDir claims every file of a root-level package) | Restrict the root package's tree to `R/` and `tests/`; give `inst/` and `vignettes/` exports only. Keep the nested-package control. |
|
||||
| R-11 | Recorded boundary | A `pkg::name()` whose package defines no `name` resolves through its `importFrom(origin, name)` plus `export(name)` for one hop only, and only for a local origin that defines the name once. A re-export of a re-export, or an external or multiply-defining origin, gives no edge; only `importFrom()` entries are read. | `r-qualified-exports.test.ts` (decoy: l_reexport, l_ext_origin, l_dup_origin, R-11 two hops); `r-receiver-corpus.test.ts` (`pkga::fmt()`) | Follow the chain with a visited set and a depth bound; keep external and duplicate origins unresolved. |
|
||||
| R-12 | Measured gap | The `library()` export filter works on names. A class reached as a receiver type through the `IMPORTS` edge a `library(pkg)` creates is not filtered: `rs <- ResultSet$new(); rs$count()` still resolves to a private class of `pkg` at 0.85. | `r-qualified-exports.test.ts` (R-12 library(exp); ResultSet$new() then rs$count()) | Apply the visibility rule when a receiver type is looked up through the import edge. Test a private class and an exported class side by side. |
|
||||
| R-13 | Recorded boundary | In an R6 class whose name contains a dot (`My.Class`), a member call through `self` or a typed receiver gets no receiver lookup: no edge and a `receiver-unresolved` outcome, for an own method, an inherited one and a missing one alike. The shared receiver pass reads a dotted type name as a qualified one and cannot find the owner's members, and the earlier route bound a same-named free function (0.49) or the class's own method (1.0). Unresolved, not wrong. | `r-self-binding.test.ts` (c12 dotted: three rows, no edge, `receiver-unresolved`); `r-typed-receiver-members.test.ts` (c_own, c_inh, c_missing) | Make the shared receiver pass look up a dotted class name's members and parents. Flip the three rows to the own method, the parent's method and unresolved. |
|
||||
| R-14 | Recorded boundary | A free function in a file that does not bind `self` is treated as owning `self`, so a global `self <- Foo$new()` defined in another file is not followed: `self$m()` has no edge and the outcome is `receiver-owned-but-unbound`. A file that binds `self` itself is exempt. | `r-self-binding.test.ts` (c13 binds self itself; c13b a self bound in another file is not followed) | Resolve a global `self` across files, or stop treating a free function as an owner when any file binds `self`. Keep c9 (no object) unresolved. |
|
||||
| R-15 | Negative control / deliberate limit | A `self$m()` in a class whose parent is named through a package (`inherit = basepkg::BasePkgClass`) cannot find the member: no edge and the outcome `member-lookup-ambiguous`, the closest label the shared pass offers. The class gets no `EXTENDS` edge either. | `r-call-shapes.test.ts` (creates no EXTENDS edge for inherit = pkg::Base; records member-lookup-ambiguous) | Add a shared "member not found" reason so the outcome names the real cause, then link `pkg::Base` when the package is indexed. |
|
||||
| R-16 | Recorded gap | `rename` refuses (`incomplete_semantics`) a function while a same-named parameter is read in a body (`function(x, helper) x + helper`) or a `for` variable of that name is called in a loop (`for (helper in xs) helper(1)`): no edge covers the occurrence. Both refuse; neither leaves a call behind. A parameter that is only declared, or called (then `ambiguous_reference`), is as described in the guide. | rename fixtures `parameter-body-read`, `for-variable-call`, `parameter-read-only`, `parameter-no-default-call` | Cover a parameter read and a `for` variable as bindings in the scope graph, then let the rename provider accept them. Keep the call-under-parameter refusal. |
|
||||
|
||||
## Deferred work
|
||||
|
||||
These are not incorrect results: the provider does not model them, and each has a recorded
|
||||
absence (a corpus row or a named test) so that building one shows up as a deliberate
|
||||
change.
|
||||
|
||||
| ID | Evidence | Gap or boundary | Pinned by | Next planning step and acceptance |
|
||||
| ---- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| R-17 | Recorded boundary | S3 dispatch: `UseMethod()` and `NextMethod()` create no edge; S3 methods are indexed as plain functions with dotted names, and a bare call never binds to a dotted definition. | `r-dotted-s3-collision.test.ts`; `r-call-shapes.test.ts` (does not link UseMethod() or NextMethod() to a method) | Resolve `UseMethod("generic")` to the methods registered for the class of the first argument, once that class is known. Needs receiver types first (R-20). |
|
||||
| R-18 | Recorded boundary | S4 generics and dispatch: a call to a generic (`area(x)` after `setGeneric`), a `setClass` generator (`Person(...)`), `new("Class")` and `Class$new()` get no edge from the class itself. The S4 `new("Class")` constructor binds the per-file type environment only, not the scope, so it types no receiver. The guess can be wrong across load units: with `library(geo)`, a call `area(c)` of geo's generic, or `Circle(r = 1)` of its generator `Circle <- setClass("Circle", ...)`, binds a same-named plain function of a script at 0.5 (audit case g2, pinned). Two cases are right and kept: the bare-`setClass` plus separate constructor-function idiom (`setClass("Circle", ...)` and `Circle <- function(r) new(...)`) keeps its edge to the constructor, and in one package the later-collated `Circle <- function` wins over an earlier `Circle <- setClass(...)`, so its 0.5 edge is correct. That is why no generator-specific veto ships. The query change that would capture generics is deferred: it moves pinned corpus rows. | `r-receiver-corpus.test.ts` (S4 generic, generator and `new("Pt")` calls; `u <- new("User"); u$save()`); `type-env-r.test.ts` (the type-environment binding); `r-s4-generator.test.ts` (generator cells: the wrong 0.5 limits, the constructor idiom, the control cells and the collate-order cell) | Bind `new("Class")` in the scope as `Class$new()` is, then resolve generics through method signatures. Assert signature owners and keep the unbound spelling unresolved until then. |
|
||||
| R-19 | Recorded boundary | RefClass methods that call a sibling or inherited method by bare name (`log_it(x)`) or through `.self$log_it(x)` get no edge. `self` in a RefClass method is owned but untyped (`receiver-owned-but-unbound`). | `r-self-binding.test.ts` (c10 an unassigned R6Class and a RefClass method); `r-receiver-corpus.test.ts` (RefClass bare-name and `.self$` rows) | Bind `.self` and bare method names to the enclosing RefClass and its `contains=` chain, with the same owner and decoy controls as c1 to c9. |
|
||||
| R-20 | Coverage gap / audit | Receiver propagation: `x <- Cls$new()` (R6, RefClass) types a receiver only when every binding of `x` in its frame agrees with it (the T1 rule): a second class, an untyped value, a loop variable, a formal, `rm(x)`, `assign("x", ...)` or `class(x) <- v` leaves it untyped, and the call is a recorded `receiver-unresolved` drop. This removes wrong edges and also some right ones: the lost edges are two constructors of different classes with the call after the second, where R calls the last one (`a2` in a function, `n1` for two module-level bindings) and `x <- x$validate()` (`e4`). A local from any other call, a parameter, a roxygen `@param` type (the type extractor feeds the type environment only; no edge reads it) and a typed sibling's parameter name leave the receiver untyped, and an untyped receiver records no outcome (INVISIBLE). | `r-receiver-agreement.test.ts` (the lost-edge cells a2, n1 and e4, the module-level cells, the `with()` and cross-file limits); `r-receiver-corpus.test.ts` (untyped local, untyped parameter, roxygen parameter, both sibling orders, the eleven agreement rows); `r-roxygen-param.test.ts` | Promote restricted roxygen `@param` types through `interpretTypeBinding`, not the type environment, and record a receiver-unresolved outcome for an untyped receiver. Keep the sibling cells: no type may leak between functions. |
|
||||
| R-21 | Coverage gap / audit | Field and return types: R6 fields and method results carry no declared type, so `Cls$new()$m()`, `a$b$m()`, `self$repo$save()` and typed-base chains such as `p$addr$save()` (a field initialised with `Address$new()`) or `p$me()$save()` get no edge. They record `receiver-unresolved` instead. | `r-chained-receivers.test.ts`; `r-receiver-corpus.test.ts` (`a$b$save()`, `User$new()$save()`, `self$repo$save()`) | Type fields from their initialisers and methods from `invisible(self)` or a constructor return, then walk the chain. Assert the exact target and keep the untyped base unresolved. |
|
||||
| R-22 | Recorded gap | Reaching definitions and taint: R has no def/use harvest, so the PDG layer reports `no-facts`, emits no `REACHING_DEF` edge and records no taint call site. An empty answer from `pdg_query` or `explain` for R means not analysed. | `unit/cfg/r-visitor.test.ts` and `integration/cfg/r-cfg-join.test.ts` (reaching definitions report `no-facts` for R) | Harvest definitions and uses per function in the CFG visitor, then enable reaching definitions for R with the shared harness. |
|
||||
| R-23 | Negative control / deliberate limit | `super$m()` and `private$m()` create no edge: nothing binds `super` or `private` to a class. Neither reaches the parent's method or a free function of that name. | `r-self-binding.test.ts` (c6, c7); `r-receiver-corpus.test.ts` (`super$greet()`, `private$secret()`) | Bind `super` to the parent class and `private` to the enclosing class. Test an own and an inherited private method and keep free-function decoys unreached. |
|
||||
| R-24 | Recorded boundary | Callable values: a function passed as a value (`lapply(x, f)`, `Map`, `pkg::f` as an argument), `do.call("f", ...)`, `match.fun()`, `get()` and registry dispatch (`handlers[["a"]]()`) create no `CALLS` edge. | `r-call-shapes.test.ts` (a function used other than as a callee creates no CALLS edge) | Classify R in the cross-language callable-value analysis and decide which value references count as calls. Keep the string forms unresolved. |
|
||||
| R-25 | Recorded boundary (outside R) | The shared free-call pass now resolves a qualified site before it applies a lexical name claim. Rust and PHP implement the same hook, publish no such claim for a `let`, a parameter or a `use function`, and measure the same before and after: the reorder is a no-op for them. A Rust `let dispatch = ..; tools::dispatch()` has no edge to `tools.rs` before or after, a gap of another pass. | `free-call-qualified-over-name-claim.test.ts` (fake hook); `rust-qualified-over-local.test.ts`; `php-qualified-over-local.test.ts` | Not an R item. Plan it under the Rust inventory: resolve a module-qualified call beside a local binding of the same name. |
|
||||
|
||||
## Further boundaries from the audit fixes
|
||||
|
||||
These rows were found while fixing the findings of the audit above. Rows R-26 to R-31 and R-33 were
|
||||
measured in the audit and are not pinned by a repo test; the others are pinned by the named tests.
|
||||
|
||||
| ID | Evidence | Gap or boundary | Pinned by | To close |
|
||||
| -- | -- | -- | -- | -- |
|
||||
| R-26 | Measured gap (audit) | A `library(external)` after `library(local)` does not mask the local package's names: the external package is dropped without a trace, so a name both define still binds to the local package at 0.85, where R masks it with the package attached last. | none (measured in the audit; no repo test) | Record external `library()` calls as masking bindings that resolve to nothing; keep the last-attached rule. |
|
||||
| R-27 | Measured gap (audit) | A name defined in a `source()`d file does not mask a name from `library()`: `source()` binds no name, so the wildcard import wins, where R's global environment masks the attached package. | none (measured in the audit; no repo test) | Bind the sourced file's names as named imports; plan it with R-03. |
|
||||
| R-28 | Measured gap (audit) | Inheritance across class systems: an R6 `inherit =` naming an S4 class, or an S4 `contains =` naming an R6 class, still gets an `EXTENDS` edge, although the systems do not inherit from each other. The generic heritage query has no veto. | none (measured in the audit; no repo test) | Add an inheritance-base veto hook to the shared heritage pass. |
|
||||
| R-29 | Measured gap (audit) | A class defined inside a function is reachable from other files at 0.85 (`import-resolved`) through the shared unique-name class fallback, although no other file can see it. | none (measured in the audit; no repo test) | Give the typed-receiver class lookup a visibility filter, or let a language opt out of the unique-name class fallback. |
|
||||
| R-30 | Measured gap (audit) | `Collate:` in `DESCRIPTION` is never read: where two files of a package define the same name, the same-file preference or the ambiguity rule decides, not the load order R uses. | none (measured in the audit; no repo test) | Read `Collate:` and rank same-name definitions by load order. |
|
||||
| R-31 | Measured gap (audit) | A RefClass generator assigned under another name (`Acc <- setRefClass("Account")`) names the class from the string: `Account$new()` resolves, `Acc$new()` has no edge. | none (measured in the audit; no repo test) | Capture the assigned name of a generator and alias it to the class. |
|
||||
| R-32 | Measured gap | A dotted call that spells a full name is looked up by qualified name at 0.35 (`scope-resolution: call`), which also matches a member whose qualified name is `Class.member`: `data.frame(x)` from another file binds the method `frame` of an R6 class `data`, and `Stack.push(x)` a member `Stack.push`. The same tier is the right route for a dotted free function (`read.config()`), so it is documented, not removed. In the same file `data.frame(x)` gets no edge. | `r-typed-receiver-members.test.ts` (u2 `data.frame(x)`, u3 `Stack.push(x)` at 0.35) | Add an opt-in flag so a dotted bare call never matches an owned member; keep the free-function route. |
|
||||
| R-33 | Measured gap (audit) | Calls inside quoted code (`quote()`, `bquote()`, `expression()`, `substitute()`, `alist()`, `rlang::expr()`, and formulas) are recorded as calls of the declaration the code names, at the same confidence as a direct call. The edge is a may-call to the right declaration, never to an unrelated one, and it keeps `eval()`-ed code and NSE verbs linked and renamed. | none (measured in the audit; no repo test) | Only if wanted: drop free-call matches under the first argument of the quoting functions (about 20 lines in `captures.ts`), keep formulas; `rename` would then refuse those occurrences as uncovered. |
|
||||
| R-34 | Recorded boundary | Package visibility rules A, B and D (the dependency veto of the name guess; see the guide, Resolution model) apply to package code only. They veto calls that are unsupported for check-clean package code, which is stricter than the shared hook's wording ("impossible"): a user who attaches a package by hand can still make such a call run. Scripts are left alone, since a script may attach anything, so a script call keeps its 0.5 guess across packages. The rules switch off when the package's NAMESPACE exports or imports were unreadable. | `r-package-visibility.test.ts` | Treat a script as a package of its own: its `library()` and `source()` calls declare its dependencies. |
|
||||
| R-35 | Recorded boundary | `with()` and `within()` are not modelled as bindings of a receiver: `with(data, u$save())` after `u <- User$new()` keeps its typed edge even when `data` holds a `u` (b6: a wrong edge kept), and a module-level `u` is left unresolved only through another function's `<<-` poison (m7), not because of `with()`. | `r-receiver-agreement.test.ts` (b6, m7) | Model `with()`/`within()` as frames whose bindings come from the data argument. |
|
||||
| R-36 | Recorded boundary | The receiver-typing frames are per file, but a package namespace spans files: a module binding in `a.R` rebound by `<<-` in `b.R` stays typed (`otherfile` q1 keeps its edge to `Db.query`). A test file that reuses one name for different classes in several `test_that` blocks leaves the name untyped in all of them, because blocks are not frames. | `r-receiver-agreement.test.ts` (q1, o1, reset) | Merge the frames of the files of one load unit; make block scopes frames. |
|
||||
| R-37 | Recorded boundary | `rename` does not see code that reads the function name off the call (`match.call()`, `deparse(substitute(f))`, snapshot tests of printed calls) or builds code from a value (`{name}` interpolation of a variable, `paste0`-built code). A string that spells a call of the name is refused; these are not. | none (documented limit) | Not detectable from the sources; keep the documentation. |
|
||||
| R-38 | Measured gap | The rename guard for R files the index does not cover (extension scan of skipped directories, `.gitignore`d files, over-cap files, `.Rprofile`, `R/*.S`) retains the identifier tokens of all of them with the plan. On a very large unindexed R tree this exceeds the retained-plan budget and the preview fails closed with `plan_too_large`: this repository's own 6,378 R files (fixtures and scratch copies included, all counted as unindexed) charge 117% of the 64 MiB budget, while a 511-file R tree charges 4.06%. Callers hidden by `.gitnexusignore` or in `.git`, `node_modules`, `*.Rcheck`, `renv/library` and `packrat/lib*` are not scanned, nor are extensionless Rscript files. | `r-rename-unindexed-sources.test.ts`; `rename-provider-r.test.ts` (unindexed-bin-script, rprofile-mention, unindexed-r-dir-s-file, unindexed-no-mention) | Keep a digest plus a bit per file once `refreshResolutionConfig` receives the old name. |
|
||||
| R-39 | Recorded boundary | The local-definition guard (a call before a function-local or block-local definition of the name) skips a body that contains no call of the name, and ignores a call inside the qualifying literal itself (recursion). The plan text would have refused a non-direct local literal even with zero calls; the guard refuses only where a call can run before the definition. | R rename fixtures, `rename-provider-r*.test.ts` (local-def-call-before, local-def-if, local-def-switch, local-def-loop, local-def-closure-before, local-def-first, block-def-call-before, block-def-conditional, block-def-nested-first) | None needed; recorded because the guard is narrower than first planned. |
|
||||
| R-40 | Recorded gap | A block-local or nested definition still counts toward the uniqueness of its name, so a call of that name in another block, where R calls the one top-level definition in another file, gets no edge and no outcome (`stubonly()`, `outerh()`, `mockme()`): a lost edge, not a wrong one. Functions nested in functions behave the same. | `r-block-scopes.test.ts` (limit rows) | Exclude definitions not visible from the caller from the uniqueness count, with caller-scope plumbing in the visibility hook. |
|
||||
| R-41 | Recorded boundary (outside R) | A file over the shared size cap (512 KB by default) is skipped for every language. With the cap raised, a 50,000-line (2.4 MB) file took about 138 s and ended with its definitions dropped, for R and TypeScript alike. Not R-specific and left unchanged. | none (shared behaviour) | Shared work: bound the per-file parse time for all languages. |
|
||||
| R-42 | Recorded boundary | Work bounds. (1) A matcher visit costs about 50 to 70 ns, and no fixed per-test overhead is charged on top of the counted visits: charging one was tried and dropped because it moved a pinned visit count and a visibility verdict. (2) At call-nesting depth 1,500 the R query returns 1,028 matches for 1,500 calls (tree-sitter behaviour), so a match count is not a call count; the pathological-input tests assert the sentinel node, not a number of matches. | `r-replacement-scaling.test.ts`; `r-pathological-inputs.test.ts`; `r-export-pattern-shared-budget.test.ts`; `r-qualified-visibility.test.ts` | Retake the per-test overhead with a raised visit budget in `r-qualified-visibility`. |
|
||||
| R-43 | Measured gap | A call of a name written before a function literal assigned to that name in the same function body or block scope (`test_that("x", { expect_equal(helper(), 1); helper <- function() "stub" })`, or a literal assigned under `if`) resolves to the local literal (0.85 `local-call`), where R calls the outer definition: the local binds only once the assignment has run. Rename refuses such a name (`ambiguous_reference`), since the call would be left behind. The edge was measured in the C15 probes and is not pinned. | R rename fixtures, `rename-provider-r*.test.ts` (local-def-call-before, block-def-call-before, block-def-conditional) | Bind a call to a local literal only when the literal is assigned, unconditionally, before it. |
|
||||
| R-44 | Measured gap | Methods added to a RefClass generator by `Gen$methods(name = function() ...)` are not modelled: they create no Method node, and a bare call inside one resolves like a top-level call (`Foo$methods(run = function() helper())` reaches the top-level `helper` at 0.85 `local-call`, where R calls the class method `helper`). Rename refuses a name that any `x$methods(...)` call mentions (`ambiguous_reference`); the receiver is not traced to a `setRefClass()` call. | R rename fixtures, `rename-provider-r*.test.ts` (refclass-methods-call) | Read `$methods()` calls on a generator as members of its class, then bind bare calls in RefClass methods (R-19). |
|
||||
|
||||
## Code and regression entry points
|
||||
|
||||
- [Language provider directory](../../gitnexus/src/core/ingestion/languages/r/) and its
|
||||
[scope resolver](../../gitnexus/src/core/ingestion/languages/r/scope-resolver.ts).
|
||||
- [Receiver corpus](../../gitnexus/test/integration/resolvers/r-receiver-corpus.test.ts)
|
||||
with fixtures [`r-receiver-corpus/`](../../gitnexus/test/fixtures/lang-resolution/r-receiver-corpus/)
|
||||
and [`r-receiver-corpus-qualified/`](../../gitnexus/test/fixtures/lang-resolution/r-receiver-corpus-qualified/).
|
||||
- Receivers and `self`:
|
||||
[r-self-binding.test.ts](../../gitnexus/test/integration/resolvers/r-self-binding.test.ts),
|
||||
[r-chained-receivers.test.ts](../../gitnexus/test/integration/resolvers/r-chained-receivers.test.ts),
|
||||
[r-receiver-chain.test.ts](../../gitnexus/test/unit/scope-resolution/r/r-receiver-chain.test.ts),
|
||||
[r-call-shapes.test.ts](../../gitnexus/test/integration/resolvers/r-call-shapes.test.ts),
|
||||
[r-receiver-agreement.test.ts](../../gitnexus/test/integration/resolvers/r-receiver-agreement.test.ts),
|
||||
[r-typed-receiver-members.test.ts](../../gitnexus/test/integration/resolvers/r-typed-receiver-members.test.ts).
|
||||
- Locals and replacement calls:
|
||||
[r-assignment-locals.test.ts](../../gitnexus/test/integration/resolvers/r-assignment-locals.test.ts),
|
||||
[r-formal-shadows.test.ts](../../gitnexus/test/integration/resolvers/r-formal-shadows.test.ts),
|
||||
[r-replacement-calls.test.ts](../../gitnexus/test/integration/resolvers/r-replacement-calls.test.ts),
|
||||
[r-lexical-names.test.ts](../../gitnexus/test/unit/scope-resolution/r/r-lexical-names.test.ts),
|
||||
[r-block-scopes.test.ts](../../gitnexus/test/integration/resolvers/r-block-scopes.test.ts).
|
||||
- Qualified calls and exports:
|
||||
[r-qualified-exports.test.ts](../../gitnexus/test/integration/resolvers/r-qualified-exports.test.ts),
|
||||
[r-qualified-calls.test.ts](../../gitnexus/test/integration/resolvers/r-qualified-calls.test.ts),
|
||||
[r-qualified-visibility.test.ts](../../gitnexus/test/unit/scope-resolution/r/r-qualified-visibility.test.ts),
|
||||
[r-package-visibility.test.ts](../../gitnexus/test/integration/resolvers/r-package-visibility.test.ts),
|
||||
[r-s3method-registration.test.ts](../../gitnexus/test/integration/resolvers/r-s3method-registration.test.ts),
|
||||
[r-s4-generator.test.ts](../../gitnexus/test/integration/resolvers/r-s4-generator.test.ts).
|
||||
- Rename: [rename-provider-r.test.ts](../../gitnexus/test/integration/rename-provider-r.test.ts) and
|
||||
`rename-provider-r-part2.test.ts` to `rename-provider-r-part5.test.ts` (one fixture list dealt
|
||||
into five parts for the CI time budget; only part 1 is on the Windows and macOS list, since
|
||||
cmd.exe's 8191-char command limit leaves no room for the other four), and
|
||||
[r-rename-unindexed-sources.test.ts](../../gitnexus/test/unit/r-rename-unindexed-sources.test.ts),
|
||||
with fixtures under `gitnexus/test/fixtures/rename-integration/r/`.
|
||||
- Work bounds:
|
||||
[r-replacement-scaling.test.ts](../../gitnexus/test/unit/scope-resolution/r/r-replacement-scaling.test.ts),
|
||||
[r-pathological-inputs.test.ts](../../gitnexus/test/integration/resolvers/r-pathological-inputs.test.ts).
|
||||
- Shared free-call ordering:
|
||||
[free-call-qualified-over-name-claim.test.ts](../../gitnexus/test/unit/scope-resolution/free-call-qualified-over-name-claim.test.ts),
|
||||
[rust-qualified-over-local.test.ts](../../gitnexus/test/integration/resolvers/rust-qualified-over-local.test.ts),
|
||||
[php-qualified-over-local.test.ts](../../gitnexus/test/integration/resolvers/php-qualified-over-local.test.ts).
|
||||
|
||||
Evidence details: [receiver baseline notes](../../gitnexus/bench/receiver-resolution/BASELINE.md) (no R row) and
|
||||
[current roadmap](../../type-resolution-roadmap.md#current-backlog).
|
||||
209
docs/languages/r-provider.md
Normal file
209
docs/languages/r-provider.md
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
# R Language Provider
|
||||
|
||||
Status: implemented (experimental)
|
||||
|
||||
The provider is covered by unit and integration tests, listed under [Tests](#tests). It is classified as experimental in the language support table. This status describes what is implemented today; it does not promise R's dynamic dispatch.
|
||||
|
||||
Wrong or lost results inside the supported slice, and the work that is deliberately deferred, are tracked with IDs (`R-NN`), evidence labels and pinning tests in [r-ingestion-gaps.md](r-ingestion-gaps.md). This guide describes the behaviour; that inventory is the starting point for a plan.
|
||||
|
||||
## Scope
|
||||
|
||||
GitNexus indexes `.r` and `.R` files with the `@eagleoutice/tree-sitter-r` grammar and resolves them through the scope-resolution pipeline (see ARCHITECTURE.md, Scope-Resolution Pipeline). R-specific logic lives in `gitnexus/src/core/ingestion/languages/r/`, with the per-language extractor files beside those of the other languages (the import resolver is `languages/r/import-target.ts`). Apart from the registrations every language has (the language enum, the file-extension and classification tables, the grammar loader and parse-worker grammar map, and the query, provider and scope-resolver registries), shared ingestion code does not name R; the provider plugs in through `LanguageProvider` and `ScopeResolver` hooks. The grammar is loaded through the shared grammar loader on first use, and the parse worker loads it behind a guard like the vendored grammars: if the native binding cannot load, R files are skipped with one logged warning per run and no other language is affected (the warning names the file count and either "parsing disabled via GITNEXUS_SKIP_OPTIONAL_GRAMMARS" when the grammar was opted out, or "parser not available (native binding may not have built)" with an `npm rebuild @eagleoutice/tree-sitter-r` hint). R's own warnings are the package-configuration ones in `package-config.ts`.
|
||||
|
||||
R has no class syntax: classes are function calls (`R6Class(...)`, `setClass(...)`, `setRefClass(...)`) and package exports live in a NAMESPACE file rather than in the source. That is why the provider uses three optional hooks: `postParse` (attach each member to its owner and refine export status once every file is parsed), `resolveMemberOwnerNode` (find the owner of a member from a call rather than an enclosing node) and `resolveDetachedOwnerName` (qualify a member's id with its class, so two classes in one file that share a member name stay distinct nodes: `Method:R/a.R:Stack.push#0`, `Property:R/a.R:Stack.items`).
|
||||
|
||||
## Supported constructs
|
||||
|
||||
Definitions:
|
||||
|
||||
- Functions defined by assignment (`<-`, `=`, `<<-`). S3 methods appear as ordinary functions with dotted names such as `print.foo`.
|
||||
- S4 classes (`setClass`), generics (`setGeneric`), methods (`setMethod`) and slots (`representation`, `slots`).
|
||||
- A `setMethod()` is owned by the class its signature names: the string, or the first argument of `signature(...)` or `c(...)`. A method on several classes (multiple dispatch) belongs to its first class, so `signature("A", "B")` and `signature("A", "C")` give one method of `A` (the first written wins) and a signature whose first argument is not a string names no owner.
|
||||
- R6 classes with their methods, fields and active bindings.
|
||||
- RefClass (`setRefClass`) classes with their methods and fields.
|
||||
- Slots and fields given as `list(...)`, `representation(...)` or `c(...)`. A named entry (`c(a = "numeric")`) records its type; an unnamed string in `slots = c("a", "b")` or `fields = c("a", "b")` names an untyped member, as in R. An unnamed string in `representation(...)` or `list(...)` is not a member.
|
||||
- The argument that names an S4 or RefClass class, a generic or a method is the argument spelled with the first formal (`Class=`, `name=`, `f=`) wherever it sits, else the first unnamed argument (so a later `Class=` takes the formal from an earlier unnamed argument, as in R). Other strings (`contains=`, `valueClass=`, a `setMethod` signature) create no node.
|
||||
|
||||
Types:
|
||||
|
||||
- `x <- Type$new()` (R6, RefClass) and `x <- new("Class")` (S4) type `x`. Resolution of `x$method()` is asserted by tests for R6 only (`r-receiver-corpus`). The S4 `new()` binding is made in the per-file type environment, not in the scope, so it types no receiver (`u <- new("User"); u$save()` has no edge); it is unit-tested only. A constructor types a name only when every binding of that name in its frame agrees with it (`receiver-agreement.ts`): a second class, an untyped value, a loop variable, a formal, `rm(x)`, `assign("x", ...)` or `class(x) <- v` in the same frame leaves the receiver untyped, and `obj$m()` then ends as `receiver-unresolved` with no edge, never as an edge to another class's method. `NULL`, a constant and a write through the object (`x$a <- v`, `x[[i]] <- v`) do not count against it. A frame is the file's top level or one `function_definition`; the braces of `local({...})` and of other calls belong to the enclosing frame; `<<-` and `->>` bind in no frame of their own but are recorded in every enclosing frame, up to the top level. A module-level `u <- User$new()` also types `u` in the file's functions, unless a function binds `u` itself or the file `<<-`s it. Frames are per file: a package namespace spans files, so a rebinding in another file is not seen (R-36). `with()` and `within()` are not modelled (R-35). A test file that reuses one name for different classes in several `test_that` blocks leaves that name untyped in all of them, because blocks are not frames. A local from any other call, a parameter and a roxygen type leave it untyped (see [r-ingestion-gaps.md](r-ingestion-gaps.md), R-20).
|
||||
- roxygen2 `@param` types in a restricted form: the description is the type alone (`#' @param name Type`) or starts with it in braces (`#' @param name {Type} prose`), the type is an R6, S4 or RefClass class defined in the same file, and the name is a formal of the function the block documents. The type is bound inside that function only. Prose that begins with a capitalised word binds nothing. Other roxygen2 tags (`@return`, `@field`, `@inheritParams`, `@rdname`, `@noRd`, `@export`) are not read.
|
||||
|
||||
Imports:
|
||||
|
||||
- `library()` and `require()` resolve to local packages, and `source()` to local files. The argument is `package=` / `file=` wherever it sits, else the first unnamed argument after any named one; named-first arguments such as `lib.loc=` and `local=` are not imports.
|
||||
- NAMESPACE `importFrom()` names bind to definitions in local packages. A name that can never bind (not exported, not defined at top level, or dotted) creates no import, so its calls keep the `global-name-fallback` guess, unless the name is provably private to its package: then the guess is vetoed too and the call gets no edge (see Export status).
|
||||
|
||||
Calls:
|
||||
|
||||
- Free calls, `obj$method()` calls (receiver typed as above), R6 `self$method()` calls, and calls inside native pipe (`|>`) chains.
|
||||
- A chained member call, `a$b$m()`, `Cls$new()$m()` or `self$f$m()` (the receiver is itself a `$`/`@` access or a call), is never typed: R6 fields and method results carry no declared type, so the chain is not followed. The site is minted with the shared codec's `truncated` receiver chain, which the shared fold refuses, and the call ends with a `receiver-unresolved` outcome and no edge, instead of reaching a free function or a method of that name elsewhere (it counts toward the `impact` lower bound). The first link must be a plain variable: a chain based on a call (`get_user()$save()`, `pkg::Cls$new()$m()`), on `x[[1]]`, `x[1]` or `(x)`, or one the codec cannot encode (more steps than its depth limit, a name with a space) mints no site at all: no edge and no outcome. That shape is invisible, not unresolved. The shared fold's comment calls the `truncated` marker defence in depth that no producer mints; R relies on it: `r-receiver-chain.test.ts` fails if R stops minting the marker or if upstream starts folding a truncated chain. End to end the marker is not load-bearing today: with it removed (`truncated: false`), the outcomes and edges of the `r-chained-receivers` fixture are unchanged and only that unit test fails (R-09). Renaming a function whose name is the last step of such a call (a free `save` beside `a$b$save()`) is refused as `ambiguous_reference`.
|
||||
- A replacement-function call, `foo(x) <- value`, is recorded as a call of `` `foo<-` ``, as R runs it (`=`, `<<-`, `->` and `->>` too, and `pkg::foo(x) <- v` as a call of ``pkg::`foo<-` ``, which passes the export check of the setter name: a NAMESPACE entry may be written ``export(`foo<-`)`` or `export("foo<-")`, and an unexported setter is dropped from outside the package, never replaced by the getter). A call is in a replacement position when it lies in the target chain, reached only through first arguments and the bases of `$`, `@`, `[` and `[[`; every call of the chain is recorded as its own setter (`foo(bar(x)) <- 1` calls `` `foo<-` `` and `` `bar<-` ``), so the getter half that R also evaluates (`bar(x)`) has no edge: a missing edge, not a wrong one. Other arguments (`foo(x, bar(1)) <- v`, the index of `foo(x)[bar(1)] <- v`) and the assigned value stay ordinary calls, and `x$a <- 1` or `x[i] <- 1` (a builtin setter) has no call site at all. A function defined as `` `foo<-` <- function(x, value) ... `` is now found by the call; a string-spelled definition (`"foo<-" <- function(...)`) and `setReplaceMethod()` / `setGeneric("foo<-")` define no node, so such a call has no edge (not even to the getter). An explicit `` `foo<-`(x, v) `` is unchanged. `rename` refuses both halves of a replacement pair (see Rename).
|
||||
- `self` in an R6 method is typed as the class: `self$m()` resolves through the class and its parents (`inherit =`) at the receiver-call confidence (0.85), never to a free function of that name. The class is the name the `R6Class` call is assigned to (`Child <- R6::R6Class(...)`, bare or `R6::`), and the method is a function in its `public`, `private` or `active` list (a private method has a typed `self` too). `self` is the public interface, so `self$m()` reaches only public and active members: a method in a `private` list, of the class or a parent, is skipped, and a parent's public method of that name is the target even when the class has a private one (`private$m()` itself is not resolved). A closure inside a method sees the method's `self`. Writing through `self` (`self$count <- 1`, `self[["x"]] <- v`, `self$items[[i]] <- v`, `names(self$items) <- v`) leaves it the instance. Where `self` is no R6 instance (a free function, a parameter called `self`, a plain `self <- other()` in the method or function, an `R6Class` call that is assigned to nothing, a `setRefClass` method) a `self$m()` call gets no edge and the resolution outcome is `receiver-owned-but-unbound`, unless that binding is itself typed (`self <- Foo$new()` makes `self` a `Foo`); a method that neither the class nor a parent defines as a public member (or a parent that is not indexed, as in `inherit = pkg::Base`) gets no edge and the outcome `member-lookup-ambiguous` (the closest label the shared pass offers for "member not found"). A file that binds `self` itself (`self <- Foo$new()` at top level) is exempt: its free functions read that object. The same lookup rules hold for every typed receiver, not only `self`: `u <- User$new(); u$secret()` does not reach a private method (a typed miss records `member-lookup-ambiguous`), `u@save()` is not a member call and makes no site, and a call through a generator object (`g <- setRefClass(...)`) is not a receiver. A class whose name contains a dot (`My.Class`) gets no receiver lookup at all, for `self$m()` and for typed receivers alike: the call ends `receiver-unresolved` with no edge, where it used to reach a same-named free function (R-13). The edge of a `self$m()` call is labelled `global` at 0.85 when the class is in the caller's file and `import-resolved` at 0.85 when it is in another file.
|
||||
- A call through a name that a function's formal or one of its `for` variables binds (`function(data, transform) transform(data)`) gets no edge, not even to a top-level function of that name. The scope publishes those names as lexical names: they stop the lookup and the unique-name fallback. Every named formal is a lexical name, whatever its default: a default (`n = 2`, `helper = NULL`) proves nothing about the argument a caller passes, which may be a function, so a call through the formal is never bound to a function of that name outside. A `for` loop outside every function binds nothing.
|
||||
- An ordinary assignment in a function's own body is a lexical name too, when its value may be a function (`helper <- identity`, `helper = some_fn()`, `identity -> helper`): a call of the name in that function, or in a closure nested in it, gets no edge to a top-level function of the same name. A function literal (`helper <- function(y) 2`) is a real definition and the call resolves to it; a constant (`helper <- 2`, a string, `TRUE`) is skipped by R at a call, so the call still reaches the outer function; `<<-` and `->>` assign outward and bind nothing in the frame. A complex target binds its base (`names(x) <- v`, `x$a <- v` bind `x`, never the called name). The rule is flow-insensitive, a deliberate trade for a conservative result: a call written before the assignment, or under an `if` that may skip it, is left unresolved too. Not modelled, so the call still reaches the outer function: `assign()`, `delayedAssign()`, `list2env()` and `with(list(...), ...)` values (R-02; pinned by `r-assignment-locals`), and a string target (`"helper" <- identity`). The braces of `local({ ... })` are read as part of the function body, so an assignment inside them makes the name lexical (the call gets no edge). Braces that R evaluates in an environment of their own (`local({...})`, `with(data, {...})`, `within()`, testthat's `test_that(desc, {...})`, `describe()` and `it()`, bare or `pkg::`-qualified) are block scopes: a function literal defined inside is visible in those braces only, so a call from another block or file does not reach it (`fallback-refused`, never the 0.5 guess). `withr::with_*()` and `suppressWarnings({...})` evaluate their braces in the caller's frame and get no scope. A block-local definition still counts toward the uniqueness of its name, so a call elsewhere of a name that is also defined at top level in another file gets no edge where R would call the top-level definition: a lost edge, not a wrong one (R-40). Module-level assignments are not modelled either: a top-level `helper <- identity` (or `helper <- 5`) in the caller's own file still lets the unique-name fallback reach another file's `helper` at 0.5. A qualified call is decided by its qualifier alone: `function(f) mypkg::f()` and `helper <- identity; pkga::helper(x)` still bind `mypkg`'s `f` and `pkga`'s `helper` (the shared free-call pass resolves the qualifier before it applies a lexical name claim).
|
||||
- Named nested functions (closures) are indexed, and calls inside attribute to the nested function, which can shadow a top-level name; a nested function reads the lexical names of the function around it. Top-level script calls outside any function are attributed to the File node.
|
||||
- `pkg::fn()` and `pkg:::fn()`, decided by the qualifier (see [Resolution model](#resolution-model)).
|
||||
|
||||
Heritage:
|
||||
|
||||
- R6 `inherit=` and S4 `contains=` (a single class or `c(...)`). `contains = "VIRTUAL"` links to one placeholder class named `VIRTUAL`.
|
||||
|
||||
Exports:
|
||||
|
||||
- Read from each local package's NAMESPACE: `export()`, `exportClasses()`, `exportMethods()`, `S3method()` and `exportPattern()`. `S3method(generic, class, method)` registers `method`, the function R registers, else `generic.class` (a generic of another package, `S3method(pkg::generic, class)`, registers `generic.class`). A registration is not an export: `pkg::generic.class`, `library(pkg)` and `importFrom(pkg, generic.class)` do not reach a method that only `S3method()` names, though a function also named by `export()` or `exportPattern()` is still exported. The graph node of a registered method stays public (`isExported`), and `rename` refuses it. Conditional (`if`/`else`) exports, raw-string and multi-line arguments and the text after a malformed directive are not read (see Limits and known gaps). A `DESCRIPTION` file with a `Package:` line marks a package root.
|
||||
|
||||
## Resolution model
|
||||
|
||||
The scope query (`languages/r/query.ts`) captures four call shapes only: `f(x)`, `pkg::f(x)`, `obj$f(x)` and a chained `a$b$f(x)` (an `extract_operator` or a call as the receiver; see the chained-call bullet under Calls). A free or qualified call in the target of an assignment (`f(x) <- v`) is a call of the setter `` `f<-` `` (see the replacement-function bullet under Calls).
|
||||
|
||||
- A bare call binds lexically first. `library()` and `require()` of a local package attach that package's exports, as R does for an installed package: every top-level name when the package has no NAMESPACE file, otherwise the names the NAMESPACE provably exports (`export()`, `exportClasses()`, `exportMethods()`, a matching `exportPattern()`; `S3method()` registers a method and does not export it). A name the NAMESPACE does not export is not attached, and neither is a name it may export but cannot be proven to (an unreadable NAMESPACE, or an `exportPattern()` that was dropped or cut short by its work budget): the 0.85 edge a `library()` yields needs proof. The hook has no importer, so a `library(ownpkg)` issued from inside the package (its `tests/` included) attaches only the exports too. When two attached packages define the same name, the package attached last wins, as R masks it: of the `library()` calls whose package defines the name, the one that comes last in the file decides (calls inside a function are not told apart from top-level ones, and a package named by several calls counts at the first of them, because R leaves an attached package where it is: `library(a); library(b); library(a)` still lets `b` mask `a`). Two files of that one package that both define the name stay ambiguous, with no edge. This differs from `devtools::load_all()`, which attaches every top-level name; the earlier behaviour of this provider followed `load_all()`. `source()` binds no name; a call into a sourced file, or into another file of the same package, binds through a workspace-wide unique-name fallback at confidence 0.5 (`global-name-fallback`). That guess skips a candidate its package's readable, complete NAMESPACE does not export, unless the caller sits inside that package's directory tree (see Export status); a name that is not provably private (unreadable NAMESPACE, a cut-short `exportPattern()`) keeps the guess. An ambiguous name yields no edge, and a function defined inside another function, or inside a block scope (see Calls), is never a candidate (no other function can reach it by name). In package code the guess is also bounded by the package's declared dependencies. When the caller is in `P/R/`, P has a NAMESPACE and neither its exports nor its imports were unreadable: Rule A vetoes a candidate in another repository package Q unless P has an `importFrom` entry for the name, imports Q (`import(Q)`, `importClassesFrom(Q, ...)` or `importMethodsFrom(Q, ...)`), lists Q in `Depends:` (transitively, since `library(A)` also attaches A's `Depends:`), or the caller file has a `library(Q)` / `require(Q)`; Rule B vetoes a candidate that is in no package's `R/` (a loose script, another tree's `tests/` or `inst/`) unless an `importFrom` entry exists; Rule D vetoes a call from `P/tests/` to a definition under `P/data-raw/` or `P/inst/`, which tests reach only by sourcing them, unless the caller file calls `source()`, `system.file()` or `test_path()`. Rules A and B veto calls that are unsupported for check-clean package code: the package's declared dependencies cannot justify them. That is stricter than the shared hook's wording ("impossible"), since a user can attach Q by hand and the call then runs; the provider prefers an unresolved call to an edge into a package the code does not declare. A vetoed guess records `fallback-refused`. Scripts are not constrained by these rules (R-34).
|
||||
- A bare call never binds to a dotted definition, so `foo(x)` cannot reach `print.foo`. Dotted (S3-style) names are never import-resolved and skip the 0.5 fallback; a call that spells the full dotted name (`read.config(x)`, `print.foo(x)`) binds by qualified-name lookup at confidence 0.35 (`scope-resolution: call`) to a unique definition, in the same package or another file, whether or not a `library()` attached it. That lookup also matches a member whose qualified name is `Class.member`, so `data.frame(x)` can bind the method `frame` of an R6 class `data`, and `Stack.push(x)` a member `Stack.push`: a wrong edge when the call means a base-R function or a function from another package (R-32). Dotted names are not bound through `importFrom()`.
|
||||
- `pkg::name()` is decided by its qualifier:
|
||||
- `pkg` is a package of the repository and defines `name` exactly once: the call binds to that definition at confidence 0.85 (`import-resolved`), subject to the export rule below.
|
||||
- `pkg` is certainly not a repository package (discovery completed and found no such package, including a repository with no `DESCRIPTION`): no edge.
|
||||
- `pkg` defines `name` more than once: no edge.
|
||||
- `pkg` is a repository package that defines no `name`: the call binds only when `pkg` re-exports it, i.e. its NAMESPACE has `export(name)` and `importFrom(origin, name)` for a repository package `origin` that defines `name` exactly once (one hop; the last `importFrom` of the name wins). The edge goes to `origin`'s definition at 0.85. Anything else (not exported, no `importFrom`, an external or multiply-defining origin, `:::`) gives no edge: a same-file or other-package definition of that name is never reached through a qualifier.
|
||||
- Discovery may have missed a package (its limits were reached): the call gets no edge. No qualified call is left to the lexical or name-guess tiers.
|
||||
- `::` and `:::` differ. `pkg:::name` binds to the definition whether or not the package exports it. `pkg::name` binds only when the package has no NAMESPACE file or exports the name, or when the caller sits inside the package's directory tree (see Export status); a `::` to a private name (or one not provably exported) from outside gets no edge, as R errors there.
|
||||
- Package discovery is bounded to depth 3 and 200 directories (when the bound may have hidden a package this is flagged internally and nothing is logged), reads directories in code-unit order, ranks duplicate package names by shallowest then by whole path, and never searches the directories the indexer itself ignores (dot-directories, `vendor`, `build`, `dist`, `lib64`, `fixtures` and the rest of its built-in list) or those named exactly `renv`, `packrat`, `revdep` or `<name>.Rcheck`. A `DESCRIPTION` with a `Built:` field is an installed library package (`lib/`, `library/`) and registers nothing. `DESCRIPTION` and `NAMESPACE` are read only as regular files (a symlink is not followed) and only up to 4 MiB; a larger `DESCRIPTION` leaves its package undiscovered (discovery counts as incomplete) and a larger `NAMESPACE`, or one that is a symlink, is treated as absent. The shared file scanner has no `renv`, `packrat`, `revdep` or `.Rcheck` entries, so `.R` files inside those trees are still parsed even though discovery skips them.
|
||||
- Export status: the language-level export check treats every symbol as public. The NAMESPACE pass then marks the remaining `R/` symbols of a NAMESPACE-bearing package non-exported. Symbols outside `R/` (`tests/`, `vignettes/`, `inst/`) are not package members and stay public; files in `R/` subdirectories are treated as `R/` files although R itself does not load them. A package without a NAMESPACE stays fully public, and a nested package without one does not inherit its enclosing package's. roxygen2 `@export` tags are not consulted by the index (`rename` does read them, see Rename). The refinement changes the graph nodes' `isExported` only; the definitions scope resolution works from carry no export verdict (`isExported` stays undefined there, since a `false` would drop the name from wildcard providers). Resolution instead computes a visibility per top-level name from the same NAMESPACE reading, once per pass: `public` (the package has no NAMESPACE file, or exports the name), `private` (a readable, complete NAMESPACE does not export it) or `unknown` (not exported, but the NAMESPACE is unreadable or an `exportPattern()` was dropped or cut short). `library()` and `pkg::name` need `public` (strict); the global-name fallback vetoes `private`, and, for package code, the dependency rules A, B and D (see Resolution model), but not `unknown`. The inputs are tested in sorted order and completeness is read after the whole batch, so a work-budget cutoff gives the same verdicts whatever the order of files or call sites. A caller is inside package P when the longest package directory (a package with a `DESCRIPTION` or a NAMESPACE) that is a path prefix of its file is P's: `tests/`, `inst/` and `vignettes/` of P count as inside, a nested package claims its own files, and for a root-level package every file outside a nested package counts as inside (so the private-name exemption is broad there). Callers inside P reach P's private names through `pkg::name` and the name guess.
|
||||
- `exportPattern()` text comes from the analysed repository, so patterns are compiled to a linear-time automaton (`linear-regex.ts`) with a work budget. Constructs with no linear-time algorithm (back-references, look-around), constructs TRE reads differently from JavaScript (`\<` `\>`, `\a`, `\e`, `\Q`, `\E`, `\x{`, `\u`, any backslash inside `[...]` (TRE reads it as a literal member, so `[a\-z]` is a range from `\` to `z` and `[\n]` holds `n`; this drops the common `exportPattern("^[^\\.]")`, whose names then read as not provably exported; write `"^[^.]"`), `[.x.]` and `[=x=]`, `{,n}`, a leading `]` in a bracket expression that has no POSIX class) and oversized patterns are dropped with one logged warning per pattern per package, and `rename` then reports `incomplete_semantics`. Character classes use ASCII semantics. A directive argument the NAMESPACE reader cannot read at all is dropped without a warning: an R 4.0 raw string (`exportPattern(r"(^[^.])")`, `export(r"(foo)")`) and any string that spans lines. It exports nothing, so the names it was meant to cover read as non-exported in the index. The reader records that it dropped something that may hide an export (this, a conditional `export()` below, or a malformed directive that swallowed one), and `rename` refuses on that record (see Rename).
|
||||
|
||||
## Limits and known gaps
|
||||
|
||||
Not edges or nodes:
|
||||
|
||||
- A function passed as a value (`lapply(x, f)`, `Map`, `lapply(x, pkg::f)`), dynamic calls (`do.call("f", ...)`, `match.fun()`, `get()`) and dispatch through a registry (`handlers[["a"]]()`) create no CALLS edge, because only the four call shapes above are captured; the cross-language callable-value analysis classifies R as not applicable.
|
||||
- R6 `super$method()` and `private$method()` calls (nothing binds `super` or `private` to a class), S4 slot access (`obj@slot`), and `UseMethod()` / `NextMethod()` create no edge.
|
||||
- Anonymous functions (`function(x)`, `\(x)`) passed as arguments are not indexed as definitions; calls inside are attributed to the enclosing function, or to the File at top level.
|
||||
- Infix operators (`a %op% b`) are not linked to their definitions.
|
||||
- Constants and other top-level non-function assignments, and environments (`new.env()`, `local()`, `assign()` / `get()`), are not nodes; the only value-like nodes are R6, RefClass and S4 fields and slots. `setValidity()`, `setClassUnion()`, `setReplaceMethod()` and `Class$methods(...)` create no definition or edge; a bare call inside a `Class$methods(...)` method resolves as if it were outside the class (R-44).
|
||||
- No framework or entry-point detection (Shiny, plumber, targets, testthat). Plumber `#*` annotations are not read and no route nodes are created, and no route or fetch edges link R to other languages.
|
||||
|
||||
Known gaps in call resolution and class handling. Those that decide which target a call reaches are tracked with IDs, evidence and pinning tests in [r-ingestion-gaps.md](r-ingestion-gaps.md): the dotted R6 class name (R-13), a global `self` bound in another file (R-14), RefClass calls by bare name and through `.self$` (R-19), and the S4 generic, generator, `new()` and `Class$new()` calls that get no edge (R-18). Rows R-26 to R-44 record the boundaries found while fixing the audit. The rest were reproduced and are left as is:
|
||||
|
||||
- `source()` paths are matched by suffix: leading directories that do not exist are dropped until a file matches, so `source("secrets/config.R")` binds `R/config.R`, and the first of several matches wins. Backslash paths and absolute paths resolve to nothing.
|
||||
- `library(localpkg)` creates one `IMPORTS` edge per file of the package from every importing file, so 100 scripts and a package of 200 files give 20,000 edges.
|
||||
- When two repository packages each define a helper of the same name, a bare call to it from another file of either package gets no edge: the name is ambiguous in the repository, although the caller's package decides it in R.
|
||||
- `METHOD_OVERRIDES` compares parameter counts, so an R6 or RefClass method that overrides one with a different formals list (`speak = function(loud = TRUE)` over `speak = function()`) gets no edge, although R has no overloading.
|
||||
- Calls inside the function of a `setMethod()`, `setGeneric()` or `setValidity()` call are credited to the File, not to the `Method` node the same call creates. Calls inside `validity =` of `setClass()` are credited to the Class.
|
||||
- R6 `inherit = pkg::Base` and legacy S4 `representation("Base")` create no `EXTENDS` edge.
|
||||
- R6 members named by a string (`"c d" = function() ...`, `'e' = 3`) are dropped; backticked names are kept with their backticks. A class or owner name read from a string keeps its escapes undecoded, so `setClass("Hello\x41")` is a different class from `setClass("HelloA")`, which R treats as one.
|
||||
- A function nested in an R6, RefClass or `setMethod()` body has an id such as `Counter.function.bump@7:21`: the enclosing function's name is a placeholder and the line and column keep ids apart, so ids change when lines shift.
|
||||
- A base class named by `inherit =` or `contains =` that is defined more than once in the repository (in two files, or twice in one) gets no `EXTENDS` edge from a class in another file; a base defined in the deriving class's own file is found.
|
||||
- Export patterns use JavaScript regular-expression syntax where R uses TRE. The constructs where the two differ are refused, not guessed (see the NAMESPACE bullet above), including every backslash inside `[...]`: `exportPattern("^[^\\.]")` is dropped although TRE and JavaScript disagree on it only for names that contain a backslash, because `[a\-z]`, `[\n]` and `[\]]` differ for ordinary names and no narrower rule has been checked against R.
|
||||
- NAMESPACE `if (...)` and `else` bodies are skipped, so a function exported only conditionally (`if (getRversion() >= "4.1.0") export(new)`) reads as not exported in the index. So does anything after a malformed directive up to the next line that starts with `name(` in column 0. Skipping a body or a malformed directive that names an export-family directive (`export`, `exportClasses`, `exportMethods`, `S3method`, `exportPattern`) is recorded, and `rename` refuses a function of such a package; the persisted `isExported` is not changed by it.
|
||||
|
||||
A class name that is not unique: a member attaches to the class of its own file when several classes share the name (the same helper class in `R/` and `tests/`, a class defined twice in one file counts once). When the member's file defines none of them, or several, the member stays unowned with its `ownerNameHint`, and one warning for methods and one for fields name the class names concerned (at most five each).
|
||||
|
||||
Not supported:
|
||||
|
||||
- The magrittr pipe `%>%` has no specific support and none is claimed; the native pipe `|>` is supported. tidyverse non-standard evaluation (bare column names, `.data$x`) is not modelled.
|
||||
- `.Rmd` and `.qmd` files are not indexed (the provider handles `.r` and `.R` only; `.Rprofile` and `.Renviron` are not indexed either), and `box::use()`, `import::from()`, `loadNamespace()`, `attachNamespace()` and `sys.source()` imports are not read.
|
||||
- S3 dispatch is not resolved: S3 methods are indexed as functions only. S4 and RefClass dispatch is not resolved either; there are definitions and heritage, not dispatch edges.
|
||||
- NAMESPACE `import()`, `importClassesFrom()` and `importMethodsFrom()` create no import edge and bind no name. They are read only as a declared link to a repository package (the dependency rules A and B in Resolution model), and `DESCRIPTION` `Depends:` likewise. Imports of external (CRAN) packages produce no edges.
|
||||
- NAMESPACE `exportClassPattern()` is not read. An S4 class exported only through such a pattern reads as not exported (`isExported` false), and an `importFrom()` of it does not bind; classes listed in `export()` or `exportClasses()` are read.
|
||||
- S4 `contains=` links only to classes defined in the repository or the `VIRTUAL` marker; basic-type bases such as `"numeric"` add no edge.
|
||||
- Package discovery is bounded (depth 3, 200 directories), so a package beyond the bound is treated like an unknown one. A package found only under `renv`, `packrat`, `revdep`, `<name>.Rcheck`, a dot-directory or another directory the indexer ignores, or whose `DESCRIPTION` has a `Built:` field, is not a repository package.
|
||||
- A `pkg::name` whose package defines no `name` resolves only through a one-hop re-export (`export` plus `importFrom` of a repository package); a re-export through a second hop, through `exportPattern()` only, or through `import()` gets no edge. `pkg::name` used as a value is not qualifier-resolved. Call sites dropped for certainly-external, unknown-locality, duplicate-definition or not-exported qualifiers leave no recorded outcome, because the drop runs before outcomes exist (R-04, R-11).
|
||||
- When discovery may have missed a package, a `pkg::name` call gets no edge (it used to keep the name-based guess). A `library(pkg)` attaches no private or not-provably-exported name, and a bare call of a name whose NAMESPACE is unreadable or whose `exportPattern()` was cut short (`unknown`) still gets the 0.5 guess, because it is not proven private. A `library(ownpkg)` from inside the package loses the private names at the 0.85 tier too (they fall to the 0.5 guess, which is allowed for callers inside the package). A `source()` of a private package file does not reach its names through the guess: the guess is vetoed for callers outside the package tree, although R puts sourced names in the global environment. A class reached as a receiver type through a `library()` IMPORTS edge (`ResultSet$new()` then `rs$count()`) is not filtered by this rule (R-03, R-07, R-08, R-12).
|
||||
- Files in `R/` subdirectories are treated as package-owned, although R itself does not load them.
|
||||
- NAMESPACE is read from disk during analysis, but neither it nor `DESCRIPTION` is a parsed source file. After an edit to either alone, an incremental analyse (no `--force`) keeps stale manifest-derived data: the persisted `isExported` of symbols in unchanged `R/` files, a `CALLS` edge bound through an `importFrom()` whose target changed, a `pkg::fn` edge after a `Package:` rename, and a `library()`, `pkg::fn` or name-guess edge whose target's export status changed. A full analyse is correct and `analyze --force` recomputes it. This is a limitation of the shared incremental write path (only changed files are re-processed), not of R resolution; other language manifests that feed resolution can be affected in the same way. A stale `isExported` does not reach `rename`: the rename guard does not take the persisted flag as proof that a function is private, and reads the package's NAMESPACE again at plan time (see Rename).
|
||||
- A name written in backticks (`` `%+%` ``, `` `my fn` ``) keeps its backticks on the node, and a definition and its call resolve only when both spell the name alike: `` `helper`(x) `` against `helper <- function(...)` (or the reverse) gets no `CALLS` edge. NAMESPACE `export()`, `exportPattern()`, `importFrom()`, `pkg::name()` and `rename` match the symbol whichever way it is written. `importFrom(pkg, `%||%`)` binds a call spelled like the definition (`` `%||%`(x, y) ``), and a call spelled the other way gets the name-based guess instead.
|
||||
- Roxygen `@param` types live in the per-file type environment; no graph edge consumes them.
|
||||
- Argument matching follows R for named and reordered arguments but does not model partial argument names: `setClass(Cla = "D")`, `new(Cla = "E")`, `setRefClass(Cla = "A")` and `setMethod("show", sig = "Foo")` are not recognised, so no definition or owner is attached.
|
||||
- Members are read only from the named forms. The positional second argument of `setClass("A", representation(x = "numeric"))`, `R6Class("X", list(...))` and `setRefClass("A", list(...))` creates no field or slot nodes; use `slots=` / `representation=`, `public =` / `private =` / `active =` and `fields =`.
|
||||
- S4 and RefClass constructors, and the import calls, are recognised only as a bare callee: `setClass(...)`, `setRefClass(...)`, `setGeneric(...)`, `setMethod(...)`, `library(...)`, `require(...)` and `source(...)`. A namespace-qualified call such as `methods::setClass("A", slots = list(x = "numeric"))` creates no class, slot, generic or method, and `base::library("pkg")` creates no import. `R6::R6Class(...)` is recognised.
|
||||
- For `S3method(generic, class, method)` the positional third argument names the registered method. A named third argument such as `S3method(print, foo, method = impl)` is not read: R registers `impl`, but the registration is recorded for `print.foo`, and `rename` treats the NAMESPACE as unreadable.
|
||||
|
||||
### Bounds on NAMESPACE exportPattern matching
|
||||
|
||||
- Matching work and NFA states are counted in deterministic work units, never wall-clock time, so the outcome is the same on every machine. A NAMESPACE file is still read in full.
|
||||
- Caps, in `linear-regex.ts`: `MAX_TOTAL_WORK` (40,000,000 state visits per matcher), `MAX_SHARED_WORK` (100,000,000 matching visits per load), `MAX_PACKAGE_WORK` (30,000,000 per package), `MAX_SHARED_STATES` (500,000 NFA states per load) and `MAX_PACKAGE_STATES` (100,000 per package); a single pattern is also capped by `MAX_PATTERN_LENGTH` (16,384 characters) and `MAX_STATES` (16,384).
|
||||
- A pattern refused after its NFA was partly built is charged for the build (eight visits per state or build step), and once the budget is spent later patterns are refused without being parsed (dropped, with a reason naming the work budget). A NAMESPACE of thousands of oversized patterns therefore spends the same budget as one of hostile matches.
|
||||
- Groups nested deeper than 256 (`MAX_NESTING`) are refused with the reason "groups are nested too deeply". The limit sits well inside the call stack (a quarter of the default stack still compiles 256 levels), so the verdict is the same cold or warm and on every machine.
|
||||
- Once the work budget is spent the matchers are no longer asked. A name an `exportPattern()` matched while the budget lasted keeps that answer (a second definition of the same name stays exported; only the first 100,000 distinct matched names per package are remembered, so past that a name reads as not exported once the budget is spent); a name not decided by then reads as not exported (fail closed), and the cut-short patterns are named in a warning.
|
||||
- A verdict answered from a matcher's memo (a name asked about again) costs one visit of the work budget, so many matchers asked about the same names over and over are bounded by the budget too.
|
||||
- A counted visit costs about 50 to 70 ns of real time on realistic patterns (13 ns for a hostile one, 13 ns for a memo hit; measured), so the package cap is about 2 s and the load cap about 7 s in the worst case: bounded, never a hang. No fixed per-test overhead is charged on top of the visits (it was tried and dropped, because it moved a pinned visit count and a visibility verdict; R-42).
|
||||
- A file over the shared size cap (512 KB by default, `GITNEXUS_MAX_FILE_SIZE`) is skipped for every language. With the cap raised, a 50,000-line (2.4 MB) R file took about 138 s and ended with its definitions dropped, as an equivalent TypeScript file does: shared behaviour, not R-specific, left unchanged (R-41). Separately, the R query returns 1,028 matches for 1,500 nested calls (tree-sitter behaviour), so a match count is not a call count.
|
||||
- A pattern that does not fit is dropped, exports nothing and is warned about: at most 50 patterns are named per package (`MAX_LISTED_DROPPED_PATTERNS`), followed by a summary line counting the rest.
|
||||
- The first pattern refused for lack of states closes that package's allowance, so later patterns in the package are refused without being parsed.
|
||||
- One hostile package cannot exhaust the load budget alone (it is limited to its own share), but several can (three, for matching work); packages processed after that degrade, with warnings. Which package degrades follows graph node order.
|
||||
- Budget exhaustion while resolving NAMESPACE `importFrom()` names is not reported: the scope resolver loads its own configuration, consults the same matchers, and a name a cut-short pattern would have exported then reads as not exported, with no warning. The `postParse` warnings above are the only report.
|
||||
- Explicit `export()` names are unaffected by these bounds.
|
||||
|
||||
## Control flow
|
||||
|
||||
R provides a control-flow-only CFG visitor (`cfg/visitors/r.ts`), used under `analyze --pdg`. It gives R `BasicBlock` nodes, `CFG` edges and control-dependence (`CDG`) edges.
|
||||
|
||||
Modelled, in statement position inside a function body: `if` / `else if` / `else`, `for`, `while`, `repeat`, `break`, `next`, `return(...)` and bare `{ }` blocks. Every loop, including `repeat` and `while (TRUE)`, has a structural exit edge so that EXIT stays reachable for control dependence. Each `function_definition` (`function(x)` and `\(x)`, assigned or anonymous) gets its own CFG; a nested function is opaque in its parent. An `if` arm that is missing (no `else`) or empty (`{}`) produces no block: it is the header's `seq` edge to the join, and control dependence labels it as the complement of the other arm's explicit `cond-true` / `cond-false` edge (`if (c) {} else work()` makes `work()` control-dependent on `c` with label `F`).
|
||||
|
||||
Not modelled:
|
||||
|
||||
- No def/use harvest and no binding table, so reaching definitions report `no-facts` and R emits no `REACHING_DEF` edges; no taint call sites are recorded. This is a coverage gap, not a result of "no flows found". The PDG tools do not say so for R: `pdg_query` with `mode: "flows"` returns `{"results": [], "total": 0}` with no note, `explain` returns `findings: []` with its generic absence note, and the `impact` note for `mode: "pdg"` still reads "over CDG + REACHING_DEF". Read an empty answer for an R function as "not analysed". The shared tools have no per-language capability signal to report this from, so it is documented here rather than patched into them.
|
||||
- Control flow nested inside an expression stays one straight-line block: `y <- if (c) a else b`, `switch()`, `&&` / `||`, `ifelse()`, and `return()` / `break` inside `tryCatch({ ... })`, `local({ ... })` or similar wrappers.
|
||||
- `stop()`, `on.exit()`, `tryCatch` handlers and other non-local exits are ordinary calls: no throw edges and no finaliser routing. A `break` / `next` with no enclosing loop falls back to EXIT.
|
||||
- Function join: a CFG is anchored at the `function_definition` token, while the `Function` node starts at the whole assignment. They share a start line for `f <- function`, `f = function` and `f <- \(x)`, but not for `f <-` followed by `function` on the next line; there the CFG is still emitted and its blocks lie inside the `Function` node's span, but the exact start-line join used by taint summaries does not resolve (inert for R today, since reaching definitions have no facts). Anonymous functions have a CFG but no `Function` node.
|
||||
|
||||
## Rename
|
||||
|
||||
`languages/r/rename.ts` gives the `rename` tool a deliberately narrow, fail-closed R provider (fixtures under `test/fixtures/rename-integration/r/`). R binds names dynamically, so only what the scope graph proves is renamed.
|
||||
|
||||
- **Succeeds:** a plain function (`name <- function(...)`) and the calls the graph resolves to it, in one file or across files; a package function that `NAMESPACE` leaves private is included. Every parameter is claimed as a binding, whatever its default, so a parameter of the old name that the body never uses is left alone (`parameter-read-only`); a call of the name under a same-named parameter is refused (see below), and so is a body that reads it (`incomplete_semantics`, below).
|
||||
- **Refused (`unsupported_occurrence`, planning status `unsupported`):**
|
||||
- any target that is not a plain `Function` (S4 classes, R6 classes, members);
|
||||
- a function whose old or new name is the whole value of a string literal anywhere in the indexed R sources (`UseMethod("area")`, `do.call("f", ...)`, `match.fun`, `get`, `setGeneric`, ...), because string lookup is not resolved. The literal is decoded first, so escapes (`"f\x6fo"`, `"\u{66}oo"`, octal) and raw strings (`r"(foo)"`) count. A string that contains a call of the name (`"foo(1)"`, glue or cli interpolation such as `"{foo(x)}"`, `parse(text = "foo(1)")`, a formula string) is refused too (`unsupported_occurrence`); a string that only mentions the name as a word, `{name}` value interpolation, a name built at run time (`paste0("fo", "o")`), and strings in files that are not indexed R sources (R Markdown, `NAMESPACE`) are not detected;
|
||||
- a new name `generic.class` where any dot-separated prefix is a function defined in the indexed R sources or a known base generic, which would register an S3 method (`to.json.foo` for a defined `to.json`, `print.foo`). The known generics are the S3 group generics (`Ops`, `Math`, `Summary`, `Complex`, `matrixOps`) with every function each group dispatches (`sum.items`, `abs.diff`), the `is.*` and `as.*` generics (`is.numeric.foo`, `as.complex.foo`), and the common generics of base, stats, graphics and utils (`print`, `format`, `summary`, `plot`, `unlist`, `pretty`, `weighted.mean`, ...). Generics of other packages (`tidy`, `filter`, ...) are not listed;
|
||||
- an infix function or operator, as the old or the new name (`` `%+%` ``, `` `%||%` ``, `` `+` ``, `` `[[` ``): `a %+% b` is an operator token, not an identifier, so a rename would edit only the definition;
|
||||
- a replacement function and its getter (`unsupported_occurrence`): a call of the name anywhere in an assignment target (`helper(z) <- 5`, `helper(z)$a <- 1`, `names(helper(z)) <- v`, the `->` forms), because R then calls `` `helper<-` ``; an old or new name ending in `<-`; and a string-defined setter (`"helper<-" <- function(...)`) of either name;
|
||||
- a new name that is already a global (`name_collision`): a top-level variable or function of that name in any indexed file (`assist <- 3`, a `for` variable, `3 -> assist`), a `<<-` or `->>` to it anywhere, or a function of that name in another file. Same-file function collisions were already refused by the shared engine; local variables, parameters and members of that name are allowed;
|
||||
- a new name that the sources already read as a value or call (`name_collision`): `sapply(x, paste)`, `Reduce(max, x)`, `x <- letters`, a default `f = assist`, a formula term, `assist[1]`, `assist(1)`. After the rename the function is the global of that name and would capture the read. A parameter of an enclosing function of that name covers a plain read but not a call: a default proves nothing about the argument, so a call of the new name under such a parameter may reach the global the renamed function becomes. Not reads: bound names, argument names (`f(assist = 1)`), members (`x$assist`, `x@assist`) and qualified names (`pkg::assist`);
|
||||
- a call of the name under a same-named parameter (`ambiguous_reference`), whatever its default (none, a constant, `NULL`, a function or a computed value) and whether or not the body assigns it, because the call may reach the parameter or the function being renamed;
|
||||
- a function while a same-named parameter is read in a body (`function(x, helper) x + helper`), or a `for` variable of that name is called in a loop (`incomplete_semantics`; fixtures `parameter-body-read`, `for-variable-call`): no edge covers the occurrence, so the planner stops; it never renames and leaves the call behind (R-16);
|
||||
- a function body that assigns the name a value that may be a function (`ambiguous_reference`; `helper <- identity`, `helper = f()`, `identity -> helper`, `helper$a <- v`), whether or not the name is called there and wherever the assignment sits relative to a call, because the local may shadow the function being renamed (the call-resolution side treats the same assignments as lexical names, see Calls). A function literal (`helper <- function(y) 2`) is a separate definition and does not refuse when it is defined first (see the local-definition guard below); a constant (`helper <- 2`) is not read as a binding, so the shared engine refuses it as an uncovered occurrence (`incomplete_semantics`);
|
||||
- a function that is also assigned with `<<-` or `->>` anywhere in the indexed R sources (`ambiguous_reference`, as the top-level form already was): the global re-assignment is a second definition that a rename would leave behind;
|
||||
- a package function under `R/` that is not proven private, because `NAMESPACE` is not an indexed source and its entry would not be renamed. Two checks must both pass. The persisted `isExported` flag must be false, which it is only for a function that a `NAMESPACE` file beside the package leaves out of `export()`, `exportPattern()` and `S3method()` (the third argument of `S3method(generic, class, method)` counts): a package with no `NAMESPACE` file, or an `R/` folder that belongs to no package, keeps the default public flag and is refused. That includes projects that keep their functions in `R/` without a `DESCRIPTION` (targets, drake and analysis repositories): every rename of a function under `R/` is refused there, and a function outside `R/` is not. The flag is not enough alone, because an edit of `NAMESPACE` with no `R/` file touched leaves it stale until a full analyse. So every rename plan also reads the package's `NAMESPACE` from disk again, through the parsing the indexer uses, and refuses (`unsupported_occurrence`) when it now exports the name by `export()`, `exportPattern()` or `S3method()`. It fails closed with `incomplete_semantics` when that `NAMESPACE` was deleted or cannot be read, when an `exportPattern()` of it was dropped or ran out of work budget (a name such a pattern was meant to cover is then not provably private), or when the reader skipped or could not read something that may hide an export: a conditional `if (...) export(x)`, a raw-string or multi-line argument, an argument that is an expression, an `S3method()` it cannot read, or a malformed directive that swallowed an export. It also refuses (`unsupported_occurrence`) a function whose roxygen2 block (the `#'` lines directly above the definition) carries `@export`, `@exportMethod`, `@exportS3Method` or another `@export*` tag, because the next `document()` would export the new name; the tag is found by text, other roxygen2 tags (`@rdname`, `@inheritParams`, `@seealso`) are not read. A private function in a package whose `NAMESPACE` is unchanged is renamed; a script outside `R/` is not affected. The check does not need `--force`; re-run `analyze` to bring the flag itself up to date. The persisted flag stays a second check on purpose, although the plan-time read decides the stale-private case on its own: it costs nothing, it needs no NAMESPACE read to answer, and when it is stale the other way round (the index says exported, NAMESPACE no longer does) it refuses until the next analyse, which is the safe direction.
|
||||
- a bare call of the name inside a method of a portable R6 class that defines a member of that name (`ambiguous_reference`): a bare call in a portable method does not see class members, so it reaches the function being renamed but has no proven edge. A class with `portable = FALSE` is not refused;
|
||||
- a name defined at top level in more than one file of the same load unit (`ambiguous_reference`; the same package, or both files outside every package): load order decides which definition callers reach. Definitions inside functions and inside block scopes (`local`, `test_that`, `with`, ...) do not count;
|
||||
- an R file the index does not cover that uses the name, or that cannot be read in full (`incomplete_semantics`). The rename preview walks the repository for `.R`/`.r` files in directories the indexer skips (`bin/`, `build/`, `tmp/`), `.gitignore`d files, files over the indexer's size cap, `.Rprofile` and the `.S`/`.q`/`.s` code files under `R/`, and records their identifier tokens; a file over 8 MB or one that cannot be read fails closed. Not scanned: `.git`, the index store, `node_modules`, `*.Rcheck`, `renv/library`, `packrat/lib*`, whatever `.gitnexusignore` excludes, extensionless Rscript files and R Markdown code. Editing a scanned file between preview and apply makes the apply fail as `resolution_changed`. The tokens of every unindexed file are retained with the plan, so a very large unindexed R tree can push the retained plan past its budget, and the preview then fails closed with `plan_too_large` (measured as an upper bound, treating every file as unindexed: 511 R files, 59,468 tokens, use 4.06% of the 64 MiB budget; the 6,378 R files of this repository, fixtures included, use 117%; R-38);
|
||||
- a function body, or the braces of a block scope (`local`, `test_that`, `describe`, `it`, `with`, `within`, bare or `pkg::`), that defines the name as a function literal, unless the definition is a direct statement of those braces (not under `if`, `switch`, a loop, `tryCatch` or any call) and every call of the name in them, nested functions and blocks included, starts after it (`ambiguous_reference`: a call may run before the local definition). So `test_that("x", { expect_equal(helper(), 1); helper <- function() "stub" })` and `test_that("x", { if (cond) helper <- function() 2; helper() })` refuse: R calls the outer `helper` there, but the index binds every call in the block to the block-local stub, so a rename would leave that call behind (R-43). A definition in a nested function or block is judged by that body alone. A body with no call of the name is not refused, and a call inside the qualifying literal itself (recursion) is ignored (R-39). A top-level call of the name that precedes the top-level definition, in the file or in a script that attaches it, is refused too, because it reaches another function of that name;
|
||||
- a new name that `NAMESPACE` already exports through `export()` or `exportPattern()` (`unsupported_occurrence`): the rename would change the package API;
|
||||
- a generated file (`unsupported_occurrence`): the target file, or any indexed R file in which the name occurs as an identifier, has \"Generated by\" or \"do not edit by hand\" in its first five lines (`RcppExports.R`, `cpp11.R`), because the generator would restore the old name;
|
||||
- a call of the name inside a `setRefClass(..., methods = list(...))` method of a class with `contains =` when a class of the chain defines a method or field of that name, or when a parent is not indexed (other than `envRefClass` and `VIRTUAL`) (`ambiguous_reference`): the call may reach the inherited member;
|
||||
- any `x$methods(...)` call (methods added to a RefClass generator after `setRefClass()`) whose arguments mention the name (`ambiguous_reference`): such methods are not modelled (Limits and known gaps; R-44), so a call inside one is bound to the function being renamed although R reaches a class method of that name first, and a method of the name added there would shadow the function in every method of the class. The receiver is not traced to a `setRefClass()` call;
|
||||
- a roxygen2 `@rawNamespace` line, or an `@export <name>` line, of the package that names the function (`unsupported_occurrence`): the next `document()` would write the old name into `NAMESPACE`.
|
||||
- **Not updated by a successful rename:** files that are not indexed R sources. R Markdown and Quarto documents (`.Rmd`, `.qmd`, vignettes, `README.Rmd`), `man/*.Rd` (`\alias`, `\usage`), roxygen2 tags that name the function (`@rdname`, `@inheritParams`, `@seealso`), `_pkgdown.yml`, `pkg:::name` uses in `inst/`, and C++ or other code that looks it up by string keep the old name. So does code that reads the name off the call: `match.call()`, `deparse(substitute(f))` and snapshot tests of printed calls (R-37). The result does not list them; search for the old name in those files after a rename.
|
||||
- **Refused by the shared engine (fail-closed, not provider code):** S3 methods and any function name with a dot after its first character (`area.circle` reads as an owner-qualified member, so `stale_graph`). A name whose only dot is the leading one (`.helper`, the usual internal-function convention) is renamed like any other function, with one exception: R's hook functions `.onLoad`, `.onAttach`, `.onUnload`, `.onDetach`, `.First`, `.Last`, `.First.sys`, `.First.lib` and `.Last.lib` are refused (`unsupported_occurrence`), because R calls them by name; S4 class names (`unsupported_occurrence`, no exact source-name span).
|
||||
- **Invalid names (`invalid_name`):** anything that is not a nonreserved R identifier: the reserved words, `...` and `..1`, `..2`, ..., and any name with a non-ASCII character, because new names are limited to ASCII letters, digits, `.` and `_` (conservative by design; the message says so). An old name made only of non-ASCII characters (`λ`) is refused as not a supported identifier. Backtick-quoting a new name is never offered: a name that needs it is refused rather than quoted at every use.
|
||||
- **Refused when any indexed R file does not parse (`parse_error`):** the rename plan parses every indexed R file, and a syntax error in one of them, related to the target or not, refuses the rename and names that file. Fix the file (or exclude it) and plan again.
|
||||
- **Not proven:** unqualified calls to a function defined in another file (including the package `R/` layout) fail with `ambiguous_reference`, because R's global-name fallback is not a provable binding. Renaming across files works only where the call is qualified or otherwise resolved.
|
||||
|
||||
## Type annotations
|
||||
|
||||
R has no inline type annotations, so the "Type Annotations" column of the language support table is not ticked for R. The roxygen2 `@param` extraction above is a comment-based fallback, in the same class as the JSDoc, PHPDoc and YARD fallbacks listed in type-resolution-system.md (Ruby's YARD support is likewise not ticked).
|
||||
|
||||
## Extending the provider
|
||||
|
||||
- Definition captures are in `R_QUERIES` (`tree-sitter-queries.ts`); scope, import, type-binding, heritage and reference captures are in `languages/r/query.ts`. Keep each declaration pattern and its matching `@scope.function` pattern in lockstep, as the comments in `query.ts` explain.
|
||||
- The captures for `setClass`, `setRefClass`, `setGeneric`, `setMethod` and for `library` / `require` / `source` are deliberately loose. The argument that names the definition or the import is chosen afterwards in `languages/r/naming-argument.ts`, because a query-level prefix that skips leading comments and named arguments is quadratic in the argument count.
|
||||
- Resolution wiring is in `languages/r/scope-resolver.ts`, registered in `SCOPE_RESOLVERS`. Import resolution is in `import-target.ts`; package discovery and NAMESPACE parsing are in `package-config.ts` and `namespace-imports.ts`; qualified calls in `qualified-call.ts`; owner and export post-processing in `post-parse.ts` and `owner-hooks.ts`.
|
||||
- Changing what the scope query captures changes what cached parsed files contain. There is no counter to raise: the parse-cache identity is derived from the bytes of the owned source roots and the grammar packages, so the change invalidates stale caches by itself. Rebuild `dist` and verify a cold parse followed by warm reuse, as CONTRIBUTING's "Parser changes and cache compatibility" section describes.
|
||||
- Shared code under `gitnexus/src/core/ingestion/` must not name languages; add behaviour through the provider hooks.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests are under `gitnexus/test/unit/` (`r-*.test.ts`, `type-env-r.test.ts` and `scope-resolution/r/`) and integration tests under `gitnexus/test/integration/resolvers/` (`r*.test.ts`), with `r-*` fixture projects in `gitnexus/test/fixtures/lang-resolution/`. They cover:
|
||||
|
||||
- definitions, owners and the naming-argument rules (`r-definition-anchors`, `r-owner-hooks`, `r-post-parse-owners`, `r-scope-naming-argument`, `r-s4-anchors`, `r-s4-formals`, `r-scope-args`);
|
||||
- NAMESPACE parsing, exports and `exportPattern()` safety and scale (`r-namespace-parser`, `r-namespace-exports`, `r-export-pattern*`, `r-export-status-*`);
|
||||
- package discovery and root-package imports (`r-package-discovery-*`, `r-import-root-package`, `r-namespace-imports`);
|
||||
- qualified calls, exports and visibility, dotted S3 names and native pipes (`r-qualified-*`, `r-qualified-exports`, `r-qualified-visibility`, `r-dotted-s3-collision`, `r-native-pipes` fixture in `r.test.ts`);
|
||||
- receivers, `self` and chains (`r-self-binding`, `r-chained-receivers`, `r-receiver-chain`, `r-call-shapes`, `r-receiver-agreement`, `r-typed-receiver-members`) and the receiver corpus (`r-receiver-corpus`: one table of call shapes with exact expected targets, decoys and recorded outcomes, summarised in [r-ingestion-gaps.md](r-ingestion-gaps.md));
|
||||
- lexical names, block scopes, assignments and replacement calls (`r-lexical-names`, `r-formal-shadows`, `r-assignment-locals`, `r-rename-assigned-locals`, `r-replacement-calls`, `r-block-scopes`);
|
||||
- package visibility, S3 registration and S4 generators (`r-package-visibility`, `r-s3method-registration`, `r-s4-generator`);
|
||||
- work bounds and pathological inputs (`r-replacement-scaling`, `r-pathological-inputs`);
|
||||
- the shared free-call ordering that lets a qualified hit outrank a lexical name claim (`free-call-qualified-over-name-claim`, `rust-qualified-over-local`, `php-qualified-over-local`);
|
||||
- rename (`rename-provider-r` and `rename-provider-r-part2` to `-part5`, which deal one fixture list into five parts so each shard stays inside the CI time budget; only part 1 runs on the Windows and macOS shards, because the Windows command line has no room for the other four; `r-rename-*`, including `r-rename-unindexed-sources`);
|
||||
- roxygen2 `@param` types (`type-env-r`, `r-roxygen-param`);
|
||||
- control flow (`test/unit/cfg/r-visitor.test.ts`, `test/integration/cfg/r-cfg-join.test.ts`).
|
||||
|
|
@ -37,7 +37,8 @@ tests. No active shape corpus reported GRAMMAR-UNAVAILABLE.
|
|||
The visible-gap counts include the three deliberate controls: JavaScript and
|
||||
Ruby `untypedChain`, and PHP `arrowCallChain`. They must remain unresolved.
|
||||
Zig and Objective-C still lack shape-arm rows; their dedicated suites were run
|
||||
separately. C and COBOL's all-N/A rows do not measure their ordinary call paths;
|
||||
separately. R has no shape-arm row either; its receiver corpus
|
||||
(`r-receiver-corpus.test.ts`) is described in [r-ingestion-gaps.md](r-ingestion-gaps.md). C and COBOL's all-N/A rows do not measure their ordinary call paths;
|
||||
their focused integration suites were also run.
|
||||
|
||||
The count arm is unchanged: **138 call drops, 168 total drops, 30 reads, no writes**.
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
|
|||
[SupportedLanguages.Swift]: ['.swift'],
|
||||
[SupportedLanguages.Dart]: ['.dart'],
|
||||
[SupportedLanguages.Vue]: ['.vue'],
|
||||
[SupportedLanguages.R]: ['.r'],
|
||||
[SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
|
||||
[SupportedLanguages.Zig]: ['.zig'],
|
||||
} satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness
|
||||
|
|
@ -127,6 +128,7 @@ const SYNTAX_MAP: Record<SupportedLanguages, string> = {
|
|||
[SupportedLanguages.Swift]: 'swift',
|
||||
[SupportedLanguages.Dart]: 'dart',
|
||||
[SupportedLanguages.Vue]: 'typescript',
|
||||
[SupportedLanguages.R]: 'r',
|
||||
[SupportedLanguages.Cobol]: 'cobol',
|
||||
[SupportedLanguages.Zig]: 'zig',
|
||||
} satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export enum SupportedLanguages {
|
|||
Swift = 'swift',
|
||||
Dart = 'dart',
|
||||
Vue = 'vue',
|
||||
R = 'r',
|
||||
/** Standalone regex processor — no tree-sitter, no LanguageProvider. */
|
||||
Cobol = 'cobol',
|
||||
Zig = 'zig',
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
*
|
||||
* Added after Ring 1: zig enters as `experimental` (new language
|
||||
* integration; promotion to `production` is a separate governance PR).
|
||||
* Same for r, added later.
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from '../languages.js';
|
||||
|
|
@ -47,6 +48,7 @@ export const LanguageClassifications: Readonly<Record<SupportedLanguages, Langua
|
|||
[SupportedLanguages.Vue]: 'experimental',
|
||||
[SupportedLanguages.Cobol]: 'experimental',
|
||||
[SupportedLanguages.Zig]: 'experimental',
|
||||
[SupportedLanguages.R]: 'experimental',
|
||||
};
|
||||
|
||||
/** Convenience predicate: is this language gating Ring 4 retirement? */
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@ All notable changes to GitNexus will be documented in this file.
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **R language support (experimental)** — `.r` and `.R` files are indexed with the `@eagleoutice/tree-sitter-r` grammar and resolved through the scope-resolution pipeline: functions, S4, R6 and RefClass classes and members, `library()` / `source()` / NAMESPACE imports and exports, `pkg::fn` calls, typed `obj$method()` and `self$method()` receivers, plus a fail-closed `rename` provider. Boundaries and known wrong or lost results are inventoried in `docs/languages/r-ingestion-gaps.md` (#3513)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Qualified free calls outrank a lexical name claim** — the shared free-call pass resolves a qualified site before it applies a lexical name claim; Rust and PHP implement the same hook, publish no such claim, and measure unchanged (#3513)
|
||||
- **MCP `query` / `context` / `impact` / `cypher` always attach a ref-carrying `staleness` field** — object results include it even when `status` is `current`. Absence is no longer the freshness signal: read `staleness.status` (`behind`/`diverged` vs `current`/`unknown`) and `branch`/`lastCommit` for which index answered. `list_repos` and the HTTP repo routes are unchanged (still omit `staleness` when current; the ref is top-level) (#3291, #3293)
|
||||
- **MCP `query` keeps one `process_symbols` row per `(id, process_id)`** — a symbol in more than one execution flow stays on each process card, and `symbol_count` is the number of those emitted rows after `max_symbols`. A `repo` of `@<group>` returns `{ group, query, results, per_repo }` and does not include `process_symbols`; `results[].symbol_count` is that member's post-slice count, and a `service` prefix counts only attaches under the prefix. Query `@<group>/<memberPath>` for that member's attach rows (#3351)
|
||||
|
||||
|
|
|
|||
|
|
@ -515,7 +515,7 @@ GitNexus supports indexing multiple repositories. Each `gitnexus analyze` regist
|
|||
|
||||
## Supported Languages
|
||||
|
||||
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby, Dart, Zig
|
||||
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby, Dart, Zig, R
|
||||
|
||||
### Language Feature Matrix
|
||||
|
||||
|
|
@ -536,9 +536,12 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift,
|
|||
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| Zig | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | — | ✓ |
|
||||
| R (experimental) | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | — | — |
|
||||
|
||||
**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, S4 classes, R6 classes and RefClass (`setRefClass`) classes with their methods and fields, and resolves `library()` / `require()` / `source()` imports to local packages and files, `obj$method()` and `pkg::fn()` calls, R6 `inherit=` / S4 `contains=` heritage and NAMESPACE-driven export status. Calls through function values, `do.call()`, infix operators and S3 dispatch are not linked, and `.Rmd` files, `box::use()` and external (CRAN) packages are not supported. With `--pdg` it also builds a control-flow graph (control dependence only; R has no reaching definitions), and `rename` handles plain functions while refusing classes, members, S3 methods, exported package functions and names spelled as strings. See [docs/languages/r-provider.md](../docs/languages/r-provider.md) for the full coverage and limits.
|
||||
|
||||
## Agent Skills
|
||||
|
||||
GitNexus ships with skill files that teach AI agents how to use the tools effectively:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -5,8 +5,8 @@
|
|||
* — over ONE shared corpus so the arms are directly comparable. One arm per
|
||||
* registered language, plus a second `csharp` arm carrying csproj configs
|
||||
* (#2902), so there is one more arm than there are languages. The newest row
|
||||
* is `zig` (PR #1432), added the day its resolver registered — the inventory
|
||||
* arm below is what noticed it missing, which is the arm doing its job.
|
||||
* is `r`, which registered without a row and was reported by the inventory arm
|
||||
* below — the arm doing its job — after `zig` (PR #1432), which did the same.
|
||||
*
|
||||
* NO LANGUAGE IS OMITTED, and that is the point of the list rather than an
|
||||
* accident of it. Nine of these arms (go, csharp, csharp_csproj, dart, ruby,
|
||||
|
|
@ -127,6 +127,19 @@
|
|||
* path-walking resolver alone and does not move when that config parsing
|
||||
* changes.
|
||||
*
|
||||
* - r: one resolver behind three import forms, and the arm builds all three
|
||||
* (see `uniqueTarget`): `library(pkg)` (a wildcard that resolves to every
|
||||
* file of the package's `R/`), `source("R/file.R")` (a side-effect import
|
||||
* answered by the suffix index after an exact-join miss) and a NAMESPACE
|
||||
* `importFrom(pkg, name)` (a named import that reads the parsed workspace,
|
||||
* spelled `pkg::name` in the corpus). It passes the package map a
|
||||
* `DESCRIPTION` scan yields and is a `CONTEXT_LANGS` member. Two of its
|
||||
* legs are O(files) per import rather than keyed (a local `library()` scans
|
||||
* the file list for the `R/` prefix; every `source()` path runs
|
||||
* `normalizedFileList.indexOf` before the index), which is why R reads the
|
||||
* highest scaling of the linear-budget languages; `_r_arm` in
|
||||
* baselines.json has the per-leg numbers.
|
||||
*
|
||||
* Two properties of the corpus are load-bearing and must not be "simplified":
|
||||
*
|
||||
* 1. **Most imports are unresolvable.** In real source the majority of imports
|
||||
|
|
@ -494,6 +507,7 @@ import { typescriptScopeResolver } from '../../src/core/ingestion/languages/type
|
|||
import { cScopeResolver } from '../../src/core/ingestion/languages/c/scope-resolver.ts';
|
||||
import { cppScopeResolver } from '../../src/core/ingestion/languages/cpp/scope-resolver.ts';
|
||||
import { objectiveCScopeResolver } from '../../src/core/ingestion/languages/objective-c/scope-resolver.ts';
|
||||
import { rScopeResolver } from '../../src/core/ingestion/languages/r/scope-resolver.ts';
|
||||
import { csharpScopeResolver } from '../../src/core/ingestion/languages/csharp/scope-resolver.ts';
|
||||
// `SCOPE_RESOLVERS` is NOT imported here — see the inventory arm at the bottom,
|
||||
// which loads it dynamically. Statically it costs 6-10 s of module load
|
||||
|
|
@ -606,6 +620,9 @@ const HEAP_BUDGETED = [
|
|||
'objc',
|
||||
// Lexical module lookup retains a workspace index over parsed Rust files.
|
||||
'rust',
|
||||
// R retains the shared workspace file index (57.26 MiB at 32000 files, ratio
|
||||
// 0.970 against the 1.25 budget), so it takes the full ceiling + floor + ratio.
|
||||
'r',
|
||||
];
|
||||
// javascript, typescript and vue were budgeted here until #2953 and are now
|
||||
// BOUNDED, which is a demotion in gate strength and a promotion in what the
|
||||
|
|
@ -645,6 +662,7 @@ const CONTEXT_LANGS = [
|
|||
'cpp',
|
||||
'csharp',
|
||||
'csharp_csproj',
|
||||
'r',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -788,6 +806,7 @@ const EXTENSION = {
|
|||
cpp: '.cpp',
|
||||
objc: '.m',
|
||||
zig: '.zig',
|
||||
r: '.R',
|
||||
};
|
||||
/** C and C++ resolve `#include` against HEADERS, which reach the resolver
|
||||
* through `resolutionConfig` rather than through `allFilePaths` — see
|
||||
|
|
@ -920,6 +939,12 @@ function uniqueDir(lang, d, i) {
|
|||
// share the other unique arms take from a nested directory comes from the
|
||||
// target instead (see `uniqueTarget`).
|
||||
if (lang === 'zig') return `src/mod${d}`;
|
||||
// An R package keeps its sources in `<pkg>/R/` and nothing else in the
|
||||
// namespace, so one package per index with its files flat under `R/` is the
|
||||
// layout `library()` (every file of `<pkg>/R/`) and `importFrom()` (the file
|
||||
// defining one name) both address. Flat on purpose: R has no nested source
|
||||
// directories inside `R/`, so there is no `d % 7` nested slice to mint.
|
||||
if (lang === 'r') return `pkg${d}/R`;
|
||||
throw unwiredLanguage('uniqueDir', lang);
|
||||
}
|
||||
|
||||
|
|
@ -1015,6 +1040,11 @@ function collideDir(lang, d, i) {
|
|||
// axis that CAN grow — `collideTarget` spells its imports up through the
|
||||
// tree and back down, ~4x the components of the unique arm.
|
||||
if (lang === 'zig') return `src/l0/l1/l2/l3/l4/mod${d}`;
|
||||
// `svc{d}/R/mod{k}.R` in every package: the conventional flat `R/` directory
|
||||
// with a repeated basename per package, so the `source("R/mod{k}.R")` spelling
|
||||
// below is ambiguous across every package and its suffix lookup answers from
|
||||
// a bucket that grows with the corpus.
|
||||
if (lang === 'r') return `svc${d}/R`;
|
||||
throw unwiredLanguage('collideDir', lang);
|
||||
}
|
||||
|
||||
|
|
@ -1069,7 +1099,8 @@ function buildFiles(lang, fileCount, pad, shape) {
|
|||
layout === 'vue' ||
|
||||
layout === 'python' ||
|
||||
layout === 'c' ||
|
||||
layout === 'cpp';
|
||||
layout === 'cpp' ||
|
||||
layout === 'r';
|
||||
const [fileStem, modStem] = PASCAL_CASE_FILES.has(layout) ? ['File', 'Mod'] : ['file', 'mod'];
|
||||
let stem =
|
||||
shape === 'collide' && collideStem ? `${modStem}${Math.floor(i / dirs)}` : `${fileStem}${i}`;
|
||||
|
|
@ -1190,6 +1221,58 @@ const kotlinProbeFile = (filePath, packageName, exportName) => {
|
|||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* An R file whose Module scope binds each of `names` to a top-level function,
|
||||
* which is exactly what `rFileTopLevel` reads — and ONLY that, because
|
||||
* `localDefs` also lists nested functions and R6 members and cannot answer "is
|
||||
* this defined at top level". The resolver reads the bindings, never the def's
|
||||
* other fields, so the rest of the shape is the inert `probeFile` one.
|
||||
*/
|
||||
const rProbeFile = (filePath, names) => {
|
||||
const base = probeFile(
|
||||
filePath,
|
||||
names.map((name) => ['Function', name]),
|
||||
);
|
||||
const moduleScope = `module:${filePath}`;
|
||||
return {
|
||||
...base,
|
||||
moduleScope,
|
||||
scopes: [
|
||||
{
|
||||
id: moduleScope,
|
||||
parent: null,
|
||||
kind: 'Module',
|
||||
range: { startLine: 1, startCol: 0, endLine: 1, endCol: 1 },
|
||||
filePath,
|
||||
bindings: new Map(
|
||||
base.localDefs.map((def) => [def.qualifiedName, [{ def, origin: 'local' }]]),
|
||||
),
|
||||
ownedDefs: base.localDefs,
|
||||
imports: [],
|
||||
typeBindings: new Map(),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* What `loadRPackageConfig` reads from disk (one `DESCRIPTION` per package),
|
||||
* rebuilt from the corpus paths: every `<dir>/R/` is the `R/` directory of the
|
||||
* package named after its last component. `namespaceInfoByPackageDir` is empty
|
||||
* — no NAMESPACE file — so `importFrom()` exports are not filtered, which is
|
||||
* the leg that reads `parsedFiles` and is the one this arm exists to measure.
|
||||
*/
|
||||
const rPackageConfigFor = (files) => {
|
||||
const packages = new Map();
|
||||
for (const file of files) {
|
||||
const at = file.indexOf('/R/');
|
||||
if (at < 0) continue;
|
||||
const dir = file.slice(0, at);
|
||||
packages.set(dir.slice(dir.lastIndexOf('/') + 1), dir);
|
||||
}
|
||||
return { packages, namespaceInfoByPackageDir: new Map() };
|
||||
};
|
||||
|
||||
function javaBenchmarkPackage(filePath) {
|
||||
const uniquePackage = /\/com\/example\/(pkg\d+)(?:\/|$)/.exec(`/${filePath}`)?.[1];
|
||||
if (uniquePackage !== undefined) return `com.example.${uniquePackage}`;
|
||||
|
|
@ -1238,6 +1321,17 @@ function buildParsedFiles(lang, files) {
|
|||
parsedFiles.push(kotlinProbeFile(filePath, kotlinBenchmarkPackage(filePath), stem));
|
||||
continue;
|
||||
}
|
||||
if (lang === 'r') {
|
||||
// One top-level function named for the file, plus an S3 method on it
|
||||
// (`file7.print`), which is a dotted top-level name: the resolver's
|
||||
// `dottedTails` guard reads it, and its tail (`print`) never equals a
|
||||
// name an import asks for, so the guard runs on every lookup and refuses
|
||||
// none.
|
||||
const slash = filePath.lastIndexOf('/');
|
||||
const stem = filePath.slice(slash + 1, filePath.lastIndexOf('.'));
|
||||
parsedFiles.push(rProbeFile(filePath, [stem, `${stem}.print`]));
|
||||
continue;
|
||||
}
|
||||
const slash = filePath.lastIndexOf('/');
|
||||
const stem = filePath.slice(slash + 1, filePath.lastIndexOf('.'));
|
||||
const parent = slash < 0 ? '' : filePath.slice(0, slash);
|
||||
|
|
@ -1467,6 +1561,32 @@ function uniqueTarget(lang, { local, r, d, j, dirs }) {
|
|||
if (miss === 1) return `ghost${(r >>> 4) % 97}`;
|
||||
return `../vendor${(r >>> 4) % 97}/missing.zig`;
|
||||
}
|
||||
if (lang === 'r') {
|
||||
// Three import forms, one per leg of the resolver, in the shares R code
|
||||
// actually has them:
|
||||
// - `library(pkg)` — `pkg{d}` is a LOCAL package and resolves to every
|
||||
// file of its `R/` directory (a list); an external one (`dplyr`) is
|
||||
// refused by the wildcard guard before any lookup;
|
||||
// - `source("R/file{j}.R")` — the project-root spelling. The importer's
|
||||
// own directory does not hold `R/R/…`, so the exact-join probe misses
|
||||
// and the suffix index answers; an external one is a vendored path in
|
||||
// no package, which runs the whole cascade to null;
|
||||
// - `importFrom(pkg, name)` — spelled `pkg::name` in this corpus because
|
||||
// the import list is `[from, target]` pairs and `resolveOne` splits it
|
||||
// back (`::` is not legal in a package name, so the split is exact).
|
||||
// Resolves through the parsed workspace's top-level definitions; an
|
||||
// external package is not in the config and answers null.
|
||||
if (local) {
|
||||
const leg = (r >>> 3) % 3;
|
||||
if (leg === 0) return `pkg${d}`;
|
||||
if (leg === 1) return `R/file${j}.R`;
|
||||
return `pkg${j % dirs}::file${j}`;
|
||||
}
|
||||
const miss = (r >>> 3) % 3;
|
||||
if (miss === 0) return ['dplyr', 'ggplot2', 'data.table', 'stringr'][(r >>> 4) % 4];
|
||||
if (miss === 1) return `vendor${(r >>> 4) % 97}/R/missing.R`;
|
||||
return `ext${(r >>> 4) % 97}::tidy`;
|
||||
}
|
||||
throw unwiredLanguage('uniqueTarget', lang);
|
||||
}
|
||||
|
||||
|
|
@ -1700,6 +1820,27 @@ function collideTarget(lang, { local, r, d, j, dirs }) {
|
|||
if (miss === 1) return `ghost${(r >>> 4) % 97}`;
|
||||
return `${up}/vendor${(r >>> 4) % 97}/missing.zig`;
|
||||
}
|
||||
if (lang === 'r') {
|
||||
// The same three forms in the same proportions as the unique arm, so the
|
||||
// resolved count is identical by construction (asserted). The source
|
||||
// spelling is the one that collides: `R/mod{k}.R` matches the file of that
|
||||
// name in EVERY package, so the suffix lookup is ambiguous across `dirs`
|
||||
// packages — the project-root `source("R/utils.R")` shape a multi-package
|
||||
// repo really has. The external spellings are unchanged and deliberately
|
||||
// share no stem with a real file, or they would suffix-match and stop
|
||||
// being misses.
|
||||
const k = Math.floor(j / dirs);
|
||||
if (local) {
|
||||
const leg = (r >>> 3) % 3;
|
||||
if (leg === 0) return `svc${d}`;
|
||||
if (leg === 1) return `R/mod${k}.R`;
|
||||
return `svc${j % dirs}::mod${k}`;
|
||||
}
|
||||
const miss = (r >>> 3) % 3;
|
||||
if (miss === 0) return ['dplyr', 'ggplot2', 'data.table', 'stringr'][(r >>> 4) % 4];
|
||||
if (miss === 1) return `vendor${(r >>> 4) % 97}/R/missing.R`;
|
||||
return `ext${(r >>> 4) % 97}::tidy`;
|
||||
}
|
||||
throw unwiredLanguage('collideTarget', lang);
|
||||
}
|
||||
|
||||
|
|
@ -1806,7 +1947,9 @@ function newPass(lang, files, pad = 0) {
|
|||
? phpComposerConfigFor(pad)
|
||||
: lang === 'csharp_csproj'
|
||||
? { csharpConfigs: CSPROJ_CONFIGS }
|
||||
: undefined,
|
||||
: lang === 'r'
|
||||
? rPackageConfigFor(files)
|
||||
: undefined,
|
||||
parsedFiles,
|
||||
};
|
||||
}
|
||||
|
|
@ -1992,6 +2135,33 @@ function resolveOne(lang, from, target, pass) {
|
|||
if (lang === 'objc') {
|
||||
return objectiveCScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config);
|
||||
}
|
||||
// The registered hook, because R's adapter is where its per-pass file index,
|
||||
// the wildcard guard and the `importFrom` branch all live. `parsedImport` is
|
||||
// rebuilt from the target's spelling exactly the way `interpretRImport` and
|
||||
// `populateRNamespaceImports` mint it: `library()` is a `wildcard`, `source()`
|
||||
// a `side-effect`, and a NAMESPACE `importFrom()` a `named` import whose
|
||||
// `targetRaw` is the package. Only the named form reads `parsedFiles`.
|
||||
if (lang === 'r') {
|
||||
const sep = target.indexOf('::');
|
||||
const parsedImport =
|
||||
sep >= 0
|
||||
? {
|
||||
kind: 'named',
|
||||
localName: target.slice(sep + 2),
|
||||
importedName: target.slice(sep + 2),
|
||||
targetRaw: target.slice(0, sep),
|
||||
}
|
||||
: target.endsWith('.R')
|
||||
? { kind: 'side-effect', targetRaw: target }
|
||||
: { kind: 'wildcard', targetRaw: target };
|
||||
return rScopeResolver.resolveImportTarget(
|
||||
parsedImport.targetRaw,
|
||||
from,
|
||||
allFilePaths,
|
||||
pass.config,
|
||||
contextFor(pass, parsedImport),
|
||||
);
|
||||
}
|
||||
if (lang === 'csharp' || lang === 'csharp_csproj') {
|
||||
return csharpScopeResolver.resolveImportTarget(
|
||||
target,
|
||||
|
|
@ -2246,6 +2416,9 @@ const HEAP_PROBE_TARGET = {
|
|||
typescript: 'vendor0/lib/missing',
|
||||
vue: 'vendor0/lib/Missing.vue',
|
||||
cpp: 'vendor0/missing.hpp',
|
||||
// A vendored `source()` path in no package: the exact-join probe misses and
|
||||
// the suffix cascade runs to the end, forcing the workspace file index.
|
||||
r: 'vendor0/R/missing.R',
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -2488,6 +2661,24 @@ const CONTEXT_PROBE = {
|
|||
probeFile('include/util.hpp', []),
|
||||
],
|
||||
},
|
||||
/**
|
||||
* `importFrom(pkgA, f)` where `pkgA` holds `a.R` (defining `f`) and `b.R`
|
||||
* (defining `g`). With the parsed workspace the named import resolves to the
|
||||
* one file that defines `f`; without it the call is the context-free one,
|
||||
* which cannot tell a named import from `library(pkgA)` and answers every
|
||||
* file of the package's `R/`. Two distinct non-null answers, so a dropped
|
||||
* `parsedFiles` cannot look like a miss. `f` is spelled with the `pkg::name`
|
||||
* convention `resolveOne` splits.
|
||||
*/
|
||||
r: {
|
||||
from: 'app/R/main.R',
|
||||
target: 'pkgA::f',
|
||||
parsedFiles: [
|
||||
rProbeFile('app/R/main.R', ['main']),
|
||||
rProbeFile('pkgA/R/a.R', ['f']),
|
||||
rProbeFile('pkgA/R/b.R', ['g']),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Resolve the probe twice through `resolveOne` — once with the pass's parsed
|
||||
|
|
@ -2501,7 +2692,9 @@ function measureContext(lang) {
|
|||
? phpComposerConfigFor(0)
|
||||
: lang === 'csharp_csproj'
|
||||
? { csharpConfigs: CSPROJ_CONFIGS }
|
||||
: undefined;
|
||||
: lang === 'r'
|
||||
? rPackageConfigFor(parsedFiles.map((f) => f.filePath))
|
||||
: undefined;
|
||||
const answer = (files) => {
|
||||
restoreBenchmarkSideChannels(lang, files ?? []);
|
||||
const pass = { allFilePaths, config, parsedFiles: files };
|
||||
|
|
@ -2662,6 +2855,7 @@ const LANG_REGISTRY = {
|
|||
cpp: SupportedLanguages.CPlusPlus,
|
||||
objc: SupportedLanguages.ObjectiveC,
|
||||
zig: SupportedLanguages.Zig,
|
||||
r: SupportedLanguages.R,
|
||||
};
|
||||
const LANGS = Object.keys(LANG_REGISTRY);
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1518,3 +1518,7 @@ Main `c30449394` is merged normally. The regenerated baseline is byte-identical
|
|||
### Typed shape bodies on the #3667 stack parent
|
||||
|
||||
The validated #3667 head `fb6316b90` is merged normally. Outside the three receiver-resolution bench files the source is byte-identical to that parent. The regenerated baseline is byte-identical to the textual merge. Against the parent, only this branch's cells move: JavaScript `plainChain`, `plainDeepChain`, `optionalChain` and `awaitParen`, Ruby `plainChain` and `optionalChain`, and PHP `plainDeepChain` and `optionalChain` go to RESOLVES; the JavaScript and Ruby `untypedChain` controls read VISIBLE-GAP; the three C dispatch cells go to N/A. Ruby `plainDeepChain`, PHP `arrowCallChain` and every `indexElement` cell keep the parent's states. Ruby `indexElement` is VISIBLE-GAP from the parent's walker change. Its `idx_repos` now carries a YARD element type, but Ruby has no `elementTypeOf`. The count arm equals the parent: 138 call drops, 168 across all kinds, 30 reads and zero writes, with 57 in-program, 49 unknown, 30 external and 2 unclassified origins. Fresh package build, both pinned TypeScript configurations and the receiver benchmark update and check pass on the combined source.
|
||||
|
||||
### R receiver typing and rename-guard fixtures (#3513)
|
||||
|
||||
The R audit fixes add R fixtures whose untyped receivers end as `receiver-unresolved` drops: a constructor that types a name only when every binding of it agrees, typed private members, dotted class names and `with()` limits. The count arm moves from 141 to 162 call drops and from 175 to 196 across all kinds; reads (34) and writes (0) are unchanged. All 21 added drops are `.R` files (`callDropsByExtension..R` goes from absent to 21), and no other extension moves. By shape: chain-field 57 to 64, no-chain 39 to 42, chain-call 31 to 34 and unclassified 13 to 21. By origin: in-program 53 to 60, unknown 64 to 70 and unclassified 2 to 10; external stays 22. The bench counts only `suppressed` outcomes with the reason `receiver-unresolved`, so the movement is that reason alone. No R arm is added to the shape corpora; only the count-arm numbers change. The baseline was refreshed with `--update-baseline` and `--check` passes.
|
||||
|
|
|
|||
|
|
@ -202,15 +202,16 @@
|
|||
}
|
||||
},
|
||||
"countArm": {
|
||||
"callDrops": 141,
|
||||
"totalDropsAllKinds": 175,
|
||||
"callDrops": 162,
|
||||
"totalDropsAllKinds": 196,
|
||||
"bySiteKind": {
|
||||
"call": 141,
|
||||
"call": 162,
|
||||
"read": 34
|
||||
},
|
||||
"callDropsByExtension": {
|
||||
".java": 50,
|
||||
".py": 24,
|
||||
".R": 21,
|
||||
".ts": 18,
|
||||
".zig": 10,
|
||||
".kt": 8,
|
||||
|
|
@ -224,17 +225,17 @@
|
|||
".swift": 1
|
||||
},
|
||||
"callDropsByShape": {
|
||||
"chain-field": 57,
|
||||
"no-chain": 39,
|
||||
"chain-call": 31,
|
||||
"<<unclassified>>": 13,
|
||||
"chain-field": 64,
|
||||
"no-chain": 42,
|
||||
"chain-call": 34,
|
||||
"<<unclassified>>": 21,
|
||||
"chain-unwrap": 1
|
||||
},
|
||||
"callDropsByOrigin": {
|
||||
"unknown": 64,
|
||||
"in-program": 53,
|
||||
"unknown": 70,
|
||||
"in-program": 60,
|
||||
"external": 22,
|
||||
"<<unclassified>>": 2
|
||||
"<<unclassified>>": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ plus callers and helpers. Rust also needs a root
|
|||
module file; Objective-C has a header and implementation per entity. Java adds an
|
||||
isolated producer file and exercises transitive factory returns and repeated
|
||||
unreachable return-name lookups. Swift uses same-module visibility; COBOL exercises
|
||||
program references and procedure pointers. Other arms import the preceding file and call through its
|
||||
program references and procedure pointers. R sources the preceding file and calls
|
||||
through an R6 instance; the second call of its chain stays unresolved, because R6
|
||||
method results carry no type. Other arms import the preceding file and call through its
|
||||
return value. These generated sources exercise the analyzer; they are not builds
|
||||
or representative application benchmarks.
|
||||
|
||||
|
|
|
|||
|
|
@ -191,6 +191,22 @@
|
|||
"moduleTypeBindings": 0,
|
||||
"typeBindings": 40999
|
||||
},
|
||||
"r/100/20": {
|
||||
"fingerprint": "862f164acf5e0ddcd5fc1bf8a2f9f3ca29479e97f6bfc8f163abae61197fb334",
|
||||
"nodes": 2299,
|
||||
"edges": 2397,
|
||||
"crossFileCalls": 99,
|
||||
"moduleTypeBindings": 0,
|
||||
"typeBindings": 2198
|
||||
},
|
||||
"r/1000/20": {
|
||||
"fingerprint": "a2fd8b23407c2ee55a99d4754d87eb5297d98d71d94c15431ab0599a1d871883",
|
||||
"nodes": 22999,
|
||||
"edges": 23997,
|
||||
"crossFileCalls": 999,
|
||||
"moduleTypeBindings": 0,
|
||||
"typeBindings": 21998
|
||||
},
|
||||
"ruby/100/20": {
|
||||
"fingerprint": "a2472fd7ca5b2493d24a354b65cd605946b19a00d65df9d5d9788d44da50865c",
|
||||
"nodes": 2299,
|
||||
|
|
|
|||
|
|
@ -175,6 +175,15 @@ ${previous(i, (p) => ` SET TARGET-PTR TO ENTRY 'C${p}'\n CAL
|
|||
PERFORM MAKE-${i}-0
|
||||
GOBACK.
|
||||
${members(n, (j) => ` MAKE-${i}-${j}.\n CONTINUE.`)}
|
||||
`,
|
||||
}),
|
||||
r: (i, n) => ({
|
||||
path: `src/c${i}.R`,
|
||||
content: `${previous(i, (p) => `source("c${p}.R")`)}
|
||||
C${i} <- R6::R6Class("C${i}", public = list(
|
||||
${members(n, (j) => ` make${i}_${j} = function() self${i > 0 || j < n - 1 ? ',' : ''}`)}
|
||||
${previous(i, (p) => ` use_previous = function() {\n value <- C${p}$new()\n value$make${p}_0()$make${p}_1()\n }`)}
|
||||
))
|
||||
`,
|
||||
}),
|
||||
zig: (i, n) => ({
|
||||
|
|
|
|||
22
gitnexus/package-lock.json
generated
22
gitnexus/package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
|||
"name": "gitnexus",
|
||||
"version": "1.6.12",
|
||||
"bundleDependencies": [
|
||||
"@eagleoutice/tree-sitter-r",
|
||||
"tree-sitter",
|
||||
"tree-sitter-c-sharp",
|
||||
"tree-sitter-cpp",
|
||||
|
|
@ -23,6 +24,7 @@
|
|||
"hasInstallScript": true,
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
"@eagleoutice/tree-sitter-r": "1.1.2",
|
||||
"@ladybugdb/core": "0.22.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
|
|
@ -891,6 +893,26 @@
|
|||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eagleoutice/tree-sitter-r": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@eagleoutice/tree-sitter-r/-/tree-sitter-r-1.1.2.tgz",
|
||||
"integrity": "sha512-HR0RyoI5uxcfsdZvTMqSM8GJyGo6DQTkfdgqygQ6N+D0KQObRH4RxYgBZ6ePsGq/36RBAqv8y0NeQvAFL2N4dQ==",
|
||||
"hasInstallScript": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.0.0",
|
||||
"node-gyp-build": "^4.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tree-sitter": "0.25.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tree_sitter": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@
|
|||
"version": "node scripts/sync-plugin-manifests.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eagleoutice/tree-sitter-r": "1.1.2",
|
||||
"@ladybugdb/core": "0.22.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
|
|
@ -164,6 +165,7 @@
|
|||
"node": "^22.18.0 || >=24.11.0"
|
||||
},
|
||||
"bundleDependencies": [
|
||||
"@eagleoutice/tree-sitter-r",
|
||||
"tree-sitter",
|
||||
"tree-sitter-c-sharp",
|
||||
"tree-sitter-cpp",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const SAMPLES = {
|
|||
dart: 'void main() { print(1); }\n',
|
||||
kotlin: 'class A {\n fun m() {}\n}\n',
|
||||
zig: 'pub fn main() void {}\n',
|
||||
r: 'f <- function(x) x\n',
|
||||
};
|
||||
|
||||
async function check(installedDir) {
|
||||
|
|
|
|||
|
|
@ -187,6 +187,9 @@ const LBUG_NATIVE = [
|
|||
'test/integration/rename-provider-objective-c.test.ts',
|
||||
'test/integration/rename-provider-php.test.ts',
|
||||
'test/integration/rename-provider-python.test.ts',
|
||||
// R parts 2-5 run on the Linux coverage shards only: cmd.exe's 8191-char limit leaves no room
|
||||
// (the Windows shard command for this list is already 7827 chars without R).
|
||||
'test/integration/rename-provider-r.test.ts',
|
||||
'test/integration/rename-provider-ruby.test.ts',
|
||||
'test/integration/rename-provider-rust.test.ts',
|
||||
'test/integration/rename-provider-swift.test.ts',
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const { isArm64NapiSharedObject } = require('./arm64-prebuild.cjs');
|
|||
const RUNTIME = '0.25.1';
|
||||
// Upstream metadata is an input to validate, never the peer range we ship.
|
||||
const AUDITED_PEERS = new Map([
|
||||
['@eagleoutice/tree-sitter-r@1.1.2', '^0.21.0'],
|
||||
['tree-sitter-c@0.23.6', '^0.22.1'],
|
||||
['tree-sitter-cpp@0.23.4', '^0.21.1'],
|
||||
['tree-sitter-java@0.23.5', '^0.21.1'],
|
||||
|
|
@ -75,8 +76,10 @@ function prepareTreeSitterBundle(
|
|||
if (manifest.dependencies['tree-sitter'] !== RUNTIME) {
|
||||
throw new Error(`Tree-sitter bundle is only validated for runtime ${RUNTIME}`);
|
||||
}
|
||||
// A grammar is recognised by its package basename, so a scoped package such as
|
||||
// @eagleoutice/tree-sitter-r is held to the same bundling and pinning rules.
|
||||
const grammars = Object.keys(manifest.dependencies).filter(
|
||||
(name) => name === 'tree-sitter' || name.startsWith('tree-sitter-'),
|
||||
(name) => name === 'tree-sitter' || name.split('/').pop().startsWith('tree-sitter-'),
|
||||
);
|
||||
for (const name of grammars) {
|
||||
if (
|
||||
|
|
|
|||
286
gitnexus/src/core/ingestion/cfg/visitors/r.ts
Normal file
286
gitnexus/src/core/ingestion/cfg/visitors/r.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
/**
|
||||
* R CfgVisitor — control flow only (no def/use harvest).
|
||||
*
|
||||
* Walks an R `function_definition` (covers both `function(x)` and the `\(x)`
|
||||
* lambda form; the grammar uses one node type) and drives the language-agnostic
|
||||
* {@link CfgBuilder}. Shapes were checked against the @eagleoutice/tree-sitter-r
|
||||
* grammar:
|
||||
* - `function_definition` fields `parameters` / `body`. The body is either a
|
||||
* `braced_expression` (children carry the field `body`) or a bare expression
|
||||
* (`function(x) x + 1`, `function(x) if (x) 1 else 2`).
|
||||
* - `if_statement` fields `condition` / `consequence` / optional `alternative`
|
||||
* (an `else if` chain is a nested `if_statement` in `alternative`).
|
||||
* - `for_statement` fields `variable` / `sequence` / `body`; `while_statement`
|
||||
* fields `condition` / `body`; `repeat_statement` field `body`.
|
||||
* - `break` and `next` are their own node types; `return(...)` is a `call`
|
||||
* whose `function` field is a `return` node.
|
||||
*
|
||||
* Edge-kind contract (same as the other visitors; CDG consumes it):
|
||||
* - if / else if / else -> `cond-true` / `cond-false`
|
||||
* - for / while / repeat -> `cond-true` / `loop-back` / `cond-false`
|
||||
* - break / next / return(…) -> `break` / `continue` / `return`
|
||||
* - straight-line -> `seq`
|
||||
*
|
||||
* A fall-through arm (no `else`, or an empty `{}` arm) produces no block and is the
|
||||
* header's `seq` edge to the join; control dependence labels it as the complement of
|
||||
* the sibling arm's explicit `cond-true` / `cond-false`, so no branch sense is lost.
|
||||
*
|
||||
* Every loop (including `repeat` and `while (TRUE)`) gets the structural
|
||||
* `header -> loopExit` `cond-false` edge, so EXIT stays reverse-reachable and
|
||||
* the post-dominator / control-dependence pass is never skipped for a function
|
||||
* with a non-terminating loop. For `repeat` that edge is an analysis device, not
|
||||
* a real condition.
|
||||
*
|
||||
* What is modelled: statement-position `if` / `for` / `while` / `repeat` /
|
||||
* `break` / `next` / `return(...)` / bare `{ }` blocks, in function bodies and in
|
||||
* the bodies of those constructs. R functions are values, so a nested
|
||||
* `function_definition` (assigned, anonymous, or an argument) is opaque in its
|
||||
* parent's CFG and gets its own CFG from the pipeline's function collector.
|
||||
*
|
||||
* Known limitations (coverage gaps, deliberately not guessed at):
|
||||
* - NO def/use harvest and no binding table. `finish()` is called without
|
||||
* bindings, so the reaching-defs solver reports `no-facts` for R and emits no
|
||||
* REACHING_DEF edges; taint `sites[]` are likewise absent.
|
||||
* - Control flow nested inside an expression is not lowered: `y <- if (c) a
|
||||
* else b`, a `return()` inside `tryCatch({ ... })` / `local({ ... })` /
|
||||
* `withCallingHandlers`, `switch(...)`, `&&` / `||` short-circuits, and
|
||||
* `ifelse()` all stay a single straight-line block.
|
||||
* - `stop()`, `on.exit()`, `tryCatch` handlers, `Recall`, `quit()` and
|
||||
* non-local exits (`return` from a promise, `break` from `sapply` closures)
|
||||
* are ordinary calls: no `throw` edges, no finalizer routing.
|
||||
* - `return` / `break` / `next` written without parentheses or as arguments
|
||||
* (`invisible(return(x))`) are not recognised as terminators.
|
||||
* - A `break` / `next` with no enclosing loop falls back to EXIT.
|
||||
*
|
||||
* Unlike the harvesting visitors this one does not catch internally: the
|
||||
* pipeline's `collectFunctionCfgs` isolates each function's build, and letting
|
||||
* `CfgNestingDepthError` through keeps it counted as `tooDeeplyNested`.
|
||||
*/
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { CfgBuilder } from '../cfg-builder.js';
|
||||
import { ControlFlowContext } from '../control-flow-context.js';
|
||||
import type { TraversalResult } from '../traversal-result.js';
|
||||
import type { CfgVisitor, FunctionCfg } from '../types.js';
|
||||
|
||||
/** R node types that own a CFG-bearing function body (`function(x)` and `\(x)`). */
|
||||
const R_FUNCTION_TYPES = new Set(['function_definition']);
|
||||
|
||||
/** Statement node types that always break a basic block. */
|
||||
const CONTROL_FLOW_TYPES = new Set([
|
||||
'if_statement',
|
||||
'for_statement',
|
||||
'while_statement',
|
||||
'repeat_statement',
|
||||
'braced_expression',
|
||||
'break',
|
||||
'next',
|
||||
]);
|
||||
|
||||
const startLineOf = (n: SyntaxNode): number => n.startPosition.row + 1;
|
||||
const endLineOf = (n: SyntaxNode): number => n.endPosition.row + 1;
|
||||
|
||||
/** `return(...)` is a call whose callee is the `return` keyword node. */
|
||||
const isReturnCall = (n: SyntaxNode): boolean =>
|
||||
n.type === 'call' && n.childForFieldName('function')?.type === 'return';
|
||||
|
||||
const isControlStatement = (n: SyntaxNode): boolean =>
|
||||
CONTROL_FLOW_TYPES.has(n.type) || isReturnCall(n);
|
||||
|
||||
/** A statement sequence that produced no blocks (empty body) is "transparent". */
|
||||
type SeqResult = TraversalResult | null;
|
||||
|
||||
/** Per-function walk state: one instance per function so loop frames never leak. */
|
||||
class RCfgWalk {
|
||||
private readonly cfc = new ControlFlowContext();
|
||||
|
||||
constructor(private readonly builder: CfgBuilder) {}
|
||||
|
||||
private statementsOf(block: SyntaxNode): SyntaxNode[] {
|
||||
return block.namedChildren.filter((c) => c.type !== 'comment');
|
||||
}
|
||||
|
||||
/** Visit a body that may be a `braced_expression` or a single expression. */
|
||||
private visitBody(node: SyntaxNode | undefined | null): SeqResult {
|
||||
return this.builder.withNesting(() => {
|
||||
if (!node) return null;
|
||||
return this.visitStmt(node);
|
||||
});
|
||||
}
|
||||
|
||||
/** Wire a sequence of statements, coalescing straight-line runs into blocks. */
|
||||
visitSeq(stmts: SyntaxNode[]): SeqResult {
|
||||
return this.builder.withNesting(() => {
|
||||
let entry: number | undefined;
|
||||
let dangling: number[] = [];
|
||||
let openSimple: number | undefined;
|
||||
|
||||
for (const stmt of stmts) {
|
||||
if (isControlStatement(stmt)) {
|
||||
openSimple = undefined; // close any open straight-line block
|
||||
const res = this.visitStmt(stmt);
|
||||
if (res === null) continue; // transparent (empty `{}`)
|
||||
if (entry === undefined) entry = res.entry;
|
||||
else this.builder.connect(dangling, res.entry, 'seq');
|
||||
dangling = [...res.exits];
|
||||
} else if (openSimple === undefined) {
|
||||
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
|
||||
if (entry === undefined) entry = idx;
|
||||
else this.builder.connect(dangling, idx, 'seq');
|
||||
openSimple = idx;
|
||||
dangling = [idx];
|
||||
} else {
|
||||
this.builder.extendBlock(openSimple, endLineOf(stmt), stmt.text);
|
||||
}
|
||||
}
|
||||
|
||||
if (entry === undefined) return null;
|
||||
return { entry, exits: dangling };
|
||||
});
|
||||
}
|
||||
|
||||
/** Dispatch one statement to its handler. Non-null except for an empty `{}`. */
|
||||
visitStmt(stmt: SyntaxNode): SeqResult {
|
||||
switch (stmt.type) {
|
||||
case 'if_statement':
|
||||
return this.visitIf(stmt);
|
||||
case 'for_statement':
|
||||
return this.visitFor(stmt);
|
||||
case 'while_statement':
|
||||
return this.visitWhile(stmt);
|
||||
case 'repeat_statement':
|
||||
return this.visitRepeat(stmt);
|
||||
case 'braced_expression':
|
||||
return this.visitSeq(this.statementsOf(stmt));
|
||||
case 'break':
|
||||
return this.visitJump(stmt, 'break');
|
||||
case 'next':
|
||||
return this.visitJump(stmt, 'continue');
|
||||
default:
|
||||
if (isReturnCall(stmt)) return this.visitReturn(stmt);
|
||||
return this.visitSimple(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
private visitSimple(stmt: SyntaxNode): TraversalResult {
|
||||
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
|
||||
return { entry: idx, exits: [idx] };
|
||||
}
|
||||
|
||||
/** `return(expr)` -> EXIT. */
|
||||
private visitReturn(stmt: SyntaxNode): TraversalResult {
|
||||
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
|
||||
this.builder.edge(idx, this.builder.exitIndex, 'return');
|
||||
return { entry: idx, exits: [] };
|
||||
}
|
||||
|
||||
/** `break` -> loop exit, `next` -> loop header; EXIT when no loop encloses it. */
|
||||
private visitJump(stmt: SyntaxNode, kind: 'break' | 'continue'): TraversalResult {
|
||||
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
|
||||
const res = kind === 'break' ? this.cfc.resolveBreak() : this.cfc.resolveContinue();
|
||||
this.builder.edge(idx, res?.target ?? this.builder.exitIndex, kind);
|
||||
return { entry: idx, exits: [] };
|
||||
}
|
||||
|
||||
/** `if (cond) a [else b]`; an `else if` chain recurses through `alternative`. */
|
||||
private visitIf(stmt: SyntaxNode): TraversalResult {
|
||||
const cond = stmt.childForFieldName('condition') ?? stmt;
|
||||
const header = this.builder.newBlock(startLineOf(stmt), endLineOf(cond), cond.text);
|
||||
|
||||
const exits: number[] = [];
|
||||
const thenRes = this.visitBody(stmt.childForFieldName('consequence'));
|
||||
if (thenRes) {
|
||||
this.builder.edge(header, thenRes.entry, 'cond-true');
|
||||
exits.push(...thenRes.exits);
|
||||
} else {
|
||||
exits.push(header); // empty then — true path falls through
|
||||
}
|
||||
|
||||
const alternative = stmt.childForFieldName('alternative');
|
||||
const elseRes = alternative ? this.visitBody(alternative) : null;
|
||||
if (elseRes) {
|
||||
this.builder.edge(header, elseRes.entry, 'cond-false');
|
||||
exits.push(...elseRes.exits);
|
||||
} else {
|
||||
exits.push(header); // no (or empty) else — false path falls through
|
||||
}
|
||||
|
||||
return { entry: header, exits: [...new Set(exits)] };
|
||||
}
|
||||
|
||||
/** Shared loop wiring: header tests, body loops back, structural exit edge. */
|
||||
private visitLoop(stmt: SyntaxNode, headerText: string, headerEndLine: number): TraversalResult {
|
||||
const header = this.builder.newBlock(startLineOf(stmt), headerEndLine, headerText);
|
||||
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
|
||||
|
||||
this.cfc.pushLoop(header, loopExit, []);
|
||||
const body = this.visitBody(stmt.childForFieldName('body'));
|
||||
this.cfc.pop();
|
||||
|
||||
if (body) {
|
||||
this.builder.edge(header, body.entry, 'cond-true');
|
||||
this.builder.connect(body.exits, header, 'loop-back');
|
||||
} else {
|
||||
this.builder.edge(header, header, 'loop-back'); // empty body re-tests
|
||||
}
|
||||
// Structural escape: keeps EXIT reverse-reachable even for `repeat`/`while (TRUE)`.
|
||||
this.builder.edge(header, loopExit, 'cond-false');
|
||||
return { entry: header, exits: [loopExit] };
|
||||
}
|
||||
|
||||
private visitFor(stmt: SyntaxNode): TraversalResult {
|
||||
const variable = stmt.childForFieldName('variable');
|
||||
const sequence = stmt.childForFieldName('sequence');
|
||||
const text = `${variable?.text ?? ''} in ${sequence?.text ?? ''}`;
|
||||
return this.visitLoop(stmt, text, sequence ? endLineOf(sequence) : startLineOf(stmt));
|
||||
}
|
||||
|
||||
private visitWhile(stmt: SyntaxNode): TraversalResult {
|
||||
const cond = stmt.childForFieldName('condition') ?? stmt;
|
||||
return this.visitLoop(stmt, cond.text, endLineOf(cond));
|
||||
}
|
||||
|
||||
private visitRepeat(stmt: SyntaxNode): TraversalResult {
|
||||
return this.visitLoop(stmt, 'repeat', startLineOf(stmt));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the CFG for one R `function_definition`, or `undefined` if not modelable.
|
||||
*
|
||||
* Join key: `functionStartLine` / `functionStartColumn` are those of the
|
||||
* `function_definition` node itself (the `function` / `\` token), NOT of an
|
||||
* enclosing `f <- ` assignment. See docs/languages/r-provider.md § Control flow.
|
||||
*/
|
||||
function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined {
|
||||
if (!R_FUNCTION_TYPES.has(fnNode.type)) return undefined;
|
||||
const body = fnNode.childForFieldName('body');
|
||||
if (!body) return undefined;
|
||||
|
||||
const builder = new CfgBuilder(
|
||||
filePath,
|
||||
startLineOf(fnNode),
|
||||
endLineOf(fnNode),
|
||||
fnNode.startPosition.column,
|
||||
);
|
||||
const walk = new RCfgWalk(builder);
|
||||
const res = walk.visitStmt(body);
|
||||
if (!res) {
|
||||
builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body
|
||||
return builder.finish();
|
||||
}
|
||||
builder.edge(builder.entryIndex, res.entry, 'seq');
|
||||
builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off -> EXIT
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
/** Whether a node is an R function this visitor builds a CFG for. */
|
||||
function isFunction(node: SyntaxNode): boolean {
|
||||
return R_FUNCTION_TYPES.has(node.type);
|
||||
}
|
||||
|
||||
/** The R CFG visitor. */
|
||||
export function createRCfgVisitor(): CfgVisitor<SyntaxNode> {
|
||||
return { buildFunctionCfg, isFunction };
|
||||
}
|
||||
|
||||
export { R_FUNCTION_TYPES };
|
||||
295
gitnexus/src/core/ingestion/field-extractors/r.ts
Normal file
295
gitnexus/src/core/ingestion/field-extractors/r.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
// gitnexus/src/core/ingestion/field-extractors/r.ts
|
||||
|
||||
/**
|
||||
* R field extractor — hand-written because R expresses classes as function
|
||||
* calls (R6Class(), setClass(), setRefClass()) rather than dedicated class syntax, so the
|
||||
* generic config-driven factory cannot navigate the AST.
|
||||
*
|
||||
* Handles:
|
||||
* - R6 fields: non-function entries in public/private list()
|
||||
* - R5 fields: setRefClass("Foo", fields = list(name = "character"))
|
||||
* - S4 representation/slots: setClass("Foo", representation(name = "character"))
|
||||
*/
|
||||
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { BaseFieldExtractor } from '../field-extractor.js';
|
||||
import { isRLeftAssignment } from '../languages/r/assignment.js';
|
||||
import { isRNamingArgument } from '../languages/r/naming-argument.js';
|
||||
import type {
|
||||
FieldExtractorContext,
|
||||
ExtractedFields,
|
||||
FieldInfo,
|
||||
FieldVisibility,
|
||||
} from '../field-types.js';
|
||||
|
||||
export class RFieldExtractor extends BaseFieldExtractor {
|
||||
language = SupportedLanguages.R;
|
||||
|
||||
isTypeDeclaration(node: SyntaxNode): boolean {
|
||||
return isR6Class(node) || isS4Class(node);
|
||||
}
|
||||
|
||||
protected extractVisibility(_node: SyntaxNode): FieldVisibility {
|
||||
return 'public';
|
||||
}
|
||||
|
||||
extract(node: SyntaxNode, context: FieldExtractorContext): ExtractedFields | null {
|
||||
if (isR6Class(node)) return this.extractR6Fields(node, context);
|
||||
if (isS4Class(node)) return this.extractS4Fields(node, context);
|
||||
return null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// R6 field extraction
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
private extractR6Fields(
|
||||
node: SyntaxNode,
|
||||
context: FieldExtractorContext,
|
||||
): ExtractedFields | null {
|
||||
const ownerFqn = node.childForFieldName('lhs')?.text;
|
||||
if (!ownerFqn) return null;
|
||||
|
||||
const call = node.childForFieldName('rhs');
|
||||
if (!call) return null;
|
||||
|
||||
const args = call.childForFieldName('arguments');
|
||||
if (!args) return null;
|
||||
|
||||
const fields: FieldInfo[] = [];
|
||||
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const arg = args.namedChild(i);
|
||||
if (!arg || arg.type !== 'argument') continue;
|
||||
|
||||
const section = arg.childForFieldName('name')?.text;
|
||||
if (section !== 'public' && section !== 'private') continue;
|
||||
|
||||
const visibility: FieldVisibility = section === 'private' ? 'private' : 'public';
|
||||
const listCall = arg.childForFieldName('value');
|
||||
if (!listCall || listCall.type !== 'call') continue;
|
||||
|
||||
const listArgs = listCall.childForFieldName('arguments');
|
||||
if (!listArgs) continue;
|
||||
|
||||
for (let j = 0; j < listArgs.namedChildCount; j++) {
|
||||
const entry = listArgs.namedChild(j);
|
||||
if (!entry || entry.type !== 'argument') continue;
|
||||
|
||||
const nameNode = entry.childForFieldName('name');
|
||||
const valueNode = entry.childForFieldName('value');
|
||||
if (!nameNode) continue;
|
||||
|
||||
// Skip function entries (those are methods, not fields)
|
||||
if (valueNode?.type === 'function_definition') continue;
|
||||
|
||||
fields.push({
|
||||
name: nameNode.text,
|
||||
type: inferR6FieldType(valueNode),
|
||||
visibility,
|
||||
isStatic: false,
|
||||
isReadonly: false,
|
||||
sourceFile: context.filePath,
|
||||
line: entry.startPosition.row + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return fields.length > 0 ? { ownerFqn, fields, nestedTypes: [] } : null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// S4 field extraction (representation / slots / fields)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
private extractS4Fields(
|
||||
node: SyntaxNode,
|
||||
context: FieldExtractorContext,
|
||||
): ExtractedFields | null {
|
||||
const args = node.childForFieldName('arguments');
|
||||
if (!args) return null;
|
||||
|
||||
// The class name is the `Class` argument (by name, else the first unnamed one).
|
||||
const ownerFqn = getRClassNameArgument(args);
|
||||
if (!ownerFqn) return null;
|
||||
|
||||
const fields: FieldInfo[] = [];
|
||||
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const arg = args.namedChild(i);
|
||||
if (!arg || arg.type !== 'argument') continue;
|
||||
|
||||
const paramName = arg.childForFieldName('name')?.text;
|
||||
if (paramName !== 'representation' && paramName !== 'slots' && paramName !== 'fields')
|
||||
continue;
|
||||
|
||||
const slotCall = arg.childForFieldName('value');
|
||||
if (!slotCall || slotCall.type !== 'call') continue;
|
||||
|
||||
const slotArgs = slotCall.childForFieldName('arguments');
|
||||
if (!slotArgs) continue;
|
||||
|
||||
// Only an unnamed character vector (`slots = c("a", "b")`) names its slots by
|
||||
// their strings; elsewhere an unnamed string is not a slot (`representation("Base")`).
|
||||
const unnamedNamesSlots =
|
||||
paramName !== 'representation' && slotCall.childForFieldName('function')?.text === 'c';
|
||||
|
||||
for (let j = 0; j < slotArgs.namedChildCount; j++) {
|
||||
const slot = slotArgs.namedChild(j);
|
||||
if (!slot || slot.type !== 'argument') continue;
|
||||
|
||||
const slotName = slot.childForFieldName('name');
|
||||
const slotType = slot.childForFieldName('value');
|
||||
if (!slotName) {
|
||||
const unnamed = unnamedNamesSlots ? extractQuotedTypeName(slotType) : null;
|
||||
if (unnamed) {
|
||||
fields.push({
|
||||
name: unnamed,
|
||||
type: null,
|
||||
visibility: 'public',
|
||||
isStatic: false,
|
||||
isReadonly: false,
|
||||
sourceFile: context.filePath,
|
||||
line: slot.startPosition.row + 1,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const typeName = extractQuotedTypeName(slotType);
|
||||
|
||||
fields.push({
|
||||
name: slotName.text,
|
||||
type: typeName,
|
||||
visibility: 'public',
|
||||
isStatic: false,
|
||||
isReadonly: false,
|
||||
sourceFile: context.filePath,
|
||||
line: slot.startPosition.row + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return fields.length > 0 ? { ownerFqn, fields, nestedTypes: [] } : null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AST helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The class name of a `setClass` / `setRefClass` argument list: the string value of
|
||||
* the argument that takes the `Class` formal. Uses the same selection as the scope
|
||||
* query and definition hook (`isRNamingArgument`), so `contains = "Base"` or
|
||||
* `representation(...)` ahead of `Class = "A"` never names the owner.
|
||||
*/
|
||||
export function getRClassNameArgument(args: SyntaxNode): string | null {
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const arg = args.namedChild(i);
|
||||
if (!arg || arg.type !== 'argument') continue;
|
||||
if (!isRNamingArgument(arg, 'Class')) continue;
|
||||
const value = arg.childForFieldName('value');
|
||||
if (value?.type !== 'string') continue;
|
||||
const name = extractQuotedTypeName(value);
|
||||
if (name) return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findRFieldOwnerNode(node: SyntaxNode): SyntaxNode | null {
|
||||
let current: SyntaxNode | null = node;
|
||||
while (current) {
|
||||
if (isR6Class(current) || isS4Class(current)) return current;
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getRTopLevelPropertyOwnerName(node: SyntaxNode): string | null {
|
||||
const ownerNode = findRFieldOwnerNode(node);
|
||||
if (!ownerNode) return null;
|
||||
return getRClassNameOfOwnerNode(ownerNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* The class name an R6 / S4 / RefClass owner node declares: the binding name of
|
||||
* `Foo <- R6Class(...)`, or the `Class` argument of `setClass(...)` /
|
||||
* `setRefClass(...)`. Null when `ownerNode` is not such a node. Looks at this node
|
||||
* only (no ancestor walk), so a caller visiting every ancestor stays linear.
|
||||
*/
|
||||
export function getRClassNameOfOwnerNode(ownerNode: SyntaxNode): string | null {
|
||||
if (!isR6Class(ownerNode) && !isS4Class(ownerNode)) return null;
|
||||
|
||||
if (ownerNode.type === 'binary_operator') {
|
||||
return ownerNode.childForFieldName('lhs')?.text ?? null;
|
||||
}
|
||||
|
||||
const args = ownerNode.childForFieldName('arguments');
|
||||
if (!args) return null;
|
||||
return getRClassNameArgument(args);
|
||||
}
|
||||
|
||||
/** Check if a node is an R6 class assignment (`X <- R6Class(...)`; `X + R6Class(...)` is not). */
|
||||
function isR6Class(node: SyntaxNode): boolean {
|
||||
if (!isRLeftAssignment(node)) return false;
|
||||
const rhs = node.childForFieldName('rhs');
|
||||
if (!rhs || rhs.type !== 'call') return false;
|
||||
const fn = rhs.childForFieldName('function');
|
||||
if (!fn) return false;
|
||||
// R6Class(...)
|
||||
if (fn.type === 'identifier' && fn.text === 'R6Class') return true;
|
||||
// R6::R6Class(...)
|
||||
if (fn.type === 'namespace_operator') {
|
||||
const rhsFn = fn.childForFieldName('rhs');
|
||||
return rhsFn?.text === 'R6Class';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Check if a call node is setClass() or setRefClass(). */
|
||||
function isS4Class(node: SyntaxNode): boolean {
|
||||
if (node.type !== 'call') return false;
|
||||
const fn = node.childForFieldName('function');
|
||||
return fn?.type === 'identifier' && (fn.text === 'setClass' || fn.text === 'setRefClass');
|
||||
}
|
||||
|
||||
/** Infer a basic type from an R6 field default value. */
|
||||
function inferR6FieldType(valueNode: SyntaxNode | null): string | null {
|
||||
if (!valueNode) return null;
|
||||
switch (valueNode.type) {
|
||||
case 'null':
|
||||
return null;
|
||||
case 'true':
|
||||
case 'false':
|
||||
return 'logical';
|
||||
case 'float':
|
||||
return 'numeric';
|
||||
case 'integer':
|
||||
return 'integer';
|
||||
case 'string':
|
||||
return 'character';
|
||||
case 'na':
|
||||
// The grammar parses NA, NA_integer_, NA_real_, NA_character_ and NA_complex_ as `na`
|
||||
// nodes, not identifiers. Plain NA is logical.
|
||||
return NA_FIELD_TYPES[valueNode.text] ?? null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The type of each NA constant, by its spelling. */
|
||||
const NA_FIELD_TYPES: Readonly<Record<string, string>> = Object.assign(Object.create(null), {
|
||||
NA: 'logical',
|
||||
NA_integer_: 'integer',
|
||||
NA_real_: 'numeric',
|
||||
NA_character_: 'character',
|
||||
NA_complex_: 'complex',
|
||||
});
|
||||
|
||||
function extractQuotedTypeName(node: SyntaxNode | null): string | null {
|
||||
if (!node || node.type !== 'string') return null;
|
||||
const content = node.namedChildren.find((c) => c.type === 'string_content');
|
||||
return content?.text ?? node.text.replace(/^["']|["']$/g, '');
|
||||
}
|
||||
|
|
@ -40,6 +40,8 @@ import type { CfgVisitor } from './cfg/types.js';
|
|||
import type { CollectedCfgs } from './cfg/collect.js';
|
||||
import type { RenameProvider } from './rename-provider.js';
|
||||
import type { GraphNode, NodeLabel, ParameterTypeClass, RelationshipType } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import type { MutableSemanticModel } from './model/semantic-model.js';
|
||||
import type { ExtractedRoute } from './route-extractors/laravel.js';
|
||||
import type { SharedSpringType } from './route-extractors/spring-shared.js';
|
||||
import type {
|
||||
|
|
@ -260,6 +262,16 @@ export function shouldHarvestModuleConstants(
|
|||
return !provider.moduleConstantHeuristic || provider.moduleConstantHeuristic(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a {@link LanguageProviderConfig.postParse} hook may read or mutate: the merged graph, the
|
||||
* populated semantic model, and the repository root. Hooks mutate `graph`/`model` in place.
|
||||
*/
|
||||
export interface PostParseContext {
|
||||
readonly graph: KnowledgeGraph;
|
||||
readonly model: MutableSemanticModel;
|
||||
readonly repoPath: string;
|
||||
}
|
||||
|
||||
interface LanguageProviderConfig {
|
||||
// ── Identity ──────────────────────────────────────────────────────
|
||||
readonly id: SupportedLanguages;
|
||||
|
|
@ -444,6 +456,16 @@ interface LanguageProviderConfig {
|
|||
filePath: string,
|
||||
) => { readonly name: string; readonly label: NodeLabel } | null;
|
||||
|
||||
/**
|
||||
* For languages whose member owner is not a syntactic ancestor container
|
||||
* (R: `R6Class(...)` / `setRefClass(...)` calls): return the node the
|
||||
* method/field extractors read members from, or `null`.
|
||||
*
|
||||
* Consulted by the enclosing-owner slot after the container walk and
|
||||
* `resolveFileTypeOwner` (same slot as the Zig file-owner).
|
||||
* Default: undefined. */
|
||||
readonly resolveMemberOwnerNode?: (definitionNode: SyntaxNode) => SyntaxNode | null;
|
||||
|
||||
/**
|
||||
* The type a CONTAINER node declares, when the language names it from its
|
||||
* context rather than from a name child of the node — a binding wrapper,
|
||||
|
|
@ -844,6 +866,24 @@ interface LanguageProviderConfig {
|
|||
*/
|
||||
readonly prepareRouteConstants?: (repo: RepoConstants) => void;
|
||||
|
||||
/**
|
||||
* Whole-graph work that can only run once every chunk is merged and before scope resolution
|
||||
* (deferred owner resolution, package-manifest-driven export refinement). "Post-parse" means after
|
||||
* all chunks are merged but still inside the parse phase — not the later `crossFile` phase.
|
||||
*
|
||||
* Called once per analyze, only if this language has parsed files, awaited, on the main thread.
|
||||
* Mutates `ctx.graph` / `ctx.model` in place. A throw aborts the parse phase (it is not isolated,
|
||||
* because the hook mutates shared state and a swallowed mid-mutation throw could leave the graph
|
||||
* half-updated).
|
||||
*
|
||||
* Incremental analyse writes back only changed files, so a mutation of an unchanged file's nodes is
|
||||
* not persisted; a hook whose result depends on manifests that are not parsed sources needs
|
||||
* `--force` after such an edit.
|
||||
*
|
||||
* Default: undefined (nothing to do after the merge).
|
||||
*/
|
||||
readonly postParse?: (ctx: PostParseContext) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Spring async messaging facts captured for one file — the listener
|
||||
* annotations that subscribe to a broker destination and the template calls
|
||||
|
|
@ -1398,6 +1438,22 @@ export function prepareRouteConstantsByProvider(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run each present language's {@link LanguageProviderConfig.postParse} hook, sequentially and in
|
||||
* language-id order (independent of scan order), awaiting each before the next: the hooks share one
|
||||
* mutable graph/model. The provider lookup is a callback so this file needs no registry import.
|
||||
* A rejection propagates and later hooks do not run.
|
||||
*/
|
||||
export async function runPostParseHooks(
|
||||
presentLanguages: ReadonlySet<SupportedLanguages>,
|
||||
providerFor: (language: SupportedLanguages) => Pick<LanguageProvider, 'postParse'> | undefined,
|
||||
ctx: PostParseContext,
|
||||
): Promise<void> {
|
||||
for (const language of [...presentLanguages].sort()) {
|
||||
await providerFor(language)?.postParse?.(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULTS: Pick<LanguageProvider, 'mroStrategy'> = {
|
||||
mroStrategy: 'first-wins',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { rubyProvider } from './ruby.js';
|
|||
import { swiftProvider } from './swift.js';
|
||||
import { dartProvider } from './dart.js';
|
||||
import { vueProvider } from './vue.js';
|
||||
import { rProvider } from './r.js';
|
||||
import { cobolProvider } from './cobol.js';
|
||||
import { zigProvider } from './zig.js';
|
||||
|
||||
|
|
@ -49,6 +50,7 @@ export const providers = {
|
|||
[SupportedLanguages.Swift]: swiftProvider,
|
||||
[SupportedLanguages.Dart]: dartProvider,
|
||||
[SupportedLanguages.Vue]: vueProvider,
|
||||
[SupportedLanguages.R]: rProvider,
|
||||
[SupportedLanguages.Cobol]: cobolProvider,
|
||||
[SupportedLanguages.Zig]: zigProvider,
|
||||
} satisfies Record<SupportedLanguages, LanguageProvider>;
|
||||
|
|
|
|||
212
gitnexus/src/core/ingestion/languages/r.ts
Normal file
212
gitnexus/src/core/ingestion/languages/r.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* R language provider.
|
||||
*
|
||||
* `library()` / `require()` of a local package are wildcard imports: the package's
|
||||
* exported names become visible (every top-level name when it has no NAMESPACE file).
|
||||
* `source()` binds no name; it only records a file
|
||||
* dependency. Named imports come from NAMESPACE `importFrom()` entries, which
|
||||
* `populateRNamespaceImports` turns into `named` imports before resolution. No special
|
||||
* call routing is needed (unlike Ruby); `emitRScopeCaptures` (`r/captures.ts`) produces
|
||||
* the import captures from the R scope query rather than from standard import nodes.
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import { typeConfig as rTypeConfig } from '../type-extractors/r.js';
|
||||
import { R_QUERIES } from '../tree-sitter-queries.js';
|
||||
import { RFieldExtractor } from '../field-extractors/r.js';
|
||||
import { rMethodExtractor } from '../method-extractors/r.js';
|
||||
import { emitRScopeCaptures, rScopeOwnsReceivers } from './r/captures.js';
|
||||
import { collectRCaptureSideChannel } from './r/r6-self.js';
|
||||
import { interpretRImport, interpretRTypeBinding } from './r/interpret.js';
|
||||
import { rPostParse } from './r/post-parse.js';
|
||||
import {
|
||||
rDefinitionProperties,
|
||||
rResolveDetachedOwnerName,
|
||||
rResolveMemberOwnerNode,
|
||||
} from './r/owner-hooks.js';
|
||||
import { isRNonNamingArgumentCapture } from './r/naming-argument.js';
|
||||
import { createRCfgVisitor } from '../cfg/visitors/r.js';
|
||||
import { rRenameProvider } from './r/rename.js';
|
||||
|
||||
const R_BUILT_INS: ReadonlySet<string> = new Set([
|
||||
// Base R
|
||||
'c',
|
||||
'list',
|
||||
'vector',
|
||||
'matrix',
|
||||
'array',
|
||||
'data.frame',
|
||||
'length',
|
||||
'nrow',
|
||||
'ncol',
|
||||
'dim',
|
||||
'names',
|
||||
'colnames',
|
||||
'rownames',
|
||||
'print',
|
||||
'cat',
|
||||
'paste',
|
||||
'paste0',
|
||||
'sprintf',
|
||||
'message',
|
||||
'warning',
|
||||
'stop',
|
||||
'tryCatch',
|
||||
'withCallingHandlers',
|
||||
'try',
|
||||
'on.exit',
|
||||
'sys.call',
|
||||
'match.arg',
|
||||
// Type checks / coercion
|
||||
'is.null',
|
||||
'is.na',
|
||||
'is.numeric',
|
||||
'is.character',
|
||||
'is.logical',
|
||||
'is.list',
|
||||
'as.character',
|
||||
'as.numeric',
|
||||
'as.integer',
|
||||
'as.logical',
|
||||
'as.data.frame',
|
||||
// Apply family
|
||||
'lapply',
|
||||
'sapply',
|
||||
'vapply',
|
||||
'mapply',
|
||||
'tapply',
|
||||
'apply',
|
||||
'Map',
|
||||
'Reduce',
|
||||
'Filter',
|
||||
// Functional
|
||||
'do.call',
|
||||
'Recall',
|
||||
'match.fun',
|
||||
// I/O
|
||||
'readRDS',
|
||||
'saveRDS',
|
||||
'readLines',
|
||||
'writeLines',
|
||||
'read.csv',
|
||||
'write.csv',
|
||||
// Environment
|
||||
'new.env',
|
||||
'environment',
|
||||
'parent.env',
|
||||
'globalenv',
|
||||
'baseenv',
|
||||
'exists',
|
||||
'get',
|
||||
'assign',
|
||||
'rm',
|
||||
'ls',
|
||||
// String
|
||||
'nchar',
|
||||
'substr',
|
||||
'substring',
|
||||
'gsub',
|
||||
'sub',
|
||||
'grep',
|
||||
'grepl',
|
||||
'regmatches',
|
||||
'trimws',
|
||||
'toupper',
|
||||
'tolower',
|
||||
'startsWith',
|
||||
'endsWith',
|
||||
'strsplit',
|
||||
// Math
|
||||
'sum',
|
||||
'mean',
|
||||
'median',
|
||||
'min',
|
||||
'max',
|
||||
'abs',
|
||||
'sqrt',
|
||||
'log',
|
||||
'exp',
|
||||
'round',
|
||||
'ceiling',
|
||||
'floor',
|
||||
'seq',
|
||||
'seq_len',
|
||||
'seq_along',
|
||||
'rep',
|
||||
'which',
|
||||
'range',
|
||||
'cumsum',
|
||||
'diff',
|
||||
// Logical
|
||||
'any',
|
||||
'all',
|
||||
'xor',
|
||||
'identical',
|
||||
// Sort / order
|
||||
'sort',
|
||||
'order',
|
||||
'rev',
|
||||
'unique',
|
||||
'duplicated',
|
||||
'table',
|
||||
// S4 / R6 internals
|
||||
'setClass',
|
||||
'setGeneric',
|
||||
'setMethod',
|
||||
'setRefClass',
|
||||
'new',
|
||||
'validObject',
|
||||
'is',
|
||||
'extends',
|
||||
'isVirtualClass',
|
||||
'R6Class',
|
||||
// Package / namespace
|
||||
'library',
|
||||
'require',
|
||||
'requireNamespace',
|
||||
'loadNamespace',
|
||||
'getNamespace',
|
||||
'source',
|
||||
'sys.source',
|
||||
'attachNamespace',
|
||||
// Control flow (builtins that appear as calls)
|
||||
'return',
|
||||
'invisible',
|
||||
'missing',
|
||||
'nargs',
|
||||
'Sys.time',
|
||||
'Sys.sleep',
|
||||
'proc.time',
|
||||
]);
|
||||
|
||||
export const rProvider = defineLanguage({
|
||||
id: SupportedLanguages.R,
|
||||
extensions: ['.R', '.r'],
|
||||
entryPointPatterns: [],
|
||||
astFrameworkPatterns: [],
|
||||
treeSitterQueries: R_QUERIES,
|
||||
typeConfig: rTypeConfig,
|
||||
// Every definition is exported by default. NAMESPACE-driven export refinement lives in
|
||||
// `rPostParse`: an `ExportChecker` has no file-path context, and NAMESPACE detection needs it.
|
||||
exportChecker: () => true,
|
||||
fieldExtractor: new RFieldExtractor(),
|
||||
methodExtractor: rMethodExtractor,
|
||||
builtInNames: R_BUILT_INS,
|
||||
emitScopeCaptures: emitRScopeCaptures,
|
||||
// Where the R6 private members are (`self$m()` never reaches one), carried to the main thread.
|
||||
collectCaptureSideChannel: collectRCaptureSideChannel,
|
||||
scopeOwnsReceivers: rScopeOwnsReceivers,
|
||||
interpretImport: interpretRImport,
|
||||
interpretTypeBinding: interpretRTypeBinding,
|
||||
postParse: rPostParse,
|
||||
resolveMemberOwnerNode: rResolveMemberOwnerNode,
|
||||
resolveDetachedOwnerName: rResolveDetachedOwnerName,
|
||||
definitionPropertiesExtractor: rDefinitionProperties,
|
||||
// R_QUERIES captures every string argument of setClass/setGeneric/setMethod;
|
||||
// only the one that names the definition is kept.
|
||||
shouldSkipDefinitionCapture: isRNonNamingArgumentCapture,
|
||||
// Control flow only (no def/use harvest): CFG + CDG, REACHING_DEF reports no-facts.
|
||||
cfgVisitor: createRCfgVisitor(),
|
||||
rename: rRenameProvider,
|
||||
});
|
||||
299
gitnexus/src/core/ingestion/languages/r/assignment.ts
Normal file
299
gitnexus/src/core/ingestion/languages/r/assignment.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
/**
|
||||
* Assignment predicates shared by the scope captures and the rename provider.
|
||||
*
|
||||
* What makes an R binary expression a definition: the assignment operators `<-`, `<<-`
|
||||
* and `=`. The grammar gives every infix operator the same `binary_operator` node, so
|
||||
* `x <- R6Class(...)` and `x + R6Class(...)` differ only in the `operator` field. The scope
|
||||
* and definition queries carry the same constraint as `operator: ["<-" "<<-" "="]`.
|
||||
*/
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { rBareName } from './bare-name.js';
|
||||
|
||||
const LEFT_ASSIGN_OPERATORS: ReadonlySet<string> = new Set(['<-', '<<-', '=']);
|
||||
|
||||
/** Whether `node` is `target <- value`, `target <<- value` or `target = value`. */
|
||||
export const isRLeftAssignment = (node: SyntaxNode): boolean =>
|
||||
node.type === 'binary_operator' &&
|
||||
LEFT_ASSIGN_OPERATORS.has(node.childForFieldName('operator')?.text ?? '');
|
||||
|
||||
const CONSTANT_VALUE_TYPES: ReadonlySet<string> = new Set([
|
||||
'float',
|
||||
'integer',
|
||||
'complex',
|
||||
'string',
|
||||
'true',
|
||||
'false',
|
||||
'na',
|
||||
'inf',
|
||||
'nan',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether `node` is a constant that can never be a function: a number, a string, `TRUE`/`FALSE`,
|
||||
* `NA`, `Inf`, `NaN` or a signed number (`-1`, `+2`). `NULL` is not one: it is the usual
|
||||
* placeholder of an optional callback (`helper = NULL`), so a name holding it may hold a function.
|
||||
*/
|
||||
export function isRConstantValue(node: SyntaxNode): boolean {
|
||||
if (CONSTANT_VALUE_TYPES.has(node.type)) return true;
|
||||
return (
|
||||
node.type === 'unary_operator' &&
|
||||
['-', '+'].includes(node.childForFieldName('operator')?.text ?? '') &&
|
||||
['float', 'integer', 'complex', 'inf', 'nan'].includes(
|
||||
node.childForFieldName('rhs')?.type ?? '',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** The name a plain assignment (`<-`, `=`, `<<-`, `->`, `->>`) or a `for` loop binds, or null. */
|
||||
export function rBoundName(node: SyntaxNode): string | null {
|
||||
if (node.type === 'for_statement') {
|
||||
const variable = node.childForFieldName('variable');
|
||||
return variable?.type === 'identifier' ? rBareName(variable.text) : null;
|
||||
}
|
||||
if (node.type !== 'binary_operator') return null;
|
||||
const operator = node.childForFieldName('operator')?.text;
|
||||
if (operator === undefined) return null;
|
||||
let target: SyntaxNode | null = null;
|
||||
if (operator === '<-' || operator === '=' || operator === '<<-') {
|
||||
target = node.childForFieldName('lhs');
|
||||
} else if (operator === '->' || operator === '->>') {
|
||||
target = node.childForFieldName('rhs');
|
||||
}
|
||||
return target?.type === 'identifier' ? rBareName(target.text) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether assigning `value` to a name binds something a call of that name may reach.
|
||||
*
|
||||
* R looks a callee up as a function and skips any binding that is not one, so what matters is
|
||||
* whether the value can be a function:
|
||||
* - a `function_definition` is a real definition (the scope extractor already records it, and a
|
||||
* call resolves to it), so it is not a lexical name here;
|
||||
* - a constant (`isRConstantValue`) is skipped by R at a call, which then reaches the outer
|
||||
* function, so it is not either;
|
||||
* - everything else (an identifier, a call, anything computed, `NULL`) may hold a function.
|
||||
*/
|
||||
export function isLexicalAssignment(value: SyntaxNode): boolean {
|
||||
return value.type !== 'function_definition' && !isRConstantValue(value);
|
||||
}
|
||||
|
||||
/** The first argument of a call node (`x` of `names(x)`), or null when there is none. */
|
||||
function firstArgumentValue(call: SyntaxNode): SyntaxNode | null {
|
||||
const argument = call.childForFieldName('arguments')?.namedChildren.find((child) => {
|
||||
return child.type === 'argument';
|
||||
});
|
||||
return argument?.childForFieldName('value') ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The identifier an assignment target writes through: `x` for `x`, `x$a`, `x@a`, `x[i]`, `x[[i]]`
|
||||
* and `names(x)`, `names(x)[2]`, `pkg::foo(x)` (a replacement call assigns its first argument, never
|
||||
* the called name). Null for a target with no identifier at its base (`f()$a`, a string).
|
||||
*/
|
||||
function assignmentBase(target: SyntaxNode): SyntaxNode | null {
|
||||
for (let node: SyntaxNode | null = target; node !== null; ) {
|
||||
switch (node.type) {
|
||||
case 'identifier':
|
||||
return node;
|
||||
case 'extract_operator':
|
||||
node = node.childForFieldName('lhs');
|
||||
break;
|
||||
case 'subset':
|
||||
case 'subset2':
|
||||
node = node.childForFieldName('function');
|
||||
break;
|
||||
case 'call':
|
||||
node = firstArgumentValue(node);
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The name (as written) an assignment node binds to a value that may be a function, or null. */
|
||||
function lexicalAssignmentOf(node: SyntaxNode): string | null {
|
||||
if (node.type !== 'binary_operator') return null;
|
||||
const operator = node.childForFieldName('operator')?.text;
|
||||
let target: SyntaxNode | null;
|
||||
let value: SyntaxNode | null;
|
||||
if (operator === '<-' || operator === '=') {
|
||||
target = node.childForFieldName('lhs');
|
||||
value = node.childForFieldName('rhs');
|
||||
} else if (operator === '->') {
|
||||
target = node.childForFieldName('rhs');
|
||||
value = node.childForFieldName('lhs');
|
||||
} else {
|
||||
// `<<-` and `->>` assign outward and bind nothing in this frame.
|
||||
return null;
|
||||
}
|
||||
if (target === null || value === null) return null;
|
||||
if (target.type === 'identifier') {
|
||||
return isLexicalAssignment(value) && target.text.length > 0 ? target.text : null;
|
||||
}
|
||||
// `x$a <- v`, `names(x) <- v`: R evaluates `x`, modifies it and binds the result to `x` here,
|
||||
// whatever the value. Not for `self`: writing a field of an R6 instance (`self$count <- 1`,
|
||||
// `self$items[[i]] <- v`) leaves `self` the same instance, so it stays the method's typed
|
||||
// receiver. Only a plain `self <- v` (above) rebinds it.
|
||||
const base = assignmentBase(target);
|
||||
if (base === null || base.text.length === 0 || base.text === 'self') return null;
|
||||
return base.text;
|
||||
}
|
||||
|
||||
const lexicalAssigned = new WeakMap<SyntaxNode['tree'], Map<number, ReadonlySet<string>>>();
|
||||
|
||||
/**
|
||||
* The names a function's own body binds by ordinary assignment to a value that may be a function
|
||||
* (`isLexicalAssignment`), as written (backticks included, like a formal): `x <- v`, `x = v`,
|
||||
* `v -> x`, and the base identifier of a complex target (`x$a <- v`, `names(x) <- v`, never the
|
||||
* called name, so `foo(x) <- 1` binds `x`; a complex target based on `self` binds nothing, see
|
||||
* `lexicalAssignmentOf`). Flow-insensitive: a name counts when ANY assignment
|
||||
* of it in the body does. A nested function binds its own variables and is not read; `<<-` and
|
||||
* `->>` assign outward; `assign()`, `delayedAssign()`, `list2env()`, `local()` and `with()` are not
|
||||
* modelled (`local({ x <- f })` is read as an assignment in this frame, which only costs an edge).
|
||||
*
|
||||
* The one implementation behind both the call side (`rFunctionLexicalNames`) and the rename guard
|
||||
* (`checkSafety`), so the two cannot drift apart.
|
||||
*/
|
||||
export function rLexicalAssignedNames(fn: SyntaxNode): ReadonlySet<string> {
|
||||
let byFunction = lexicalAssigned.get(fn.tree);
|
||||
if (byFunction === undefined) lexicalAssigned.set(fn.tree, (byFunction = new Map()));
|
||||
const known = byFunction.get(fn.id);
|
||||
if (known !== undefined) return known;
|
||||
const names = new Set<string>();
|
||||
const body = fn.childForFieldName('body');
|
||||
const pending = body === null ? [] : [body];
|
||||
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
|
||||
if (node.type === 'function_definition') continue;
|
||||
const bound = lexicalAssignmentOf(node);
|
||||
if (bound !== null) names.add(bound);
|
||||
for (const child of node.namedChildren) pending.push(child);
|
||||
}
|
||||
byFunction.set(fn.id, names);
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Whether `node` is the callee of a call: `f(x)`, `x |> f()`. */
|
||||
export function isCallee(node: SyntaxNode): boolean {
|
||||
const call = node.parent;
|
||||
return call?.type === 'call' && call.childForFieldName('function')?.id === node.id;
|
||||
}
|
||||
|
||||
/** The left-hand side of `<-`, `=`, `<<-`, or the right-hand side of `->`, `->>`; null for any other node. */
|
||||
export function assignmentTarget(node: SyntaxNode): SyntaxNode | null {
|
||||
if (node.type !== 'binary_operator') return null;
|
||||
const operator = node.childForFieldName('operator')?.text;
|
||||
if (operator === '<-' || operator === '=' || operator === '<<-')
|
||||
return node.childForFieldName('lhs');
|
||||
if (operator === '->' || operator === '->>') return node.childForFieldName('rhs');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the call node `call` lies in the target chain of an assignment, which makes it a call of
|
||||
* the replacement function: `foo(x) <- v` runs `` `foo<-` ``, not `foo`. The chain is reached from the
|
||||
* assignment target (`assignmentTarget`) only through a call's first argument and the base of `$`,
|
||||
* `@`, `[` and `[[`, the same walk `assignmentBase` makes to find the variable written. Any other
|
||||
* argument (`foo(x, bar(1)) <- v`, the index of `x[bar(1)]`) and the value being assigned are
|
||||
* ordinary reads.
|
||||
*
|
||||
* For one call only: it walks up, and tree-sitter's `.parent` re-descends from the root, so asking
|
||||
* it about every call of a file is cubic in the nesting depth. The capture layer asks
|
||||
* {@link rReplacementCallTest} instead, which gives the same answer in one linear pass; this
|
||||
* function is the reference that pass is tested against.
|
||||
*/
|
||||
export function isRReplacementCall(call: SyntaxNode): boolean {
|
||||
for (let node = call; ; ) {
|
||||
const parent: SyntaxNode | null = node.parent;
|
||||
if (parent === null) return false;
|
||||
switch (parent.type) {
|
||||
case 'binary_operator':
|
||||
return assignmentTarget(parent)?.id === node.id;
|
||||
case 'extract_operator':
|
||||
if (parent.childForFieldName('lhs')?.id !== node.id) return false;
|
||||
node = parent;
|
||||
break;
|
||||
case 'subset':
|
||||
case 'subset2':
|
||||
if (parent.childForFieldName('function')?.id !== node.id) return false;
|
||||
node = parent;
|
||||
break;
|
||||
case 'argument': {
|
||||
const list = parent.parent;
|
||||
const owner = list?.parent;
|
||||
if (parent.childForFieldName('value')?.id !== node.id) return false;
|
||||
if (list?.type !== 'arguments' || owner?.type !== 'call') return false;
|
||||
if (owner.childForFieldName('arguments')?.id !== list.id) return false;
|
||||
if (list.namedChildren.find((child) => child.type === 'argument')?.id !== parent.id) {
|
||||
return false;
|
||||
}
|
||||
node = owner;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids of every call under `root` that is in a replacement position: the calls for which
|
||||
* {@link isRReplacementCall} is true, found top-down in one pass. From the target of each
|
||||
* assignment, the chain is followed down through a call's first argument and the base of `$`,
|
||||
* `@`, `[` and `[[`, the reverse of the upward walk, so no `.parent` is read. Each node is visited
|
||||
* once and chains of different assignments never share a node, so the pass is linear in the
|
||||
* size of the tree; it uses an explicit stack, never recursion, however deep the nesting.
|
||||
*/
|
||||
export function rReplacementCallIds(root: SyntaxNode): Set<number> {
|
||||
const ids = new Set<number>();
|
||||
const pending = [root];
|
||||
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
|
||||
const target = assignmentTarget(node);
|
||||
if (target !== null) markReplacementChain(target, ids);
|
||||
for (const child of node.namedChildren) pending.push(child);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Add every call of the target chain that starts at `target` to `ids`. */
|
||||
function markReplacementChain(target: SyntaxNode, ids: Set<number>): void {
|
||||
for (let node: SyntaxNode | null = target; node !== null; ) {
|
||||
switch (node.type) {
|
||||
case 'call': {
|
||||
ids.add(node.id);
|
||||
const list = node.childForFieldName('arguments');
|
||||
const first =
|
||||
list?.type === 'arguments'
|
||||
? list.namedChildren.find((child) => child.type === 'argument')
|
||||
: undefined;
|
||||
node = first?.childForFieldName('value') ?? null;
|
||||
break;
|
||||
}
|
||||
case 'extract_operator':
|
||||
node = node.childForFieldName('lhs');
|
||||
break;
|
||||
case 'subset':
|
||||
case 'subset2':
|
||||
node = node.childForFieldName('function');
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a call of the tree under `root` is in a replacement position, for asking about many
|
||||
* calls of one file: the set of {@link rReplacementCallIds} is built on the first question and
|
||||
* reused, so the answers cost one linear pass in all.
|
||||
*/
|
||||
export function rReplacementCallTest(root: SyntaxNode): (call: SyntaxNode) => boolean {
|
||||
let ids: Set<number> | undefined;
|
||||
return (call) => (ids ??= rReplacementCallIds(root)).has(call.id);
|
||||
}
|
||||
|
||||
/** The name of the replacement function a getter name stands for: `foo` -> `` `foo<-` ``. */
|
||||
export function rSetterName(name: string): string {
|
||||
return '`' + rBareName(name) + '<-`';
|
||||
}
|
||||
22
gitnexus/src/core/ingestion/languages/r/bare-name.ts
Normal file
22
gitnexus/src/core/ingestion/languages/r/bare-name.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* R lets any name be written in backticks (`` `%+%` ``, `` `my fn` ``,
|
||||
* `` `helper` ``); the symbol is the text between them. The graph and the scope
|
||||
* model keep a definition's name exactly as written, while NAMESPACE entries,
|
||||
* roxygen tags and rename requests carry the symbol. The scope, qualified-call,
|
||||
* post-parse, rename and type code compare the two spellings through
|
||||
* {@link rBareName}, so there is one definition of "the same name" there. The one
|
||||
* other normaliser is `stripBackticks` in `languages/r/import-target.ts`, which removes
|
||||
* every leading and trailing backtick from a NAMESPACE entry (a quoted
|
||||
* `` "`x`" `` entry keeps its backticks after `normaliseArg`) before comparing it
|
||||
* with a symbol.
|
||||
*
|
||||
* One pair of surrounding backticks is removed, in constant time. An escaped
|
||||
* backtick inside the name stays as written, as `normaliseArg` in
|
||||
* `namespace-imports.ts` leaves it when it unquotes a NAMESPACE entry.
|
||||
*/
|
||||
export function rBareName(name: string): string {
|
||||
const last = name.length - 1;
|
||||
return last >= 1 && name.charCodeAt(0) === 96 && name.charCodeAt(last) === 96
|
||||
? name.slice(1, last)
|
||||
: name;
|
||||
}
|
||||
269
gitnexus/src/core/ingestion/languages/r/captures.ts
Normal file
269
gitnexus/src/core/ingestion/languages/r/captures.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import type { Capture, CaptureMatch, MixedChainStep } from 'gitnexus-shared';
|
||||
import { nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { getRParser, getRScopeQuery } from './query.js';
|
||||
import { isRNonNamingScopeMatch } from './naming-argument.js';
|
||||
import { MAX_CHAIN_DEPTH } from '../../utils/call-analysis.js';
|
||||
import { encodeReceiverChain } from '../../utils/receiver-chain-codec.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
import { rLexicalAssignedNames, rReplacementCallTest, rSetterName } from './assignment.js';
|
||||
import { rConstructorAnchorTest } from './receiver-agreement.js';
|
||||
import {
|
||||
createSelfOwnerClassifier,
|
||||
fileBindsSelf,
|
||||
rDefinitionPositionKey,
|
||||
recordRR6PrivateMembers,
|
||||
rR6Method,
|
||||
} from './r6-self.js';
|
||||
|
||||
/**
|
||||
* Emit R scope captures — a single tree-sitter query run against the R
|
||||
* grammar (see `query.ts` for the capture shapes and the rationale for each
|
||||
* one). No programmatic enrichment is needed: unlike C/Zig/Rust, R's
|
||||
* declarations carry no synthesized arity/parameter metadata, and heritage
|
||||
* sites resolve directly by name (no marker encoding, unlike Ruby's
|
||||
* `include`/`extend`/`prepend`).
|
||||
*/
|
||||
export function emitRScopeCaptures(
|
||||
sourceText: string,
|
||||
filePath: string,
|
||||
cachedTree?: unknown,
|
||||
): readonly CaptureMatch[] {
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getRParser>['parse']> | undefined;
|
||||
if (tree === undefined) {
|
||||
tree = parseSourceSafe(getRParser(), sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
}
|
||||
|
||||
const rawMatches = getRScopeQuery().matches(tree.rootNode);
|
||||
const out: CaptureMatch[] = [];
|
||||
// A file that binds `self` itself (`self <- Foo$new()` at top level) has a variable of that name
|
||||
// for its free functions to read, so they do not own `self`.
|
||||
const bindsSelf = fileBindsSelf(tree.rootNode);
|
||||
const functionOwnsSelf = createSelfOwnerClassifier();
|
||||
const typedMethods = new Set<number>();
|
||||
// Where the R6 private members are, for `rResolveSelfMember` (`self$m()` never reaches one).
|
||||
const privateMembers = new Set<string>();
|
||||
// One linear pass over the tree on the first free call, not a walk up from every call.
|
||||
const isReplacementCall = rReplacementCallTest(tree.rootNode);
|
||||
// A constructor binding types its name only when every binding of the name in its frame agrees
|
||||
// (`receiver-agreement.ts`); the tree is read once, on the first constructor binding.
|
||||
const isAgreedConstructor = rConstructorAnchorTest(tree.rootNode);
|
||||
|
||||
for (const m of rawMatches) {
|
||||
// The scope query captures every candidate argument of setClass/library/require/
|
||||
// source; keep only the one that names the class or import.
|
||||
if (isRNonNamingScopeMatch(m.captures)) continue;
|
||||
const grouped: Record<string, Capture> = {};
|
||||
let hasRealCapture = false;
|
||||
let functionNode: SyntaxNode | undefined;
|
||||
let methodNode: SyntaxNode | undefined;
|
||||
let freeCallNode: SyntaxNode | undefined;
|
||||
let chainedReceiver: SyntaxNode | undefined;
|
||||
let constructorAnchor: SyntaxNode | undefined;
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
if (tag.startsWith('@_')) continue;
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
hasRealCapture = true;
|
||||
if (tag === '@scope.function' && c.node.type === 'function_definition') functionNode = c.node;
|
||||
if (tag === '@scope.function' && c.node.type === 'argument') methodNode = c.node;
|
||||
if (tag === '@reference.call.free') freeCallNode = c.node;
|
||||
if (tag === '@reference.receiver' && c.node.type !== 'identifier') chainedReceiver = c.node;
|
||||
if (tag === '@type-binding.constructor') constructorAnchor = c.node;
|
||||
if (tag === '@scope.module' && c.node.type === 'program' && !bindsSelf) {
|
||||
grouped['@receiver-owner.self'] = syntheticCapture('@receiver-owner.self', c.node, 'self');
|
||||
}
|
||||
}
|
||||
if (!hasRealCapture) continue;
|
||||
// Dropping the type leaves the receiver untyped, and an untyped receiver gets no edge: the
|
||||
// call is unresolved, never bound to another class's method.
|
||||
if (constructorAnchor !== undefined && !isAgreedConstructor(constructorAnchor)) continue;
|
||||
if (freeCallNode !== undefined && isReplacementCall(freeCallNode)) {
|
||||
recordAsSetterCall(grouped, freeCallNode);
|
||||
}
|
||||
// A member call whose receiver is itself a `$` access or a call (`a$b$m()`): mint the
|
||||
// truncated chain, or drop the match when the chain has no variable base.
|
||||
if (chainedReceiver !== undefined) {
|
||||
const chain = rReceiverChain(chainedReceiver);
|
||||
if (chain === undefined) continue;
|
||||
grouped['@reference.receiver-chain'] = syntheticCapture(
|
||||
'@reference.receiver-chain',
|
||||
chainedReceiver,
|
||||
chain,
|
||||
);
|
||||
}
|
||||
// The function's own scope names what its formals and loop variables bind, so that a call
|
||||
// through one of them stops the lexical walk there instead of reaching a top-level function
|
||||
// of the same name.
|
||||
if (functionNode !== undefined) {
|
||||
const names = rFunctionLexicalNames(functionNode);
|
||||
if (names.length > 0) {
|
||||
grouped['@scope.lexical-names'] = syntheticCapture(
|
||||
'@scope.lexical-names',
|
||||
functionNode,
|
||||
JSON.stringify(names),
|
||||
);
|
||||
}
|
||||
// A function that binds `self` itself (`self <- other()`, a formal or loop variable called
|
||||
// `self`) owns it, R6 method or not: the method's typed `self` is no longer the receiver
|
||||
// there, so `self$m()` is unresolved unless that binding is typed (`self <- Foo$new()`).
|
||||
if (names.includes('self') || (!bindsSelf && functionOwnsSelf(functionNode))) {
|
||||
grouped['@receiver-owner.self'] = syntheticCapture(
|
||||
'@receiver-owner.self',
|
||||
functionNode,
|
||||
'self',
|
||||
);
|
||||
}
|
||||
}
|
||||
out.push(grouped);
|
||||
// An R6 method types `self` as its class. The binding is anchored on the method's name, so it
|
||||
// lands in the method's own scope (an anchor that spans the whole scope would hoist it to the
|
||||
// class scope, which every method of the class shares).
|
||||
if (methodNode !== undefined && !typedMethods.has(methodNode.id)) {
|
||||
typedMethods.add(methodNode.id);
|
||||
const method = rR6Method(methodNode);
|
||||
const methodName = methodNode.childForFieldName('name');
|
||||
if (method !== null && methodName !== null) {
|
||||
out.push({
|
||||
'@type-binding.self': syntheticCapture('@type-binding.self', methodName, 'self'),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', methodName, 'self'),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
methodName,
|
||||
method.className,
|
||||
),
|
||||
});
|
||||
// The method's definition is anchored on this same `argument` node.
|
||||
if (method.section === 'private') privateMembers.add(rDefinitionPositionKey(methodNode));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recordRR6PrivateMembers(filePath, privateMembers);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The receiver chain of a chained member call (`a$b$m()`, `Cls$new()$m()`), encoded with the
|
||||
* shared codec's `truncated` marker, or `undefined` when the receiver has no variable base.
|
||||
*
|
||||
* The walk goes down `extract_operator` (`x$f`, `x@f`: a field step) and `call(function:
|
||||
* extract_operator)` (`x$f()`: a call step) to an identifier, which is the base. R6 fields and
|
||||
* method results carry no declared type, so a chain is never typed; the marker makes the shared
|
||||
* fold refuse it, and the site ends as an unresolved receiver (an outcome, no edge) instead of
|
||||
* falling to a free function or a method of the same name elsewhere.
|
||||
*
|
||||
* `undefined` (the caller drops the match, so the call is invisible: no edge, no outcome) when
|
||||
* the base is not a variable (a call of a function or of `pkg::f`, `x[[1]]`, `x[1]`, `(x)`), a
|
||||
* step is not an identifier (`a$"b"$m()`), or the codec cannot
|
||||
* encode the chain (more steps than it allows, a name with a space). No honest outcome exists
|
||||
* for a receiver that is not a variable, and the walk is bounded so a pathological chain costs
|
||||
* a constant amount of work.
|
||||
*/
|
||||
export function rReceiverChain(receiverNode: SyntaxNode): string | undefined {
|
||||
const outerToInner: MixedChainStep[] = [];
|
||||
let node: SyntaxNode = receiverNode;
|
||||
while (outerToInner.length <= MAX_CHAIN_DEPTH) {
|
||||
if (node.type === 'identifier') {
|
||||
const steps = outerToInner.reverse();
|
||||
return encodeReceiverChain(node.text, steps, { truncated: true });
|
||||
}
|
||||
const isCall = node.type === 'call';
|
||||
const access = isCall ? node.childForFieldName('function') : node;
|
||||
if (access?.type !== 'extract_operator') return undefined;
|
||||
const lhs = access.childForFieldName('lhs');
|
||||
const rhs = access.childForFieldName('rhs');
|
||||
if (lhs === null || rhs?.type !== 'identifier') return undefined;
|
||||
outerToInner.push({ kind: isCall ? 'call' : 'field', name: rhs.text });
|
||||
node = lhs;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the captures of a call in a replacement position (`foo(x) <- v`) so that the site names
|
||||
* the setter: `@reference.name` becomes `` `foo<-` `` and a qualified `pkg::foo` becomes
|
||||
* ``pkg::`foo<-` ``. The identifier node keeps its range, so the site stays at one anchor: the getter
|
||||
* half of a nested form (`foo(bar(x)) <- 1` also reads `bar(x)`) is not recorded as a second site.
|
||||
* Explicit `` `foo<-`(x, 1) `` is a plain call of that name and never reaches here.
|
||||
*/
|
||||
function recordAsSetterCall(grouped: Record<string, Capture>, call: SyntaxNode): void {
|
||||
const callee = call.childForFieldName('function');
|
||||
if (callee === null) return;
|
||||
if (callee.type === 'identifier') {
|
||||
grouped['@reference.name'] = syntheticCapture(
|
||||
'@reference.name',
|
||||
callee,
|
||||
rSetterName(callee.text),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const name = callee.type === 'namespace_operator' ? callee.childForFieldName('rhs') : null;
|
||||
if (name?.type !== 'identifier') return;
|
||||
grouped['@reference.name'] = syntheticCapture('@reference.name', name, rSetterName(name.text));
|
||||
grouped['@reference.qualified-name'] = syntheticCapture(
|
||||
'@reference.qualified-name',
|
||||
callee,
|
||||
callee.text.slice(0, name.startIndex - callee.startIndex) + rSetterName(name.text),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The names a `function_definition` binds in its own frame without a definition node: all its
|
||||
* named formals (`...` is not a name; a default proves nothing about the argument, which may be a
|
||||
* function, so a formal with a constant default is a lexical name too), the variable of every
|
||||
* `for` loop in its body and every name an ordinary assignment binds to a value that may be a
|
||||
* function (`rLexicalAssignedNames`: `helper <- identity`, not `helper <- function(y) 1` nor
|
||||
* `helper <- 2`, and not `<<-`), outside any function nested in it (that one binds its own). The
|
||||
* assignment names are flow-insensitive: a call before the assignment, or under an `if` that may
|
||||
* skip it, stops at the local too and is left unresolved rather than bound to the outer function.
|
||||
* A name is
|
||||
* kept as written, backticks included, which is how a call site spells it. Read from the tree once
|
||||
* per function, so the work over a file is linear in its size; the walk is iterative because a
|
||||
* body can nest deeply.
|
||||
*/
|
||||
export function rFunctionLexicalNames(fn: SyntaxNode): string[] {
|
||||
const names = new Set<string>();
|
||||
const parameters = fn.childForFieldName('parameters');
|
||||
if (parameters !== null) {
|
||||
for (let i = 0; i < parameters.namedChildCount; i++) {
|
||||
const parameter = parameters.namedChild(i);
|
||||
if (parameter?.type !== 'parameter') continue;
|
||||
const name = parameter.childForFieldName('name');
|
||||
if (name?.type !== 'identifier' || name.text.length === 0) continue;
|
||||
names.add(name.text);
|
||||
}
|
||||
}
|
||||
for (const assigned of rLexicalAssignedNames(fn)) names.add(assigned);
|
||||
const stack: SyntaxNode[] = [];
|
||||
const body = fn.childForFieldName('body');
|
||||
if (body !== null) stack.push(body);
|
||||
for (let node = stack.pop(); node !== undefined; node = stack.pop()) {
|
||||
if (node.type === 'function_definition') continue;
|
||||
if (node.type === 'for_statement') {
|
||||
const variable = node.childForFieldName('variable');
|
||||
if (variable?.type === 'identifier' && variable.text.length > 0) names.add(variable.text);
|
||||
}
|
||||
for (let i = node.namedChildCount - 1; i >= 0; i--) {
|
||||
const child = node.namedChild(i);
|
||||
if (child !== null) stack.push(child);
|
||||
}
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
|
||||
/**
|
||||
* `LanguageProvider.scopeOwnsReceivers` for R: the module scope, each function frame that is
|
||||
* not an R6 method (and not nested in a function), and any function that binds `self` itself
|
||||
* (`self <- other()`, a formal called `self`) bind `self` with no type of their own, which makes
|
||||
* a `self$m()` call there a definitive negative unless that binding is typed. The tag is written by
|
||||
* `emitRScopeCaptures`, where the tree is at hand.
|
||||
*/
|
||||
const R_SELF_OWNER: ReadonlySet<string> = Object.freeze(new Set(['self']));
|
||||
|
||||
export function rScopeOwnsReceivers(match: CaptureMatch): ReadonlySet<string> | undefined {
|
||||
return match['@receiver-owner.self'] !== undefined ? R_SELF_OWNER : undefined;
|
||||
}
|
||||
299
gitnexus/src/core/ingestion/languages/r/export-pattern.ts
Normal file
299
gitnexus/src/core/ingestion/languages/r/export-pattern.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import { compileLinearRegexDetailed, type SharedWorkBudget } from './linear-regex.js';
|
||||
|
||||
/**
|
||||
* `exportPattern("...")` compilation for R NAMESPACE files.
|
||||
*
|
||||
* R evaluates `exportPattern()` arguments as POSIX (TRE) extended regular
|
||||
* expressions, whose bracket expressions support named classes such as
|
||||
* `[[:alpha:]]`. JavaScript's `RegExp` has no such classes: it reads
|
||||
* `[[:alpha:]]` as the set `[:alph` followed by a literal `]`. The RStudio
|
||||
* new-package template writes `exportPattern("^[[:alpha:]]+")`, so without a
|
||||
* translation step that pattern never matches anything.
|
||||
*
|
||||
* {@link compileRExportPattern} rewrites POSIX classes inside bracket
|
||||
* expressions to ASCII JS equivalents (the C-locale meaning of each class) and
|
||||
* compiles the result once. Patterns without `[:` are compiled verbatim, so
|
||||
* their behaviour is unchanged. It never throws.
|
||||
*
|
||||
* The compiled form is a linear-time matcher, not a `RegExp`: the pattern text comes
|
||||
* from the repository being analysed, and a backtracking engine lets a hostile
|
||||
* pattern such as `^(a+)+$` hang the analyzer (R itself uses the non-backtracking
|
||||
* TRE). See `linear-regex.ts` for the supported subset; a pattern outside it
|
||||
* (including one that uses a construct TRE reads differently from JavaScript, such as
|
||||
* `\<`, `{,n}`, a leading `]` or any backslash inside `[...]`) compiles to null and therefore
|
||||
* matches nothing. A leading `]` is a literal member when the pattern also has a POSIX class,
|
||||
* because the translation below escapes it. Callers that care why use
|
||||
* {@link compileRExportPatternDetailed}, so that a dropped pattern is reported
|
||||
* rather than silently treated as an empty export list.
|
||||
*
|
||||
* The argument reaches {@link compileRExportPattern} as an R string *value*:
|
||||
* {@link unescapeRString} first turns the NAMESPACE source text `"\\."` (the
|
||||
* R string value `\.`) into the regex `\.`. Callers that read a pattern out
|
||||
* of NAMESPACE text must unescape before compiling.
|
||||
*/
|
||||
|
||||
const POSIX_CLASSES: Readonly<Record<string, string>> = {
|
||||
alpha: 'A-Za-z',
|
||||
alnum: 'A-Za-z0-9',
|
||||
digit: '0-9',
|
||||
upper: 'A-Z',
|
||||
lower: 'a-z',
|
||||
punct: '!-\\/:-@\\[-`{-~',
|
||||
space: ' \\t\\n\\r\\f\\v',
|
||||
blank: ' \\t',
|
||||
cntrl: '\\x00-\\x1f\\x7f',
|
||||
print: '\\x20-\\x7e',
|
||||
graph: '\\x21-\\x7e',
|
||||
xdigit: '0-9A-Fa-f',
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate one bracket expression starting at `src[start] === '['`.
|
||||
* Returns the JS equivalent and the index just past its closing `]`, or null
|
||||
* when the expression is unterminated or names an unknown class.
|
||||
*/
|
||||
function translateBracket(src: string, start: number): { text: string; end: number } | null {
|
||||
let i = start + 1;
|
||||
let out = '[';
|
||||
if (src[i] === '^') {
|
||||
out += '^';
|
||||
i++;
|
||||
}
|
||||
// A `]` in first position is a literal member, not the terminator.
|
||||
if (src[i] === ']') {
|
||||
out += '\\]';
|
||||
i++;
|
||||
}
|
||||
while (i < src.length) {
|
||||
const ch = src[i];
|
||||
if (ch === ']') return { text: out + ']', end: i + 1 };
|
||||
if (ch === '[' && src[i + 1] === ':') {
|
||||
const close = src.indexOf(':]', i + 2);
|
||||
if (close === -1) return null;
|
||||
// An own-property lookup: `constructor`, `toString` or `__proto__` would otherwise
|
||||
// resolve on the prototype chain and splice that function's source text into the pattern.
|
||||
const name = src.slice(i + 2, close);
|
||||
if (!Object.hasOwn(POSIX_CLASSES, name)) return null;
|
||||
out += POSIX_CLASSES[name];
|
||||
i = close + 2;
|
||||
} else if (ch === '\\' && i + 1 < src.length) {
|
||||
out += ch + src[i + 1];
|
||||
i += 2;
|
||||
} else if (ch === '[' && (src[i + 1] === '.' || src[i + 1] === '=')) {
|
||||
return null; // a collating or equivalence element: not modelled, so the pattern is refused
|
||||
} else if (ch === '[' || ch === '^') {
|
||||
out += '\\' + ch;
|
||||
i++;
|
||||
} else {
|
||||
out += ch;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `src` has a backslash inside a bracket expression. TRE (POSIX) reads a backslash there
|
||||
* as a literal member; JavaScript, the translation above and `linear-regex.ts` read it as an
|
||||
* escape. The readings differ for more than names that contain a backslash (`[a\-z]` is a range
|
||||
* from `\` to `z` in TRE, `[\n]` holds `n`, `[\]]` ends at the first `]`), so such a pattern is
|
||||
* refused. Brackets are found as TRE finds them: a `[` opens one unless it is escaped, a `]`
|
||||
* straight after `[` or `[^` is a member, and the `]` that ends a `[:name:]`, `[.x.]` or `[=x=]`
|
||||
* element does not close it. This checks the pattern as written, before the translation adds
|
||||
* escapes of its own.
|
||||
*/
|
||||
function hasBackslashInBracket(src: string): boolean {
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
if (src[i] === '\\') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (src[i] !== '[') continue;
|
||||
let j = i + 1;
|
||||
if (src[j] === '^') j++;
|
||||
if (src[j] === ']') j++;
|
||||
for (; j < src.length && src[j] !== ']'; j++) {
|
||||
if (src[j] === '\\') return true;
|
||||
const kind = src[j + 1];
|
||||
if (src[j] === '[' && (kind === ':' || kind === '.' || kind === '=')) {
|
||||
const close = src.indexOf(kind + ']', j + 2);
|
||||
if (close === -1) return false; // unterminated: refused by the parser instead
|
||||
j = close + 1;
|
||||
}
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function translatePosixClasses(src: string): string | null {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const ch = src[i];
|
||||
if (ch === '\\' && i + 1 < src.length) {
|
||||
out += ch + src[i + 1];
|
||||
i += 2;
|
||||
} else if (ch === '[') {
|
||||
const bracket = translateBracket(src, i);
|
||||
if (bracket === null) return null;
|
||||
out += bracket.text;
|
||||
i = bracket.end;
|
||||
} else {
|
||||
out += ch;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A compiled `exportPattern()`; `test` answers whether the pattern matches anywhere in `name`. */
|
||||
export interface RExportMatcher {
|
||||
/** The pattern text that was compiled (after POSIX-class translation). */
|
||||
readonly source: string;
|
||||
test(name: string): boolean;
|
||||
/**
|
||||
* True once the matcher stopped answering because it spent its work budget (its own, or
|
||||
* the one shared by a package-config load); names it had not yet decided were then
|
||||
* treated as not matching. Absent on matchers that
|
||||
* cannot run out (a plain `RegExp`).
|
||||
*/
|
||||
readonly exhausted?: boolean;
|
||||
}
|
||||
|
||||
/** The outcome of {@link compileRExportPatternDetailed}: a matcher, or why there is none. */
|
||||
export type RExportPatternResult =
|
||||
| { readonly matcher: RExportMatcher }
|
||||
| { readonly matcher: null; readonly reason: string };
|
||||
|
||||
/**
|
||||
* Compile an `exportPattern()` argument, saying why when it cannot be compiled safely.
|
||||
* Matchers compiled with the same `shared` budget stop together once it is spent (see
|
||||
* `linear-regex.ts`); a package-config load passes one so that the number of patterns a
|
||||
* repository carries cannot multiply the per-matcher cost.
|
||||
*/
|
||||
export function compileRExportPatternDetailed(
|
||||
source: string,
|
||||
shared?: SharedWorkBudget,
|
||||
): RExportPatternResult {
|
||||
try {
|
||||
if (hasBackslashInBracket(source)) {
|
||||
return {
|
||||
matcher: null,
|
||||
reason: 'backslash inside a bracket expression (TRE reads it as a literal member)',
|
||||
};
|
||||
}
|
||||
let translated = source;
|
||||
if (source.includes('[:')) {
|
||||
const posix = translatePosixClasses(source);
|
||||
if (posix === null) {
|
||||
return {
|
||||
matcher: null,
|
||||
reason: 'unterminated bracket expression or unknown POSIX class',
|
||||
};
|
||||
}
|
||||
translated = posix;
|
||||
}
|
||||
const result = compileLinearRegexDetailed(translated, shared);
|
||||
return 'reason' in result
|
||||
? { matcher: null, reason: result.reason }
|
||||
: { matcher: result.regex };
|
||||
} catch {
|
||||
return { matcher: null, reason: 'pattern could not be compiled' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Compile an `exportPattern()` argument; null when it cannot be compiled safely. */
|
||||
export function compileRExportPattern(
|
||||
source: string,
|
||||
shared?: SharedWorkBudget,
|
||||
): RExportMatcher | null {
|
||||
return compileRExportPatternDetailed(source, shared).matcher;
|
||||
}
|
||||
|
||||
const SIMPLE_R_ESCAPES: Readonly<Record<string, string>> = {
|
||||
n: '\n',
|
||||
t: '\t',
|
||||
r: '\r',
|
||||
a: '\x07',
|
||||
b: '\b',
|
||||
f: '\f',
|
||||
v: '\v',
|
||||
'\\': '\\',
|
||||
'"': '"',
|
||||
"'": "'",
|
||||
'`': '`',
|
||||
' ': ' ',
|
||||
};
|
||||
|
||||
/**
|
||||
* Turn the body of an R string literal (the text between the quotes) into the
|
||||
* string value R would hold: `\\` -> `\`, `\"` -> `"`, `\n` -> newline, and
|
||||
* likewise `\t \r \a \b \f \v \' \``, backslash-space, `\nnn` (1-3 octal digits, the low
|
||||
* byte of the value; `\0` is a NUL, which R rejects, so it is kept verbatim), `\x`
|
||||
* (1-2 hex digits), `\u` (1-4 hex digits, bare or in braces) and `\U` (1-8 hex digits,
|
||||
* bare or in braces), the last two as code points. The digit limits are R's own: a
|
||||
* longer run is not part of the escape (tree-sitter-r's `escape_sequence` has the same limits). R
|
||||
* rejects any other backslash sequence; this leniently keeps it verbatim, so a
|
||||
* mistaken `"\."` still reaches the regex compiler as `\.`. Never throws.
|
||||
*/
|
||||
export function unescapeRString(body: string): string {
|
||||
if (!body.includes('\\')) return body;
|
||||
let out = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if (ch !== '\\' || i + 1 >= body.length) {
|
||||
out += ch;
|
||||
continue;
|
||||
}
|
||||
const next = body[i + 1];
|
||||
const simple = SIMPLE_R_ESCAPES[next];
|
||||
if (simple !== undefined) {
|
||||
out += simple;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const octal = readOctalEscape(body, i + 1);
|
||||
if (octal !== null) {
|
||||
out += octal.text;
|
||||
i = octal.end - 1;
|
||||
continue;
|
||||
}
|
||||
const hex = readHexEscape(body, i + 1);
|
||||
if (hex !== null) {
|
||||
out += hex.text;
|
||||
i = hex.end - 1;
|
||||
continue;
|
||||
}
|
||||
out += ch; // Unknown escape: keep the backslash; the next char is emitted normally.
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `\nnn` escape whose first digit is at `body[at]`; null when it is not an octal digit or the value is NUL. */
|
||||
function readOctalEscape(body: string, at: number): { text: string; end: number } | null {
|
||||
let i = at;
|
||||
while (i < body.length && i - at < 3 && body[i] >= '0' && body[i] <= '7') i++;
|
||||
if (i === at) return null;
|
||||
const code = parseInt(body.slice(at, i), 8) & 0xff;
|
||||
return code === 0 ? null : { text: String.fromCharCode(code), end: i };
|
||||
}
|
||||
|
||||
/** `x`/`u`/`U` escape whose introducer is at `body[at]`; null when malformed. */
|
||||
function readHexEscape(body: string, at: number): { text: string; end: number } | null {
|
||||
const kind = body[at];
|
||||
const maxDigits = kind === 'x' ? 2 : kind === 'u' ? 4 : kind === 'U' ? 8 : 0;
|
||||
if (maxDigits === 0) return null;
|
||||
let i = at + 1;
|
||||
const braced = kind !== 'x' && body[i] === '{';
|
||||
if (braced) i++;
|
||||
const start = i;
|
||||
while (i < body.length && i - start < maxDigits && /[0-9A-Fa-f]/.test(body[i])) i++;
|
||||
if (i === start) return null;
|
||||
if (braced) {
|
||||
if (body[i] !== '}') return null;
|
||||
}
|
||||
const code = parseInt(body.slice(start, i), 16);
|
||||
if (code > 0x10ffff) return null;
|
||||
return { text: String.fromCodePoint(code), end: braced ? i + 1 : i };
|
||||
}
|
||||
265
gitnexus/src/core/ingestion/languages/r/import-target.ts
Normal file
265
gitnexus/src/core/ingestion/languages/r/import-target.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
/**
|
||||
* R import resolution.
|
||||
* Handles library(), require(), source(), and pkg::func() resolution.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { SuffixIndex } from '../../utils/suffix-index.js';
|
||||
import { suffixResolve } from '../../utils/suffix-index.js';
|
||||
import { EXTENSIONS } from '../import-candidates.js';
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { rExportPatternMatches, type RPackageConfig } from './package-config.js';
|
||||
import { rBareName } from './bare-name.js';
|
||||
import { rFileTopLevel, rPackageDirForFile, type RFileTopLevel } from './namespace-imports.js';
|
||||
import type { ImportResolutionContext } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import { getWorkspaceFileIndex } from '../../utils/workspace-file-index.js';
|
||||
|
||||
/**
|
||||
* Low-level R import resolver (internal helper).
|
||||
* Resolves library/require/source paths to matching .R files.
|
||||
*
|
||||
* `normToRaw` (normalized path → first raw path with that spelling, built once per pass by
|
||||
* `getWorkspaceFileIndex`) answers the exact-path lookups of `source()` in O(1). Without it
|
||||
* they fall back to `indexOf` over `normalizedFileList`, which picks the same file (the first
|
||||
* in list order) but costs O(files) per `source()` import.
|
||||
*/
|
||||
export function resolveRImportInternal(
|
||||
filePath: string,
|
||||
rawImportPath: string,
|
||||
normalizedFileList: readonly string[],
|
||||
allFileList: readonly string[],
|
||||
rConfig: RPackageConfig | null,
|
||||
index?: SuffixIndex,
|
||||
normToRaw?: ReadonlyMap<string, string>,
|
||||
): string | string[] | null {
|
||||
const exactFile = (normalizedPath: string): string | undefined => {
|
||||
if (normToRaw !== undefined) return normToRaw.get(normalizedPath);
|
||||
const idx = normalizedFileList.indexOf(normalizedPath);
|
||||
return idx >= 0 ? allFileList[idx] : undefined;
|
||||
};
|
||||
const cleaned = rawImportPath.replace(/^["']|["']$/g, '');
|
||||
|
||||
// source() with file path
|
||||
if (cleaned.endsWith('.R') || cleaned.endsWith('.r')) {
|
||||
if (path.isAbsolute(cleaned)) {
|
||||
const normalized = cleaned.replace(/\\/g, '/');
|
||||
return exactFile(normalized) ?? null;
|
||||
}
|
||||
const dir = path.dirname(filePath);
|
||||
const resolved = path.join(dir, cleaned).replace(/\\/g, '/');
|
||||
const direct = exactFile(resolved);
|
||||
if (direct !== undefined) return direct;
|
||||
|
||||
const pathParts = cleaned.split('/').filter(Boolean);
|
||||
return suffixResolve(pathParts, normalizedFileList, allFileList, EXTENSIONS, index);
|
||||
}
|
||||
|
||||
// library("pkg") / require("pkg") — resolve to ALL files in the local package
|
||||
if (rConfig) {
|
||||
const pkgDir = rConfig.packages.get(cleaned);
|
||||
// A root-level package has the directory '' (falsy), so test for absence, not truthiness.
|
||||
if (pkgDir !== undefined) {
|
||||
const rDirPrefix = (pkgDir ? pkgDir + '/' : '') + 'R/';
|
||||
const files: string[] = [];
|
||||
for (let i = 0; i < normalizedFileList.length; i++) {
|
||||
if (
|
||||
normalizedFileList[i].startsWith(rDirPrefix) &&
|
||||
(normalizedFileList[i].endsWith('.R') || normalizedFileList[i].endsWith('.r'))
|
||||
) {
|
||||
files.push(allFileList[i]);
|
||||
}
|
||||
}
|
||||
if (files.length > 0) return files;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: suffix-based resolution
|
||||
const pathParts = cleaned.split('/').filter(Boolean);
|
||||
return suffixResolve(pathParts, normalizedFileList, allFileList, EXTENSIONS, index);
|
||||
}
|
||||
|
||||
/** Per-package index of top-level definitions, built once per `parsedFiles` array. */
|
||||
interface RPackageDefIndex {
|
||||
/** Files of the package's `R/` directory, with what each defines at top level. */
|
||||
readonly files: ReadonlyMap<string, RFileTopLevel>;
|
||||
/** Top-level symbol (backticks removed) → files (in `parsedFiles` order) that define it. */
|
||||
readonly definingFiles: ReadonlyMap<string, readonly string[]>;
|
||||
}
|
||||
|
||||
const packageDefIndexes = new WeakMap<
|
||||
readonly ParsedFile[],
|
||||
WeakMap<RPackageConfig, ReadonlyMap<string, RPackageDefIndex>>
|
||||
>();
|
||||
|
||||
function packageDefIndex(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
rConfig: RPackageConfig,
|
||||
pkg: string,
|
||||
): RPackageDefIndex | undefined {
|
||||
let byConfig = packageDefIndexes.get(parsedFiles);
|
||||
if (byConfig === undefined) packageDefIndexes.set(parsedFiles, (byConfig = new WeakMap()));
|
||||
let byPackage = byConfig.get(rConfig);
|
||||
if (byPackage === undefined) {
|
||||
const files = new Map<string, Map<string, RFileTopLevel>>();
|
||||
const defining = new Map<string, Map<string, string[]>>();
|
||||
for (const parsed of parsedFiles) {
|
||||
const location = rPackageDirForFile(parsed.filePath, rConfig);
|
||||
if (location === undefined) continue;
|
||||
const topLevel = rFileTopLevel(parsed);
|
||||
let pkgFiles = files.get(location.name);
|
||||
let pkgDefining = defining.get(location.name);
|
||||
if (pkgFiles === undefined || pkgDefining === undefined) {
|
||||
files.set(location.name, (pkgFiles = new Map()));
|
||||
defining.set(location.name, (pkgDefining = new Map()));
|
||||
}
|
||||
pkgFiles.set(parsed.filePath, topLevel);
|
||||
for (const name of topLevel.names) {
|
||||
const list = pkgDefining.get(name);
|
||||
if (list === undefined) pkgDefining.set(name, [parsed.filePath]);
|
||||
else list.push(parsed.filePath);
|
||||
}
|
||||
}
|
||||
const built = new Map<string, RPackageDefIndex>();
|
||||
for (const [name, pkgFiles] of files) {
|
||||
built.set(name, { files: pkgFiles, definingFiles: defining.get(name) ?? new Map() });
|
||||
}
|
||||
byConfig.set(rConfig, (byPackage = built));
|
||||
}
|
||||
return byPackage.get(pkg);
|
||||
}
|
||||
|
||||
/**
|
||||
* `s` without its leading and trailing backticks. Two scans, not `/^`+|`+$/g`: the pattern is
|
||||
* quadratic on `x` + n backticks + `y`, and a NAMESPACE entry is repository-controlled text.
|
||||
*/
|
||||
const stripBackticks = (s: string): string => {
|
||||
let start = 0;
|
||||
let end = s.length;
|
||||
while (start < end && s.charCodeAt(start) === 96) start++;
|
||||
while (end > start && s.charCodeAt(end - 1) === 96) end--;
|
||||
return s.slice(start, end);
|
||||
};
|
||||
|
||||
/** The backticked entries of one export set, stripped, and the set size they were read at. */
|
||||
const backtickedExportSymbols = new WeakMap<
|
||||
ReadonlySet<string>,
|
||||
{ readonly size: number; readonly symbols: ReadonlySet<string> }
|
||||
>();
|
||||
|
||||
/**
|
||||
* The symbols of the entries of `exports` that start or end with a backtick (a quoted
|
||||
* `` "`%+%`" `` entry keeps its backticks), stripped. Built in one pass per export set, so a
|
||||
* lookup is a set membership test instead of a walk over every export; a set that has grown or
|
||||
* shrunk since is read again.
|
||||
*/
|
||||
function backtickedExports(exports: ReadonlySet<string>): ReadonlySet<string> {
|
||||
const cached = backtickedExportSymbols.get(exports);
|
||||
if (cached !== undefined && cached.size === exports.size) return cached.symbols;
|
||||
const symbols = new Set<string>();
|
||||
for (const entry of exports) {
|
||||
if (entry.charCodeAt(0) === 96 || entry.charCodeAt(entry.length - 1) === 96) {
|
||||
symbols.add(stripBackticks(entry));
|
||||
}
|
||||
}
|
||||
backtickedExportSymbols.set(exports, { size: exports.size, symbols });
|
||||
return symbols;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a NAMESPACE `importFrom(pkg, importedName)` to the `R/` file(s) of the
|
||||
* local package `pkg` that define `importedName` at top level, or `null`.
|
||||
* `importedName` is the symbol (`%||%`) or a definition's own spelling (`` `%||%` ``): files
|
||||
* are found by the symbol, while the dotted-tail guard below compares the spelling, because
|
||||
* finalize indexes a definition by the text of its name as written.
|
||||
*
|
||||
* `null` (no binding, no IMPORTS edge) when: `pkg` is not a local package
|
||||
* (external packages never suffix-match anything); `importedName` is dotted
|
||||
* (finalize keys defs by the text after the last `.`, so it can never bind);
|
||||
* the provider has a NAMESPACE that does not export the name; no file defines
|
||||
* it at top level; or a defining file also holds a dotted top-level def whose
|
||||
* tail equals `importedName` (finalize would index `print.tidy` under `tidy`
|
||||
* and bind the WRONG def — the guard is order-blind, so it also refuses the
|
||||
* layout where the bare def comes first). `null` degrades the call site to the
|
||||
* baseline `global-name-fallback` guess. Never builds a workspace file index.
|
||||
*/
|
||||
export function resolveRNamedImport(
|
||||
rConfig: RPackageConfig | null,
|
||||
targetRaw: string,
|
||||
importedName: string,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
): readonly string[] | null {
|
||||
const pkg = targetRaw.replace(/^["']|["']$/g, '');
|
||||
const pkgDir = rConfig?.packages.get(pkg);
|
||||
if (rConfig === null || pkgDir === undefined) return null;
|
||||
|
||||
if (importedName.includes('.')) return null;
|
||||
|
||||
const symbol = rBareName(importedName);
|
||||
const info = rConfig.namespaceInfoByPackageDir.get(pkgDir);
|
||||
if (info !== undefined && info.hasNamespaceFile) {
|
||||
// An `S3method()` registration alone is not an export (`RNamespaceInfo.s3RegisteredOnly`).
|
||||
const exported =
|
||||
(info.s3RegisteredOnly?.has(symbol) !== true &&
|
||||
(info.namedExports.has(symbol) || backtickedExports(info.namedExports).has(symbol))) ||
|
||||
rExportPatternMatches(info, symbol);
|
||||
if (!exported) return null;
|
||||
}
|
||||
|
||||
const index = packageDefIndex(parsedFiles, rConfig, pkg);
|
||||
const defining = index?.definingFiles.get(symbol);
|
||||
if (index === undefined || defining === undefined || defining.length === 0) return null;
|
||||
if (defining.some((file) => index.files.get(file)?.dottedTails.has(importedName) === true)) {
|
||||
return null;
|
||||
}
|
||||
return defining;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ScopeResolver.resolveImportTarget`-shaped adapter over
|
||||
* `resolveRImportInternal`. Two responsibilities live only here, not in the
|
||||
* internal resolver:
|
||||
*
|
||||
* - Memoization: `getWorkspaceFileIndex` derives `{ normalized, all, index }`
|
||||
* once per `allFilePaths` Set identity (see its own doc comment) and this
|
||||
* adapter calls it on every invocation instead of materializing fresh
|
||||
* arrays per import — the O(imports × files) regression PR #1918/#2911
|
||||
* exist to catch (`import-target-index-reuse.contract.test.ts`).
|
||||
* - Cross-language false-positive guard: a `library()`/`require()` package
|
||||
* name not found in `rConfig.packages` would otherwise fall through to
|
||||
* `resolveRImportInternal`'s generic suffix fallback — and `EXTENSIONS`
|
||||
* (`languages/import-candidates.ts`) has no `.R`/`.r` entry, so a bare R package reference can
|
||||
* suffix-match an unrelated same-named file in another language.
|
||||
* `context.parsedImport.kind === 'wildcard'` is exactly the
|
||||
* `library()`/`require()` shape (see `interpretRImport`), so the guard is
|
||||
* scoped to that kind only — `source()`'s own suffix-based file-path
|
||||
* fallback is unaffected.
|
||||
* - NAMESPACE `importFrom()` names (`kind: 'named'`, synthesised by
|
||||
* `populateRNamespaceImports`): resolved by {@link resolveRNamedImport}
|
||||
* before `getWorkspaceFileIndex`, so they build no file index.
|
||||
*/
|
||||
export function resolveRImportTarget(
|
||||
targetRaw: string,
|
||||
fromFile: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
resolutionConfig?: unknown,
|
||||
context?: ImportResolutionContext,
|
||||
): string | readonly string[] | null {
|
||||
const rConfig = (resolutionConfig as RPackageConfig | null | undefined) ?? null;
|
||||
|
||||
if (context?.parsedImport?.kind === 'wildcard') {
|
||||
const cleaned = targetRaw.replace(/^["']|["']$/g, '');
|
||||
if (rConfig === null || !rConfig.packages.has(cleaned)) return null;
|
||||
}
|
||||
|
||||
if (context?.parsedImport?.kind === 'named') {
|
||||
return resolveRNamedImport(
|
||||
rConfig,
|
||||
targetRaw,
|
||||
context.parsedImport.importedName,
|
||||
context.parsedFiles,
|
||||
);
|
||||
}
|
||||
|
||||
const { normalized, all, index, normToRaw } = getWorkspaceFileIndex(allFilePaths);
|
||||
return resolveRImportInternal(fromFile, targetRaw, normalized, all, rConfig, index, normToRaw);
|
||||
}
|
||||
47
gitnexus/src/core/ingestion/languages/r/interpret.ts
Normal file
47
gitnexus/src/core/ingestion/languages/r/interpret.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import type { CaptureMatch, ParsedImport, ParsedTypeBinding } from 'gitnexus-shared';
|
||||
|
||||
/**
|
||||
* `source()` is a side-effect import — it establishes a file dependency but
|
||||
* binds no name (cross-file free calls into a `source()`d file resolve via
|
||||
* `rScopeResolver.allowGlobalFreeCallFallback` instead).
|
||||
*
|
||||
* `library()`/`require()` are wildcard imports — the exported names of the named
|
||||
* local package (every top-level name when it has no NAMESPACE) become visible
|
||||
* (`rScopeResolver.expandsWildcardTo`).
|
||||
*
|
||||
* `resolveRImportTarget` (`languages/r/import-target.ts`) guards on
|
||||
* `context.parsedImport.kind === 'wildcard'` specifically to refuse a
|
||||
* `library()`/`require()` package name that isn't a known local package
|
||||
* (rather than falling through to a cross-language suffix-match false
|
||||
* positive) — the two kinds set here are what that guard keys on.
|
||||
*/
|
||||
export function interpretRImport(captures: CaptureMatch): ParsedImport | null {
|
||||
const source = captures['@import.source']?.text;
|
||||
// The capture keeps its quotes, so `source("")` is two characters long: test the bare text.
|
||||
if (source === undefined || source.replace(/^["']|["']$/g, '').length === 0) return null;
|
||||
|
||||
if (captures['@import.wildcard'] !== undefined) {
|
||||
return { kind: 'wildcard', targetRaw: source };
|
||||
}
|
||||
return { kind: 'side-effect', targetRaw: source };
|
||||
}
|
||||
|
||||
/**
|
||||
* `x <- Type$new(...)` — R6's constructor spelling. `@type-binding.type` is
|
||||
* the `$` receiver (the class name), captured only when the extract
|
||||
* operator's rhs is literally `new` (see `query.ts`), so this always
|
||||
* resolves via the shared `constructor-inferred` sub-kind — the same one
|
||||
* Zig's `interpretZigTypeBinding` uses for `Counter.init()`.
|
||||
*/
|
||||
export function interpretRTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
|
||||
const name = captures['@type-binding.name']?.text;
|
||||
const type = captures['@type-binding.type']?.text;
|
||||
if (name === undefined || type === undefined) return null;
|
||||
|
||||
// `self` in an R6 method, typed as its class by `emitRScopeCaptures` (the shared `self` source,
|
||||
// as Ruby's interpreter reads it).
|
||||
if (captures['@type-binding.self'] !== undefined) {
|
||||
return { boundName: name, rawTypeName: type, source: 'self' };
|
||||
}
|
||||
return { boundName: name, rawTypeName: type, source: 'constructor-inferred' };
|
||||
}
|
||||
1141
gitnexus/src/core/ingestion/languages/r/linear-regex.ts
Normal file
1141
gitnexus/src/core/ingestion/languages/r/linear-regex.ts
Normal file
File diff suppressed because it is too large
Load diff
1440
gitnexus/src/core/ingestion/languages/r/namespace-imports.ts
Normal file
1440
gitnexus/src/core/ingestion/languages/r/namespace-imports.ts
Normal file
File diff suppressed because it is too large
Load diff
187
gitnexus/src/core/ingestion/languages/r/naming-argument.ts
Normal file
187
gitnexus/src/core/ingestion/languages/r/naming-argument.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* Which argument of an R call names what the call defines or imports.
|
||||
*
|
||||
* `R_QUERIES` captures every string-valued argument of `setClass`,
|
||||
* `setRefClass`, `setGeneric` and `setMethod`, and the scope query captures every
|
||||
* identifier- or string-valued argument of `library`, `require` and `source` and
|
||||
* every string argument of `setClass`/`setRefClass`. Only one of them is the
|
||||
* definition's name or the import's source; the rest are `contains = "VIRTUAL"`,
|
||||
* `valueClass = "numeric"`, the class slot of `setMethod("show", "Foo", ...)`,
|
||||
* `lib.loc = libO`, and so on. The choice is made here, in code, rather than in the
|
||||
* query: a tree-sitter query can only skip an unbounded run of leading
|
||||
* comments and named arguments with a repeated sibling group, and that costs
|
||||
* time quadratic in the argument count on every call in the file (a single
|
||||
* `list(a0 = 0, ...)` with 16000 arguments took 18s). This check is linear: it
|
||||
* reads each argument list once and caches the answer.
|
||||
*
|
||||
* R matches arguments by name first, then positionally. The naming argument is
|
||||
* therefore the argument spelled with the call's first formal (`Class = "A"`),
|
||||
* or, when no argument anywhere in the list is spelled that way, the first
|
||||
* unnamed argument. Comments, commas and other named arguments may precede it,
|
||||
* and a `Class = ...` after an unnamed argument takes the formal from it, so
|
||||
* `setClass(a = "Q", "B", Class = "A")` names `A` only.
|
||||
*
|
||||
* A formal may be spelled bare, in backticks or in quotes (`Class`, `` `Class` ``,
|
||||
* `"Class"`); all name the same argument. Partial matching of an abbreviated
|
||||
* name (`Cl = "A"`) is not modelled.
|
||||
*/
|
||||
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import type { CaptureMap } from '../../language-provider.js';
|
||||
|
||||
/** The first formal of each call whose first argument names what it defines or imports. */
|
||||
const NAMING_FORMAL: Readonly<Record<string, string>> = {
|
||||
setClass: 'Class',
|
||||
setRefClass: 'Class',
|
||||
setGeneric: 'name',
|
||||
setMethod: 'f',
|
||||
library: 'package',
|
||||
require: 'package',
|
||||
source: 'file',
|
||||
};
|
||||
|
||||
/** An argument's name with one pair of surrounding backticks or quotes removed. */
|
||||
function argumentNameText(name: SyntaxNode): string {
|
||||
const text = name.text;
|
||||
if (text.length >= 2) {
|
||||
const first = text[0];
|
||||
if ((first === '`' || first === '"' || first === "'") && text[text.length - 1] === first) {
|
||||
return text.slice(1, -1);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per tree, per (argument list, formal): the id of the unnamed argument that takes
|
||||
* the formal, or null when none does. One pass over the argument list answers every
|
||||
* later match in that list in constant time. Looking backwards from each match
|
||||
* instead is quadratic in the number of preceding comments (sibling lookup is
|
||||
* linear in the sibling's position), so the list is read forwards, once.
|
||||
*/
|
||||
const namingArgumentIds = new WeakMap<object, Map<string, number | null>>();
|
||||
|
||||
function namingUnnamedArgumentId(argumentList: SyntaxNode, formal: string): number | null {
|
||||
let perTree = namingArgumentIds.get(argumentList.tree);
|
||||
if (perTree === undefined) {
|
||||
perTree = new Map();
|
||||
namingArgumentIds.set(argumentList.tree, perTree);
|
||||
}
|
||||
const key = `${argumentList.id}:${formal}`;
|
||||
const cached = perTree.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
// The first unnamed argument takes the formal, unless an argument anywhere in the
|
||||
// list is spelled with it (R matches by name first), which retracts that choice.
|
||||
let found: number | null = null;
|
||||
for (const child of argumentList.namedChildren) {
|
||||
if (child.type !== 'argument') continue; // comment, comma
|
||||
const name = child.childForFieldName('name');
|
||||
if (name === null) {
|
||||
found ??= child.id;
|
||||
} else if (argumentNameText(name) === formal) {
|
||||
found = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
perTree.set(key, found);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The argument R would match to `target` in a call to a function whose formals are
|
||||
* `formals`, in order. R matches named arguments first (a name that spells a formal
|
||||
* binds it) and then fills the formals that remain from the unnamed arguments, in
|
||||
* order. Partial matching of abbreviated names (`sig =`) is not modelled. Returns
|
||||
* null when no argument reaches `target`. Linear in the argument count; meant for
|
||||
* the one call a definition hook is looking at, not for every call in a file.
|
||||
*/
|
||||
export function getRMatchedArgument(
|
||||
argumentList: SyntaxNode,
|
||||
formals: readonly string[],
|
||||
target: string,
|
||||
): SyntaxNode | null {
|
||||
const unnamed: SyntaxNode[] = [];
|
||||
const named = new Set<string>();
|
||||
for (const child of argumentList.namedChildren) {
|
||||
if (child.type !== 'argument') continue; // comment, comma
|
||||
const name = child.childForFieldName('name');
|
||||
if (name === null) {
|
||||
unnamed.push(child);
|
||||
continue;
|
||||
}
|
||||
const text = argumentNameText(name);
|
||||
if (text === target) return child;
|
||||
named.add(text);
|
||||
}
|
||||
const remaining = formals.filter((formal) => !named.has(formal));
|
||||
const index = remaining.indexOf(target);
|
||||
return index === -1 ? null : (unnamed[index] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `argument` (an `argument` node) is the one that names the call's
|
||||
* definition, given the call's first `formal`.
|
||||
*/
|
||||
export function isRNamingArgument(argument: SyntaxNode, formal: string): boolean {
|
||||
const ownName = argument.childForFieldName('name');
|
||||
if (ownName !== null) return argumentNameText(ownName) === formal;
|
||||
const argumentList = argument.parent;
|
||||
if (argumentList === null) return false;
|
||||
return namingUnnamedArgumentId(argumentList, formal) === argument.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a `R_QUERIES` match captured an argument of one of the calls above
|
||||
* that is NOT the naming argument, so the definition it would mint is spurious.
|
||||
* Matches of any other shape (R6, functions, slots, calls, imports) are never
|
||||
* skipped. The legacy `@import` matches are discarded by the parse worker, so
|
||||
* they are not considered here.
|
||||
*/
|
||||
export function isRNonNamingArgumentCapture(captureMap: CaptureMap): boolean {
|
||||
const fn = captureMap['_fn'];
|
||||
if (fn === undefined) return false;
|
||||
const formal = NAMING_FORMAL[fn.text];
|
||||
if (formal === undefined) return false;
|
||||
|
||||
// Definitions capture the string's content. Slot matches capture a named
|
||||
// argument's identifier and are not ours.
|
||||
const captured = captureMap['name'];
|
||||
if (captured === undefined || captured.type !== 'string_content') return false;
|
||||
const argument = captured.parent?.parent;
|
||||
if (argument === undefined || argument === null || argument.type !== 'argument') return false;
|
||||
|
||||
return !isRNamingArgument(argument, formal);
|
||||
}
|
||||
|
||||
/** The captures of a raw tree-sitter match, as the scope emitter sees them. */
|
||||
interface RawCapture {
|
||||
readonly name: string;
|
||||
readonly node: SyntaxNode;
|
||||
}
|
||||
|
||||
/** Callee captures of the scope query's naming-argument patterns (`r/query.ts`). */
|
||||
const SCOPE_CALLEE_CAPTURES: ReadonlySet<string> = new Set(['_s4_decl', '_srcfn', '_libfn']);
|
||||
|
||||
/**
|
||||
* True when a scope-query match captured an argument of `setClass` /
|
||||
* `setRefClass` (`@declaration.name`), `library` / `require` or `source`
|
||||
* (`@import.source`) that is NOT the naming argument. The scope query captures
|
||||
* every such argument; the emitter drops these matches.
|
||||
*/
|
||||
export function isRNonNamingScopeMatch(captures: readonly RawCapture[]): boolean {
|
||||
const fn = captures.find((c) => SCOPE_CALLEE_CAPTURES.has(c.name));
|
||||
if (fn === undefined) return false;
|
||||
const formal = NAMING_FORMAL[fn.node.text];
|
||||
if (formal === undefined) return false;
|
||||
|
||||
const captured = captures.find(
|
||||
(c) => c.name === 'declaration.name' || c.name === 'import.source',
|
||||
);
|
||||
if (captured === undefined) return false;
|
||||
const argument =
|
||||
captured.node.type === 'string_content' ? captured.node.parent?.parent : captured.node.parent;
|
||||
if (argument === undefined || argument === null || argument.type !== 'argument') return false;
|
||||
|
||||
return !isRNamingArgument(argument, formal);
|
||||
}
|
||||
55
gitnexus/src/core/ingestion/languages/r/owner-hooks.ts
Normal file
55
gitnexus/src/core/ingestion/languages/r/owner-hooks.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* R owner hooks for the shared ingestion pipeline.
|
||||
*
|
||||
* R has no class syntax: R6 / RefClass members belong to a call or assignment
|
||||
* (`Foo <- R6::R6Class(...)`, `setRefClass(...)`), not to an enclosing
|
||||
* container node. `resolveMemberOwnerNode` hands the member extractors that
|
||||
* owner node. Members whose owner is a string (`setMethod("area", "Circle",
|
||||
* ...)`) or a top-level R6/RefClass call are instead tied to their class after
|
||||
* all chunks merge: `definitionPropertiesExtractor` records the owner name as
|
||||
* an `ownerNameHint`, resolved by R's `postParse` hook.
|
||||
*/
|
||||
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import type { DefinitionPropertiesContext } from '../../language-provider.js';
|
||||
import {
|
||||
findRFieldOwnerNode,
|
||||
getRClassNameOfOwnerNode,
|
||||
getRTopLevelPropertyOwnerName,
|
||||
} from '../../field-extractors/r.js';
|
||||
import { getRTopLevelMethodOwnerName } from '../../method-extractors/r.js';
|
||||
|
||||
/** `LanguageProvider.resolveMemberOwnerNode` for R. */
|
||||
export const rResolveMemberOwnerNode = (definitionNode: SyntaxNode): SyntaxNode | null =>
|
||||
findRFieldOwnerNode(definitionNode);
|
||||
|
||||
/**
|
||||
* `LanguageProvider.resolveDetachedOwnerName` for R: the class name an ancestor
|
||||
* call or assignment declares (`Foo <- R6Class(...)`, `setClass("Foo", ...)`,
|
||||
* `setRefClass("Foo", ...)`) or the class a `setMethod("m", "Foo", ...)` call
|
||||
* names in its signature. It qualifies the member's id (`Method:<file>:Foo.m`,
|
||||
* `Property:<file>:Foo.x`), so two classes in one file that share a member name
|
||||
* stay distinct nodes. It carries no graph id: the owner edge is still attached
|
||||
* by `postParse` through `ownerNameHint`.
|
||||
*/
|
||||
export const rResolveDetachedOwnerName = (ancestor: SyntaxNode): string | undefined =>
|
||||
getRClassNameOfOwnerNode(ancestor) ?? getRTopLevelMethodOwnerName(ancestor) ?? undefined;
|
||||
|
||||
/**
|
||||
* `LanguageProvider.definitionPropertiesExtractor` for R: the deferred owner
|
||||
* name (`ownerNameHint`) of a `Method`/`Property` whose owning class is named by
|
||||
* a string argument or a top-level R6/RefClass call rather than a syntactic
|
||||
* ancestor.
|
||||
*/
|
||||
export const rDefinitionProperties = (
|
||||
ctx: DefinitionPropertiesContext,
|
||||
): Readonly<Record<string, unknown>> | undefined => {
|
||||
const ownerNameHint =
|
||||
ctx.nodeLabel === 'Method'
|
||||
? (getRTopLevelMethodOwnerName(ctx.definitionNode) ??
|
||||
getRTopLevelPropertyOwnerName(ctx.definitionNode))
|
||||
: ctx.nodeLabel === 'Property'
|
||||
? getRTopLevelPropertyOwnerName(ctx.definitionNode)
|
||||
: null;
|
||||
return ownerNameHint ? { ownerNameHint } : undefined;
|
||||
};
|
||||
724
gitnexus/src/core/ingestion/languages/r/package-config.ts
Normal file
724
gitnexus/src/core/ingestion/languages/r/package-config.ts
Normal file
|
|
@ -0,0 +1,724 @@
|
|||
import { constants, type Dirent } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {
|
||||
parseRNamespaceExports,
|
||||
parseRNamespaceImportFrom,
|
||||
parseRNamespaceImportPackages,
|
||||
rFileTopLevel,
|
||||
rOwningPackageDir,
|
||||
rRecordSymbolVisibilityMemo,
|
||||
type RNamespaceImportFromEntry,
|
||||
type RSymbolVisibility,
|
||||
} from './namespace-imports.js';
|
||||
import { rBareName } from './bare-name.js';
|
||||
import { compileRExportPatternDetailed, type RExportMatcher } from './export-pattern.js';
|
||||
import { SharedWorkBudget } from './linear-regex.js';
|
||||
import type { RUnindexedSource } from './unindexed-sources.js';
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { isDev } from '../../utils/env.js';
|
||||
import { isHardcodedIgnoredDirectory } from '../../../../config/ignore-service.js';
|
||||
import { logger } from '../../../logger.js';
|
||||
|
||||
/** An `exportPattern()` argument that was not compiled, and why. */
|
||||
export interface RDroppedExportPattern {
|
||||
/** The pattern text as R would see it (the string value, after unescaping). */
|
||||
readonly pattern: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/** R package config parsed from DESCRIPTION files in a multi-package repo */
|
||||
export interface RPackageConfig {
|
||||
/**
|
||||
* Map of package name to directory path relative to repo root. When several
|
||||
* directories declare the same `Package:` name the shallowest directory wins
|
||||
* and, among equal depths, the one whose whole path string sorts first by
|
||||
* code unit (so `a-b/x` beats `a/x`: `-` sorts before `/`); the others are
|
||||
* not entries here (their NAMESPACE is still recorded in
|
||||
* {@link namespaceInfoByPackageDir}, which is keyed by directory). Installed
|
||||
* libraries and check output (`renv`, `packrat`, `revdep`, `<name>.Rcheck`), dot-directories
|
||||
* and the directories the indexer ignores are not searched, and a `DESCRIPTION` with a
|
||||
* `Built:` field (an installed package) is not read, so none of their packages are entries here.
|
||||
*/
|
||||
packages: Map<string, string>;
|
||||
/** Package-scoped NAMESPACE config keyed by package dir relative to repo root. */
|
||||
namespaceInfoByPackageDir: Map<string, RNamespaceInfo>;
|
||||
/**
|
||||
* Every directory (relative to the repo root, `''` for the root) in which discovery read a
|
||||
* `DESCRIPTION` with a `Package:` line: the directories of {@link packages} plus the
|
||||
* directories of same-name copies that lost to them, with or without a NAMESPACE. The
|
||||
* qualifier-locality decision reads it, so that a file under such a directory's `R/` is not
|
||||
* taken as evidence for a package named after its directory. Optional so that hand-built
|
||||
* configs read as "the directories of {@link packages} and {@link namespaceInfoByPackageDir}".
|
||||
*/
|
||||
packageDirs?: ReadonlySet<string>;
|
||||
/**
|
||||
* True when discovery cannot rule out an undiscovered package: a directory
|
||||
* of the main walk could not be read, or a subtree the walk skipped (below
|
||||
* the depth limit, or still queued when the directory cap stopped it) may
|
||||
* hide a package. Skipped subtrees are scanned once more for `DESCRIPTION`
|
||||
* files (see {@link hidesUndiscoveredPackage}); the flag is set only when
|
||||
* that scan finds a `Package:` name absent from {@link packages}, exceeds
|
||||
* its directory budget, or hits an unreadable path. A skipped subtree with
|
||||
* no package inside (`tests/testthat/fixtures/...`, `inst/extdata/...`) or
|
||||
* 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".
|
||||
*/
|
||||
truncated?: boolean;
|
||||
/**
|
||||
* R files of the repository that are not indexed sources (skipped directories, `.Rprofile`,
|
||||
* `R/*.S`), with the identifier tokens each contains. Set only by the rename-preview reload
|
||||
* (`refreshResolutionConfig`), so the rename guard can refuse a name such a file uses; absent
|
||||
* means the repository was not scanned.
|
||||
*/
|
||||
unindexedRSources?: readonly RUnindexedSource[];
|
||||
}
|
||||
|
||||
export interface RNamespaceInfo {
|
||||
/** True when the package has a readable NAMESPACE file. */
|
||||
hasNamespaceFile: boolean;
|
||||
/** Explicit named exports from export()/exportClasses()/exportMethods()/S3method(). */
|
||||
namedExports: Set<string>;
|
||||
/** Precompiled matchers from exportPattern("..."), compiled once here so
|
||||
* `refineRExportStatus` doesn't recompile per node it checks. POSIX bracket
|
||||
* classes such as `[[:alpha:]]` are translated to JS equivalents first
|
||||
* (see `compileRExportPattern`). The matchers run in linear time, so a hostile
|
||||
* pattern cannot stall analysis. Patterns that fail to compile, or that the
|
||||
* linear-time matcher cannot handle, are dropped at this stage (they never match)
|
||||
* and listed in {@link droppedExportPatterns}. */
|
||||
exportPatterns: RExportMatcher[];
|
||||
/**
|
||||
* Non-empty `exportPattern()` arguments that could not be compiled, with the reason.
|
||||
* A dropped pattern exports nothing, so every name the pattern was meant to cover
|
||||
* reads as unexported; `reportDroppedRExportPatterns` turns these into warnings.
|
||||
* Optional so that hand-built configs read as "nothing dropped".
|
||||
*/
|
||||
droppedExportPatterns?: readonly RDroppedExportPattern[];
|
||||
/**
|
||||
* How many further non-empty `exportPattern()` arguments were dropped but not listed in
|
||||
* {@link droppedExportPatterns}, which holds at most {@link MAX_LISTED_DROPPED_PATTERNS}
|
||||
* per package so that a NAMESPACE of hundreds of thousands of refused patterns cannot
|
||||
* produce hundreds of thousands of warnings. Reported as one line.
|
||||
*/
|
||||
omittedDroppedExportPatterns?: number;
|
||||
/**
|
||||
* The work budget this package's matchers draw on: a sub-budget of the load's. Absent on
|
||||
* hand-built configs. {@link rExportPatternMatches} stops consulting the matchers once it
|
||||
* is spent.
|
||||
*/
|
||||
exportPatternBudget?: SharedWorkBudget;
|
||||
/**
|
||||
* True when the NAMESPACE reader skipped, dropped or could not read something that may hide an
|
||||
* export (see {@link RNamespaceExports.unreadable}): a conditional export, a raw-string or
|
||||
* multi-line argument, a malformed directive. A name absent from the export lists is then not
|
||||
* proven private. Optional so that hand-built configs read as "nothing hidden".
|
||||
*/
|
||||
exportsUnreadable?: boolean;
|
||||
/** `importFrom(pkg, name)` pairs in NAMESPACE file order (all entries, incl. self-imports and duplicates). */
|
||||
importFrom: readonly RNamespaceImportFromEntry[];
|
||||
/**
|
||||
* Every name an `S3method()` directive registers (bare, as {@link rBareName} spells it). A
|
||||
* registration is not an export: it makes the method reachable through S3 dispatch only. The
|
||||
* graph keeps such a method public (`isExported`) and `rename` refuses it, but resolution does
|
||||
* not treat it as exported (see {@link s3RegisteredOnly}). Optional so that hand-built configs
|
||||
* read as "no registration".
|
||||
*/
|
||||
s3Registered?: ReadonlySet<string>;
|
||||
/**
|
||||
* The {@link s3Registered} names that no `export()`, `exportClasses()` or `exportMethods()`
|
||||
* entry lists. They stay in {@link namedExports} (which records what the directives name), and
|
||||
* {@link rNamespaceExports} answers false for them unless an `exportPattern()` matches, so
|
||||
* `library()`, `pkg::name`, `importFrom()` and the visibility memo see real exports only.
|
||||
* Optional, absent reads as empty.
|
||||
*/
|
||||
s3RegisteredOnly?: ReadonlySet<string>;
|
||||
/**
|
||||
* Packages the NAMESPACE declares an import from other than through `importFrom()`:
|
||||
* `import(Q)` (a named `except =` argument is skipped; the package is still imported) and the
|
||||
* S4 forms `importClassesFrom(Q, ...)` and `importMethodsFrom(Q, ...)`. Read by the
|
||||
* package-visibility rule of the unique-name guess. Optional, absent reads as empty.
|
||||
*/
|
||||
importPackages?: ReadonlySet<string>;
|
||||
/**
|
||||
* Package names of the DESCRIPTION `Depends:` field (continuation lines included, `R` itself
|
||||
* dropped): the packages attaching this one also attaches. Optional, absent reads as empty.
|
||||
*/
|
||||
depends?: ReadonlySet<string>;
|
||||
/**
|
||||
* True when the NAMESPACE reader skipped or could not read something that may hide an import:
|
||||
* a conditional (`if` / `else`) body or a malformed directive that names an import-family
|
||||
* directive, or an import argument it cannot read. The imports above are then a lower bound,
|
||||
* so the package-visibility rule is not applied. Optional, absent reads as false.
|
||||
*/
|
||||
importsUnreadable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Most patterns named in warnings per package, for dropped patterns (the rest are counted,
|
||||
* see `omittedDroppedExportPatterns`) and for patterns cut short by the budget alike, so
|
||||
* that a NAMESPACE of tens of thousands of patterns cannot produce as many warnings.
|
||||
*/
|
||||
export const MAX_LISTED_DROPPED_PATTERNS = 50;
|
||||
|
||||
/** Most names remembered as matched per package, so a hostile name list cannot grow the set without bound. */
|
||||
const MAX_REMEMBERED_MATCHES = 100_000;
|
||||
|
||||
/** Names a pattern list matched while its budget lasted (only for lists that draw on a budget). */
|
||||
const matchedWhileBudgetLasted = new WeakMap<object, Set<string>>();
|
||||
|
||||
/**
|
||||
* True when one of the package's `exportPattern()` matchers matches `name`. Once the
|
||||
* package's work budget (or the load's) is spent it stops asking the matchers, so the cost no
|
||||
* longer grows as patterns x names. A name it matched while the budget lasted keeps that
|
||||
* answer (one set lookup, so a duplicate-named definition does not flip from exported to
|
||||
* private), but only for the first {@link MAX_REMEMBERED_MATCHES} (100,000) distinct matched
|
||||
* names per package: a name matched after that is not remembered and answers false once the
|
||||
* budget is spent. A name it had not decided answers false, and the patterns it did not get to run are
|
||||
* then reported by {@link reportRExportPatternProblems}.
|
||||
*/
|
||||
export function rExportPatternMatches(
|
||||
info: Pick<RNamespaceInfo, 'exportPatterns' | 'exportPatternBudget'>,
|
||||
name: string,
|
||||
): boolean {
|
||||
const budget = info.exportPatternBudget;
|
||||
let remembered = matchedWhileBudgetLasted.get(info.exportPatterns);
|
||||
for (const pattern of info.exportPatterns) {
|
||||
if (budget?.isSpent === true) {
|
||||
budget.cutShort = true;
|
||||
return remembered?.has(name) === true;
|
||||
}
|
||||
if (pattern.test(name)) {
|
||||
if (budget !== undefined) {
|
||||
if (remembered === undefined) {
|
||||
remembered = new Set();
|
||||
matchedWhileBudgetLasted.set(info.exportPatterns, remembered);
|
||||
}
|
||||
if (remembered.size < MAX_REMEMBERED_MATCHES) remembered.add(name);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the package's NAMESPACE exports `symbol`: an explicit `export()`, `exportClasses()`
|
||||
* or `exportMethods()` entry, or an `exportPattern()` match. An `S3method()` registration alone
|
||||
* does not export ({@link RNamespaceInfo.s3RegisteredOnly}). The one test resolution, the
|
||||
* visibility memo, the indexer's export refinement and the rename guard use; the refinement and
|
||||
* the guard also keep a registered method public ({@link RNamespaceInfo.s3Registered}).
|
||||
*/
|
||||
export function rNamespaceExports(
|
||||
info: Pick<
|
||||
RNamespaceInfo,
|
||||
'namedExports' | 'exportPatterns' | 'exportPatternBudget' | 's3RegisteredOnly'
|
||||
>,
|
||||
symbol: string,
|
||||
): boolean {
|
||||
return (
|
||||
(info.namedExports.has(symbol) && info.s3RegisteredOnly?.has(symbol) !== true) ||
|
||||
rExportPatternMatches(info, symbol)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when part of the package's `exportPattern()` entries is not in effect: a pattern that
|
||||
* was dropped at load, or one that ran out of work budget. {@link rNamespaceExports} answers
|
||||
* false for a name such a pattern was meant to cover, so a false answer is not proof that the
|
||||
* name is private.
|
||||
*/
|
||||
export function rExportPatternsIncomplete(
|
||||
info: Pick<
|
||||
RNamespaceInfo,
|
||||
| 'exportPatterns'
|
||||
| 'exportPatternBudget'
|
||||
| 'droppedExportPatterns'
|
||||
| 'omittedDroppedExportPatterns'
|
||||
>,
|
||||
): boolean {
|
||||
return (
|
||||
(info.droppedExportPatterns?.length ?? 0) > 0 ||
|
||||
(info.omittedDroppedExportPatterns ?? 0) > 0 ||
|
||||
info.exportPatternBudget?.isSpent === true ||
|
||||
info.exportPatternBudget?.cutShort === true ||
|
||||
info.exportPatterns.some((matcher) => matcher.exhausted === true)
|
||||
);
|
||||
}
|
||||
|
||||
export { rSymbolVisibility, type RSymbolVisibility } from './namespace-imports.js';
|
||||
|
||||
/**
|
||||
* Decide, once for the whole pass, the {@link RSymbolVisibility} of every top-level name of every
|
||||
* package that has a NAMESPACE (and of every name that package imports, which it may re-export),
|
||||
* and record the result for `rSymbolVisibility`.
|
||||
*
|
||||
* Determinism: `rNamespaceExports` spends the package's `exportPattern()` budget, and
|
||||
* {@link rExportPatternsIncomplete} reads that budget, so judging a name at the moment a site asks
|
||||
* could give a different answer depending on which site (or file) came first. Here every name is
|
||||
* tested in one batch, packages in directory order and names in code-unit order, and completeness
|
||||
* is read only AFTER the whole batch: a name no pattern matched is `private` when no pattern was
|
||||
* dropped or cut short and the NAMESPACE was fully read, `unknown` otherwise. A name outside the
|
||||
* memo reads `unknown`. Replaces any earlier memo for `cfg`.
|
||||
*/
|
||||
export function rBuildSymbolVisibilityMemo(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
cfg: RPackageConfig,
|
||||
): void {
|
||||
const namesByDir = new Map<string, Set<string>>();
|
||||
const namesOf = (dir: string): Set<string> => {
|
||||
let names = namesByDir.get(dir);
|
||||
if (names === undefined) namesByDir.set(dir, (names = new Set()));
|
||||
return names;
|
||||
};
|
||||
for (const parsed of parsedFiles) {
|
||||
const dir = rOwningPackageDir(parsed.filePath, cfg);
|
||||
if (dir === undefined || cfg.namespaceInfoByPackageDir.get(dir)?.hasNamespaceFile !== true) {
|
||||
continue;
|
||||
}
|
||||
const names = namesOf(dir);
|
||||
for (const name of rFileTopLevel(parsed).names) names.add(name);
|
||||
}
|
||||
for (const [dir, info] of cfg.namespaceInfoByPackageDir) {
|
||||
if (!info.hasNamespaceFile) continue;
|
||||
const names = namesOf(dir);
|
||||
for (const entry of info.importFrom) names.add(rBareName(entry.name));
|
||||
}
|
||||
|
||||
// Pass 1 (the batch): test every name, packages in directory order, names in code-unit order.
|
||||
const verdicts: { info: RNamespaceInfo; dir: string; name: string; exported: boolean }[] = [];
|
||||
for (const dir of [...namesByDir.keys()].sort()) {
|
||||
const info = cfg.namespaceInfoByPackageDir.get(dir);
|
||||
if (info === undefined) continue;
|
||||
for (const name of [...namesOf(dir)].sort()) {
|
||||
verdicts.push({ info, dir, name, exported: rNamespaceExports(info, name) });
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: completeness is read only now, after the whole batch has spent what it will spend.
|
||||
const memo = new Map<string, Map<string, RSymbolVisibility>>();
|
||||
for (const { info, dir, name, exported } of verdicts) {
|
||||
const notProvenPrivate = info.exportsUnreadable === true || rExportPatternsIncomplete(info);
|
||||
let byName = memo.get(dir);
|
||||
if (byName === undefined) memo.set(dir, (byName = new Map()));
|
||||
byName.set(name, exported ? 'public' : notProvenPrivate ? 'unknown' : 'private');
|
||||
}
|
||||
rRecordSymbolVisibilityMemo(cfg, memo);
|
||||
}
|
||||
|
||||
/**
|
||||
* The package config each parsed file was resolved with. The resolution pass records it
|
||||
* (`recordRPackageConfig`, from the scope resolver's workspace hook) so that the rename guard,
|
||||
* which receives the parsed files but not the config, can read the NAMESPACE the planner loaded
|
||||
* from disk for this very plan rather than the export verdict persisted at index time.
|
||||
*/
|
||||
const packageConfigOfParsedFile = new WeakMap<object, RPackageConfig>();
|
||||
|
||||
/** Record `resolutionConfig` (an {@link RPackageConfig}, if it is one) against each parsed file. */
|
||||
export function recordRPackageConfig(
|
||||
parsedFiles: readonly object[],
|
||||
resolutionConfig: unknown,
|
||||
): void {
|
||||
if (typeof resolutionConfig !== 'object' || resolutionConfig === null) return;
|
||||
const config = resolutionConfig as RPackageConfig;
|
||||
if (!(config.namespaceInfoByPackageDir instanceof Map)) return;
|
||||
for (const parsed of parsedFiles) packageConfigOfParsedFile.set(parsed, config);
|
||||
}
|
||||
|
||||
/** The config recorded for `parsed`, or undefined when no resolution pass recorded one. */
|
||||
export function recordedRPackageConfig(parsed: object | undefined): RPackageConfig | undefined {
|
||||
return parsed === undefined ? undefined : packageConfigOfParsedFile.get(parsed);
|
||||
}
|
||||
|
||||
/** Longest pattern prefix quoted in a warning. */
|
||||
const QUOTED_PATTERN_LENGTH = 60;
|
||||
|
||||
const quotePattern = (pattern: string): string =>
|
||||
JSON.stringify(
|
||||
pattern.length > QUOTED_PATTERN_LENGTH
|
||||
? `${pattern.slice(0, QUOTED_PATTERN_LENGTH)}...`
|
||||
: pattern,
|
||||
);
|
||||
|
||||
/**
|
||||
* Warn, once per pattern per package, about every `exportPattern()` that is not in
|
||||
* effect: one dropped at load ({@link RNamespaceInfo.droppedExportPatterns}) or one
|
||||
* whose matcher ran out of work budget since (names it had not yet decided were read as
|
||||
* unexported). Call after the matchers have been used. A dropped pattern exports
|
||||
* nothing, which would otherwise look exactly like a package that exports few names.
|
||||
*/
|
||||
export function reportRExportPatternProblems(config: RPackageConfig): void {
|
||||
for (const [pkgDir, info] of config.namespaceInfoByPackageDir) {
|
||||
const where = pkgDir === '' ? '.' : pkgDir;
|
||||
for (const { pattern, reason } of info.droppedExportPatterns ?? []) {
|
||||
logger.warn(
|
||||
`R package ${where}: exportPattern(${quotePattern(pattern)}) was ignored (${reason}); ` +
|
||||
`names it would export are treated as not exported`,
|
||||
);
|
||||
}
|
||||
if ((info.omittedDroppedExportPatterns ?? 0) > 0) {
|
||||
logger.warn(
|
||||
`R package ${where}: ${info.omittedDroppedExportPatterns} further exportPattern() ` +
|
||||
`arguments were ignored as well and are not listed; names they would export are ` +
|
||||
`treated as not exported`,
|
||||
);
|
||||
}
|
||||
const cutShort = info.exportPatternBudget?.cutShort === true;
|
||||
let listed = 0;
|
||||
let unlisted = 0;
|
||||
for (const matcher of info.exportPatterns) {
|
||||
if (matcher.exhausted !== true && !cutShort) continue;
|
||||
if (listed >= MAX_LISTED_DROPPED_PATTERNS) {
|
||||
unlisted++;
|
||||
continue;
|
||||
}
|
||||
listed++;
|
||||
logger.warn(
|
||||
`R package ${where}: exportPattern(${quotePattern(matcher.source)}) exceeded its work ` +
|
||||
`budget (its own, its package's or the repository's); ` +
|
||||
`names it had not yet been tested against are treated as not exported`,
|
||||
);
|
||||
}
|
||||
if (unlisted > 0) {
|
||||
logger.warn(
|
||||
`R package ${where}: ${unlisted} further exportPattern() arguments exceeded their work ` +
|
||||
`budget as well and are not listed; names they had not yet been tested against are ` +
|
||||
`treated as not exported`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Directories never descended into, by the main walk and the hidden-package scan alike. */
|
||||
const SKIPPED_DIR_NAMES: ReadonlySet<string> = new Set(['node_modules', '.git', '.Rproj.user']);
|
||||
|
||||
/**
|
||||
* Directories R tooling fills with installed packages or check output: the renv project
|
||||
* library, the packrat private library and the revdepcheck workspace. Each holds one
|
||||
* `DESCRIPTION` per third-party package, none of which is part of the repository.
|
||||
*/
|
||||
const VENDORED_LIBRARY_DIR_NAMES: ReadonlySet<string> = new Set(['renv', 'packrat', 'revdep']);
|
||||
|
||||
/** Suffix of the `<pkg>.Rcheck` directories `R CMD check` writes. */
|
||||
const RCHECK_SUFFIX = '.Rcheck';
|
||||
|
||||
/**
|
||||
* True for a directory that discovery must not enter: `renv`, `packrat` or `revdep`,
|
||||
* `<something>.Rcheck` (a name that is only the suffix is not one), any dot-directory
|
||||
* (`.Rlibs`, `.git`, `.Rproj.user`), and every directory the indexer itself never reads
|
||||
* ({@link isHardcodedIgnoredDirectory}: `vendor`, `build`, `dist`, `lib64`, `fixtures`, ...).
|
||||
* The indexer indexes no file under those, so a package in one is not part of the repository,
|
||||
* and a tree of them would otherwise spend the scan budgets. The match is on the directory's
|
||||
* own name, exact and case-sensitive: `renv-tools`, `Renv`, `revdeps` or `pkg.rcheck` are
|
||||
* ordinary directories and may hold a real package. The indexer's rules that need the
|
||||
* directory's location (a root-level `env` or `generated`, an `env` with `pyvenv.cfg`) are not
|
||||
* applied; neither holds an R package in practice.
|
||||
*/
|
||||
function isSkippedDirName(name: string): boolean {
|
||||
return (
|
||||
SKIPPED_DIR_NAMES.has(name) ||
|
||||
VENDORED_LIBRARY_DIR_NAMES.has(name) ||
|
||||
name.startsWith('.') ||
|
||||
isHardcodedIgnoredDirectory(name) ||
|
||||
(name.length > RCHECK_SUFFIX.length && name.endsWith(RCHECK_SUFFIX))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory entries in code-unit name order. `readdir` order is filesystem-dependent
|
||||
* (and `localeCompare` is locale-dependent), so every walk below reads through this to
|
||||
* make what it visits, and what it finds first, the same on every machine.
|
||||
*/
|
||||
async function readDirSorted(dir: string): Promise<Dirent[]> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
return entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort key that ranks the directories declaring one package name: fewer path segments
|
||||
* first, then the whole `/`-separated relative path compared by code unit. Comparing keys
|
||||
* with `<` therefore needs no help from the walk order. The depth is zero-padded so that
|
||||
* it compares as a number; `''` (the repo root) has depth 0.
|
||||
*/
|
||||
function preferredPackageDir(pkgDir: string): string {
|
||||
const depth = pkgDir === '' ? 0 : pkgDir.split('/').length;
|
||||
return `${String(depth).padStart(6, '0')}\0${pkgDir}`;
|
||||
}
|
||||
|
||||
/** Global directory budget for {@link hidesUndiscoveredPackage} across all its roots. */
|
||||
const HIDDEN_SCAN_BUDGET = 5000;
|
||||
|
||||
/**
|
||||
* The `Package:` field of a `DESCRIPTION` file's text, or `undefined` when it has none. A leading
|
||||
* UTF-8 byte-order mark is not part of the first field name, and a field with an empty value
|
||||
* names nothing: `[ \t]*`, not `\s*`, so the value never begins on the next line.
|
||||
*/
|
||||
function descriptionPackageName(content: string): string | undefined {
|
||||
const text = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
|
||||
return /^Package:[ \t]*(\S+)/m.exec(text)?.[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* The package names of a `DESCRIPTION` file's `Depends:` field: comma-separated entries, each a
|
||||
* name optionally followed by a version requirement (`pkgB (>= 1.0)`), over the field's
|
||||
* continuation lines (lines that start with a space or a tab). `R` itself is not a package.
|
||||
*/
|
||||
function descriptionDepends(content: string): Set<string> {
|
||||
const text = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
|
||||
const out = new Set<string>();
|
||||
const lines = text.split('\n');
|
||||
let start = lines.findIndex((line) => line.startsWith('Depends:'));
|
||||
if (start === -1) return out;
|
||||
let field = lines[start].slice('Depends:'.length);
|
||||
while (++start < lines.length && /^[ \t]/.test(lines[start])) field += `\n${lines[start]}`;
|
||||
for (const entry of field.split(',')) {
|
||||
const name = /^\s*([A-Za-z][A-Za-z0-9.]*)/.exec(entry)?.[1];
|
||||
if (name !== undefined && name !== 'R') out.add(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a `DESCRIPTION` file's text has a `Built:` field. `R CMD INSTALL` adds it to the
|
||||
* copy it installs; a source package never has one, so a `DESCRIPTION` with it belongs to an
|
||||
* installed library package (`lib/`, `library/`, a site library checked in), not to the
|
||||
* repository's own packages.
|
||||
*/
|
||||
function isInstalledPackageDescription(content: string): boolean {
|
||||
const text = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
|
||||
return /^Built:/m.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the subtrees the main walk skipped for a package it never discovered.
|
||||
*
|
||||
* Iterative DFS over `roots` under one global `budget` of directories (skipping
|
||||
* the directories {@link isSkippedDirName} names, as the main walk does). Returns
|
||||
* `true` (cannot rule a hidden package out) when the budget is exceeded, when a
|
||||
* directory or `DESCRIPTION` cannot be read, or when a `DESCRIPTION` declares a
|
||||
* `Package:` name that is not in `knownPackageNames`. Returns `false` when every
|
||||
* subtree was scanned and holds no such package. Deterministic and read-only.
|
||||
* The answer is a property of the set of directories, not of the order they are
|
||||
* visited in (every `true` condition is order-independent and a `false` means the
|
||||
* whole forest was read), but the walk still reads sorted so that it is reproducible.
|
||||
*/
|
||||
export async function hidesUndiscoveredPackage(
|
||||
roots: readonly string[],
|
||||
knownPackageNames: ReadonlySet<string>,
|
||||
budget: number = HIDDEN_SCAN_BUDGET,
|
||||
): Promise<boolean> {
|
||||
const stack = [...roots];
|
||||
let remaining = budget;
|
||||
for (let dir = stack.pop(); dir !== undefined; dir = stack.pop()) {
|
||||
if (--remaining < 0) return true;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readDirSorted(dir);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (!isSkippedDirName(entry.name)) stack.push(path.join(dir, entry.name));
|
||||
} else if (entry.isFile() && entry.name === 'DESCRIPTION') {
|
||||
try {
|
||||
const content = await readManifestFile(path.join(dir, entry.name));
|
||||
const pkgName = descriptionPackageName(content);
|
||||
if (
|
||||
pkgName !== undefined &&
|
||||
!knownPackageNames.has(pkgName) &&
|
||||
!isInstalledPackageDescription(content)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest DESCRIPTION or NAMESPACE read (4 MiB). Neither is a parsed source file, so the walker's
|
||||
* per-file cap does not apply; a real one is a few kilobytes. A larger file is treated like one
|
||||
* that cannot be read: a DESCRIPTION leaves its package undiscovered (discovery is then marked
|
||||
* truncated), a NAMESPACE leaves the package without NAMESPACE information (rename refuses).
|
||||
*/
|
||||
const MAX_MANIFEST_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Read a DESCRIPTION or NAMESPACE as UTF-8. Rejects when the file cannot be read, is not a regular
|
||||
* file, or is larger than {@link MAX_MANIFEST_BYTES}.
|
||||
*
|
||||
* The file is opened once and the type check, the size check and the read all use that one handle,
|
||||
* so the file cannot be swapped between the check and the read (a `stat` by path followed by a
|
||||
* `readFile` by path can). `O_NOFOLLOW` makes the open itself refuse a symlink where the platform
|
||||
* has it; where it does not (Windows) the callers' directory read has already admitted only a
|
||||
* regular file. `O_NONBLOCK` stops a FIFO blocking the open; it is then refused as not a file. At
|
||||
* most one byte past the checked size is read, so a file that grows after the check is refused
|
||||
* rather than read whole (or truncated).
|
||||
*/
|
||||
export async function readManifestFile(file: string): Promise<string> {
|
||||
const handle = await fs.open(
|
||||
file,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0),
|
||||
);
|
||||
try {
|
||||
const opened = await handle.stat();
|
||||
if (!opened.isFile()) throw new Error(`${path.basename(file)} is not a file`);
|
||||
const { size } = opened;
|
||||
if (size > MAX_MANIFEST_BYTES) throw new Error(`${path.basename(file)} is too large to read`);
|
||||
// One byte more than the size checked above: reading it means the file grew.
|
||||
const buffer = Buffer.allocUnsafe(size + 1);
|
||||
let bytesRead = 0;
|
||||
while (bytesRead < buffer.length) {
|
||||
const chunk = await handle.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead);
|
||||
if (chunk.bytesRead === 0) break;
|
||||
bytesRead += chunk.bytesRead;
|
||||
}
|
||||
if (bytesRead > size) throw new Error(`${path.basename(file)} changed while it was read`);
|
||||
return buffer.toString('utf-8', 0, bytesRead);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 packageDirs = new Set<string>();
|
||||
// One allowance (matching work and NFA states) for every exportPattern of every package
|
||||
// found by this load, split into a sub-budget per package: the number of patterns a
|
||||
// repository carries must not multiply the per-matcher cost.
|
||||
const loadBudget = new SharedWorkBudget();
|
||||
const scanQueue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
|
||||
const maxDepth = 3;
|
||||
const maxDirs = 200;
|
||||
let dirsScanned = 0;
|
||||
let truncated = false;
|
||||
// Directories the walk never visits (depth limit, cap leftovers): scanned at the end.
|
||||
const unvisited: string[] = [];
|
||||
|
||||
while (scanQueue.length > 0 && dirsScanned < maxDirs) {
|
||||
const { dir, depth } = scanQueue.shift()!;
|
||||
dirsScanned++;
|
||||
try {
|
||||
const entries = await readDirSorted(dir);
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (isSkippedDirName(entry.name)) continue;
|
||||
const child = path.join(dir, entry.name);
|
||||
if (depth < maxDepth) {
|
||||
scanQueue.push({ dir: child, depth: depth + 1 });
|
||||
} else {
|
||||
// Below the depth limit: not visited by the walk; scanned below.
|
||||
unvisited.push(child);
|
||||
}
|
||||
}
|
||||
if (entry.isFile() && entry.name === 'DESCRIPTION') {
|
||||
try {
|
||||
const descPath = path.join(dir, entry.name);
|
||||
const content = await readManifestFile(descPath);
|
||||
const pkgName = descriptionPackageName(content);
|
||||
if (pkgName !== undefined && !isInstalledPackageDescription(content)) {
|
||||
const pkgDir = path.relative(repoRoot, dir).replace(/\\/g, '/');
|
||||
packageDirs.add(pkgDir);
|
||||
// Same name in several directories: keep the preferred one, whatever the
|
||||
// order the walk met them in (see preferredPackageDir).
|
||||
const current = packages.get(pkgName);
|
||||
if (
|
||||
current === undefined ||
|
||||
preferredPackageDir(pkgDir) < preferredPackageDir(current)
|
||||
) {
|
||||
packages.set(pkgName, pkgDir);
|
||||
if (isDev) {
|
||||
logger.info(`📦 Found R package: ${pkgName} at ${pkgDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
// The same test as DESCRIPTION: a regular file listed by the directory read, never
|
||||
// a symlink (which could point outside the repository).
|
||||
const nsEntry = entries.find((candidate) => candidate.name === 'NAMESPACE');
|
||||
try {
|
||||
if (nsEntry === undefined || !nsEntry.isFile())
|
||||
throw new Error('no NAMESPACE file');
|
||||
const nsContent = await readManifestFile(path.join(dir, 'NAMESPACE'));
|
||||
const parsedExports = parseRNamespaceExports(nsContent);
|
||||
const namedExports = new Set<string>(parsedExports.namedExports);
|
||||
const imports = parseRNamespaceImportPackages(nsContent);
|
||||
// POSIX classes are translated; an uncompilable pattern is skipped (never
|
||||
// matches) and recorded so the caller can say so.
|
||||
const exportPatterns: RExportMatcher[] = [];
|
||||
const droppedExportPatterns: RDroppedExportPattern[] = [];
|
||||
let omittedDropped = 0;
|
||||
// This package's share of the load's budget: one hostile package cannot
|
||||
// take all of it and leave the packages after it without their patterns.
|
||||
const packageBudget = loadBudget.child();
|
||||
for (const pattern of new Set(parsedExports.exportPatterns)) {
|
||||
const compiled = compileRExportPatternDetailed(pattern, packageBudget);
|
||||
if ('reason' in compiled) {
|
||||
if (droppedExportPatterns.length < MAX_LISTED_DROPPED_PATTERNS) {
|
||||
droppedExportPatterns.push({ pattern, reason: compiled.reason });
|
||||
} else {
|
||||
omittedDropped++;
|
||||
}
|
||||
} else {
|
||||
exportPatterns.push(compiled.matcher);
|
||||
}
|
||||
}
|
||||
|
||||
namespaceInfoByPackageDir.set(pkgDir, {
|
||||
hasNamespaceFile: true,
|
||||
namedExports,
|
||||
exportPatterns,
|
||||
droppedExportPatterns,
|
||||
omittedDroppedExportPatterns: omittedDropped,
|
||||
exportPatternBudget: packageBudget,
|
||||
exportsUnreadable: parsedExports.unreadable,
|
||||
importFrom: parseRNamespaceImportFrom(nsContent),
|
||||
s3Registered: new Set((parsedExports.s3Registered ?? []).map(rBareName)),
|
||||
s3RegisteredOnly: new Set((parsedExports.s3RegisteredOnly ?? []).map(rBareName)),
|
||||
importPackages: new Set(imports.packages),
|
||||
depends: descriptionDepends(content),
|
||||
importsUnreadable: imports.unreadable,
|
||||
});
|
||||
} catch {
|
||||
// No NAMESPACE file or can't read it
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Can't read DESCRIPTION: the package it declares was not discovered.
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Can't read directory: whatever it holds was not discovered.
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Directories still queued when the cap stopped the walk were never visited either.
|
||||
for (const { dir } of scanQueue) unvisited.push(dir);
|
||||
if (
|
||||
!truncated &&
|
||||
unvisited.length > 0 &&
|
||||
(await hidesUndiscoveredPackage(unvisited, new Set(packages.keys())))
|
||||
) {
|
||||
truncated = true;
|
||||
}
|
||||
return { packages, namespaceInfoByPackageDir, packageDirs, truncated };
|
||||
}
|
||||
230
gitnexus/src/core/ingestion/languages/r/post-parse.ts
Normal file
230
gitnexus/src/core/ingestion/languages/r/post-parse.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
/**
|
||||
* R post-parse hooks.
|
||||
*
|
||||
* R classes are expressed as function calls (`R6::R6Class("Name", ...)`,
|
||||
* `setClass("Name", ...)`, `setMethod("fn", "ClassName", ...)`) rather than
|
||||
* tree-sitter class syntax, so the worker cannot always find the enclosing
|
||||
* class node via a parent walk. When that happens, the worker stores the
|
||||
* owner name as an `ownerNameHint` string on the symbol/graph node.
|
||||
*
|
||||
* After parsing is complete and the TypeRegistry is fully populated, we
|
||||
* resolve every `ownerNameHint` to a real `ownerId` and:
|
||||
* 1. Patch `node.properties.ownerId`
|
||||
* 2. Mutate the SymbolDefinition's `ownerId` (shared ref across registries)
|
||||
* 3. Register the symbol into MutableMethodRegistry / MutableFieldRegistry
|
||||
* 4. Emit the HAS_METHOD / HAS_PROPERTY graph edge
|
||||
*
|
||||
* NAMESPACE-aware export refinement also runs here — ExportChecker doesn't have
|
||||
* a file-path context, so it defaults all R symbols to public. Once we know
|
||||
* which package's `R/` directory each file lives in, we flip `isExported` to false
|
||||
* for symbols that aren't in the NAMESPACE exports.
|
||||
*/
|
||||
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import type { MutableSemanticModel } from '../../model/semantic-model.js';
|
||||
import type { PostParseContext } from '../../language-provider.js';
|
||||
import {
|
||||
loadRPackageConfig,
|
||||
reportRExportPatternProblems,
|
||||
rNamespaceExports,
|
||||
type RNamespaceInfo,
|
||||
type RPackageConfig,
|
||||
} from './package-config.js';
|
||||
import { rBareName } from './bare-name.js';
|
||||
import { rOwningPackageDir } from './namespace-imports.js';
|
||||
import type { GraphNode, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import { logger } from '../../../logger.js';
|
||||
|
||||
type EdgeLabel = 'HAS_METHOD' | 'HAS_PROPERTY';
|
||||
|
||||
/** Most class names quoted in the warning for owner hints that stay ambiguous. */
|
||||
const MAX_LISTED_AMBIGUOUS_OWNERS = 5;
|
||||
|
||||
/**
|
||||
* The R classes named `name`, one per graph node. The class registry is shared by every
|
||||
* language, so a Python `Circle` next to an R `Circle` must not make the name ambiguous, nor
|
||||
* stand in for an R class that is absent. It also appends without de-duplication: a class
|
||||
* defined twice in one file (a script re-defining it, `if (x) Foo <- R6Class(...) else Foo <-
|
||||
* R6Class(...)`, a factory called from two places) registers the same node twice, which is
|
||||
* one class, not an ambiguity.
|
||||
*/
|
||||
const distinctRClassDefs = (
|
||||
graph: KnowledgeGraph,
|
||||
model: MutableSemanticModel,
|
||||
name: string,
|
||||
): SymbolDefinition[] => {
|
||||
const byNode = new Map<string, SymbolDefinition>();
|
||||
for (const def of model.types.lookupClassByName(name)) {
|
||||
if (byNode.has(def.nodeId)) continue;
|
||||
if (graph.getNode(def.nodeId)?.properties.language !== SupportedLanguages.R) continue;
|
||||
byNode.set(def.nodeId, def);
|
||||
}
|
||||
return [...byNode.values()];
|
||||
};
|
||||
|
||||
/**
|
||||
* The class a member's owner hint names. A single class wins. When several distinct classes
|
||||
* share the name (the same helper in `R/` and `tests/`, a package copied into a second
|
||||
* directory), the one defined in the member's own file wins: a method or field written inside
|
||||
* `Foo <- R6Class(...)` always belongs to the `Foo` of that file, and a `setMethod()` next to
|
||||
* its `setClass()` does too. When the member's file defines none of them, or several, the owner is
|
||||
* undecidable from the name and the member stays unowned.
|
||||
*/
|
||||
const pickOwnerClass = (
|
||||
graph: KnowledgeGraph,
|
||||
classDefs: readonly SymbolDefinition[],
|
||||
memberFilePath: string,
|
||||
): SymbolDefinition | undefined => {
|
||||
if (classDefs.length === 1) return classDefs[0];
|
||||
const sameFile = classDefs.filter(
|
||||
(def) => graph.getNode(def.nodeId)?.properties.filePath === memberFilePath,
|
||||
);
|
||||
return sameFile.length === 1 ? sameFile[0] : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve R deferred owner hints into ownerIds. Runs post-parse, after all
|
||||
* Class symbols have been registered into the TypeRegistry.
|
||||
*/
|
||||
export const attachDeferredROwners = (
|
||||
graph: KnowledgeGraph,
|
||||
model: MutableSemanticModel,
|
||||
nodeLabel: 'Method' | 'Property',
|
||||
edgeType: EdgeLabel,
|
||||
): void => {
|
||||
const ambiguousOwners = new Set<string>();
|
||||
graph.forEachNode((node: GraphNode) => {
|
||||
// Skip immediately for non-R nodes — avoids doing any further work per node
|
||||
// in this full-graph traversal for repos with no R files at all.
|
||||
if (node.properties.language !== SupportedLanguages.R) return;
|
||||
if (node.label !== nodeLabel) return;
|
||||
|
||||
// If we already have a resolved ownerId, the hint is redundant — drop it.
|
||||
if (typeof node.properties.ownerId === 'string') {
|
||||
delete node.properties.ownerNameHint;
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerNameHint =
|
||||
typeof node.properties.ownerNameHint === 'string' ? node.properties.ownerNameHint : null;
|
||||
if (!ownerNameHint) return;
|
||||
|
||||
const filePath = typeof node.properties.filePath === 'string' ? node.properties.filePath : '';
|
||||
const classDefs = distinctRClassDefs(graph, model, ownerNameHint);
|
||||
const owner = pickOwnerClass(graph, classDefs, filePath);
|
||||
if (owner === undefined) {
|
||||
// Not found, or several classes of this name that the member's own file cannot tell apart:
|
||||
// leave the hint for debugging and report the second case once per call.
|
||||
if (classDefs.length > 1) ambiguousOwners.add(ownerNameHint);
|
||||
return;
|
||||
}
|
||||
const ownerId = owner.nodeId;
|
||||
|
||||
// 1. Patch the graph node.
|
||||
node.properties.ownerId = ownerId;
|
||||
delete node.properties.ownerNameHint;
|
||||
|
||||
// 2. Mutate the SymbolDefinition in place (shared ref across indexes).
|
||||
const name = typeof node.properties.name === 'string' ? node.properties.name : '';
|
||||
const defs = model.symbols.lookupExactAll(filePath, name);
|
||||
const def = defs.find((d) => d.nodeId === node.id);
|
||||
if (def) {
|
||||
def.ownerId = ownerId;
|
||||
|
||||
// 3. Explicitly register into the owner-scoped registry so resolveMemberCall
|
||||
// can find the symbol via MethodRegistry / FieldRegistry.
|
||||
if (nodeLabel === 'Method') {
|
||||
model.methods.register(ownerId, name, def);
|
||||
} else {
|
||||
model.fields.register(ownerId, name, def);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Emit the HAS_METHOD / HAS_PROPERTY edge.
|
||||
graph.addRelationship({
|
||||
id: generateId(edgeType, `${ownerId}->${node.id}`),
|
||||
sourceId: ownerId,
|
||||
targetId: node.id,
|
||||
type: edgeType,
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
});
|
||||
});
|
||||
|
||||
if (ambiguousOwners.size > 0) {
|
||||
const names = [...ambiguousOwners].sort();
|
||||
const listed = names.slice(0, MAX_LISTED_AMBIGUOUS_OWNERS).map((n) => JSON.stringify(n));
|
||||
const more = names.length - listed.length;
|
||||
logger.warn(
|
||||
`R: ${nodeLabel} nodes of ${names.length} class name(s) were left without an owner because ` +
|
||||
`several classes share the name and the member's own file does not define exactly one ` +
|
||||
`of them (${listed.join(', ')}${more > 0 ? `, and ${more} more` : ''})`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Refine R export status based on NAMESPACE files. A file belongs to the package whose
|
||||
* `R/` directory contains it (the nearest enclosing discovered package; R loads nothing else
|
||||
* into a package namespace), so only `R/` symbols are tested against that package's NAMESPACE:
|
||||
* a symbol that package does not export is flipped to `isExported: false`. Files outside every
|
||||
* package's `R/` (`tests/`, `vignettes/`, `inst/`, scripts, ...) and packages without a
|
||||
* NAMESPACE file keep the default public behaviour; a nested package without a NAMESPACE does
|
||||
* not inherit its enclosing package's.
|
||||
*/
|
||||
export const refineRExportStatus = (
|
||||
graph: KnowledgeGraph,
|
||||
rPackageConfig: RPackageConfig | null,
|
||||
): void => {
|
||||
if (!rPackageConfig || rPackageConfig.namespaceInfoByPackageDir.size === 0) return;
|
||||
|
||||
// Nodes of one file share an owner; resolve it once per file.
|
||||
const ownerByFile = new Map<string, RNamespaceInfo | undefined>();
|
||||
const namespaceInfoFor = (filePath: string): RNamespaceInfo | undefined => {
|
||||
if (ownerByFile.has(filePath)) return ownerByFile.get(filePath);
|
||||
const pkgDir = rOwningPackageDir(filePath, rPackageConfig);
|
||||
const info =
|
||||
pkgDir === undefined ? undefined : rPackageConfig.namespaceInfoByPackageDir.get(pkgDir);
|
||||
ownerByFile.set(filePath, info);
|
||||
return info;
|
||||
};
|
||||
|
||||
graph.forEachNode((node: GraphNode) => {
|
||||
if (node.properties.language !== SupportedLanguages.R) return;
|
||||
const filePath = typeof node.properties.filePath === 'string' ? node.properties.filePath : '';
|
||||
if (!filePath) return;
|
||||
|
||||
const nsInfo = namespaceInfoFor(filePath);
|
||||
if (!nsInfo || !nsInfo.hasNamespaceFile) return;
|
||||
|
||||
const name = typeof node.properties.name === 'string' ? node.properties.name : '';
|
||||
if (!name) return;
|
||||
|
||||
// NAMESPACE writes the symbol (`export(`%+%`)`); the node keeps the definition's spelling.
|
||||
// An explicit export or an exportPattern match keeps the node public. The patterns are
|
||||
// precompiled linear-time matchers (see loadRPackageConfig): no per-node recompile. An
|
||||
// `S3method()` registration keeps it public too: resolution does not treat the method as
|
||||
// exported (`pkg::name` and `library()` do not reach it), but it is public API reached
|
||||
// through dispatch, so entry-point scoring and process detection see it as before.
|
||||
const bare = rBareName(name);
|
||||
if (rNamespaceExports(nsInfo, bare) || nsInfo.s3Registered?.has(bare) === true) return;
|
||||
|
||||
// Not in NAMESPACE → not exported.
|
||||
node.properties.isExported = false;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* R's `LanguageProvider.postParse` hook: deferred owner attach for methods and
|
||||
* properties, then NAMESPACE-driven export refinement. Runs once per analyze,
|
||||
* after every chunk is merged and before scope resolution.
|
||||
*/
|
||||
export const rPostParse = async (ctx: PostParseContext): Promise<void> => {
|
||||
attachDeferredROwners(ctx.graph, ctx.model, 'Method', 'HAS_METHOD');
|
||||
attachDeferredROwners(ctx.graph, ctx.model, 'Property', 'HAS_PROPERTY');
|
||||
const config = await loadRPackageConfig(ctx.repoPath);
|
||||
refineRExportStatus(ctx.graph, config);
|
||||
reportRExportPatternProblems(config);
|
||||
};
|
||||
353
gitnexus/src/core/ingestion/languages/r/qualified-call.ts
Normal file
353
gitnexus/src/core/ingestion/languages/r/qualified-call.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
/**
|
||||
* R `pkg::name()` / `pkg:::name()` qualified calls.
|
||||
*
|
||||
* The scope query captures the whole `namespace_operator` as
|
||||
* `@reference.qualified-name`, so a call site carries `rawQualifiedName`
|
||||
* (`pkg::f`). Without it every tier discards the qualifier and guesses by
|
||||
* name: `dplyr::filter()` binds to an unrelated local `filter`, and
|
||||
* `mypkg::tidy()` inside a wrapper named `tidy` binds to the wrapper itself.
|
||||
*
|
||||
* {@link populateRQualifiedCalls} runs at `populateWorkspaceReferences` time
|
||||
* (before `finalizeScopeModel`, on cold and warm-cache runs alike, with the
|
||||
* workspace's `RPackageConfig`) and decides each qualified site by the
|
||||
* {@link rQualifierLocality} of its package:
|
||||
*
|
||||
* - `external` (not local, and package discovery completed): the site is
|
||||
* dropped from `referenceSites`, so no tier can guess an edge for it;
|
||||
* - `local` with exactly one top-level definition of the name in the
|
||||
* package's `R/` files: that definition is recorded per call site and
|
||||
* returned by {@link resolveRQualifiedFreeCall}, which the free-call
|
||||
* fallback consults before the lexical tiers (a precise 0.85 edge), when
|
||||
* the operator is `:::`, or `::` and the name is public (the package has no
|
||||
* NAMESPACE, or exports the name) or the caller is inside the package's
|
||||
* directory tree ({@link rEnclosingPackageDir}). A `::` site naming a
|
||||
* private or not-provably-exported name from outside is dropped (R errors
|
||||
* there). The fallback resolves the hook before it applies a lexical name claim, so a
|
||||
* caller's parameter, `for` variable or local assignment of the same name does not
|
||||
* suppress the edge: `::` never consults local bindings;
|
||||
* - `local` with two or more such definitions (same file or across files):
|
||||
* the site is dropped (every valid target is inside the named package, so
|
||||
* any edge would be provably wrong);
|
||||
* - `local` with no definition: a re-export is the one case that resolves. When
|
||||
* the package's NAMESPACE exports the name and has `importFrom(origin, name)` for
|
||||
* a local `origin` that defines the name exactly once, the site is recorded against
|
||||
* that definition (one hop). Anything else drops the site: a name not exported,
|
||||
* no `importFrom`, an external or multiply-defining origin, or `:::`;
|
||||
* - `unknown` locality (discovery truncated, or no config): the site is dropped.
|
||||
*
|
||||
* Invariant: after this pass no qualified call site survives without a recorded
|
||||
* precise target. The free-call fallback never reads `rawQualifiedName` between its
|
||||
* explicit-path hook and the scope-chain walk, so a kept site without a target would
|
||||
* walk the lexical scope on the bare tail and bind a caller-local decoy
|
||||
* (`pkg::missing()` to the caller's own `missing`).
|
||||
*
|
||||
* The per-package definition counts are also recorded for the
|
||||
* global-name-fallback veto (`isRGlobalNameFallbackPlausible`).
|
||||
*
|
||||
* Import direction: this file imports `namespace-imports.ts`, never the other
|
||||
* way round, so `parseRQualifier` and the definition-count registry live there
|
||||
* and are re-exported here.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, ReferenceSite, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { rBuildSymbolVisibilityMemo, type RPackageConfig } from './package-config.js';
|
||||
import { rBareName } from './bare-name.js';
|
||||
import {
|
||||
parseRQualifier,
|
||||
parseRQualifierOperator,
|
||||
rEnclosingPackageDir,
|
||||
rFileBelongsToDiscoveredPackage,
|
||||
rOwningPackageDir,
|
||||
rPackageDirForFile,
|
||||
rPackageSymbolVisibility,
|
||||
rRecordQualifiedDefinitionCounts,
|
||||
rReexportOrigin,
|
||||
rSymbolVisibility,
|
||||
TOP_LEVEL_BINDABLE_TYPES,
|
||||
} from './namespace-imports.js';
|
||||
|
||||
export { parseRQualifier } from './namespace-imports.js';
|
||||
|
||||
/** Whether a qualifier's package is a package of the analysed repo. */
|
||||
export type RQualifierLocality = 'local' | 'external' | 'unknown';
|
||||
|
||||
/**
|
||||
* True when `filePath` sits under a `<pkg>/R/` directory: a `/`-delimited path
|
||||
* segment equal to `pkg` immediately followed by `R/` (`pkg/R/a.R`,
|
||||
* `deep/x/pkg/R/a.R`; not `mypkg/R/a.R`, not `pkg/scripts/a.R`).
|
||||
*
|
||||
* The directory name stands in for the package name only for a file that no discovered
|
||||
* package owns (the fallback for a package whose `DESCRIPTION` discovery missed or that has
|
||||
* none). A file under a discovered package's `R/` belongs to the package its `DESCRIPTION`
|
||||
* names, whatever its directory is called, so it is never evidence for another name.
|
||||
*/
|
||||
function isUnderPackageRDir(
|
||||
pkg: string,
|
||||
filePath: string,
|
||||
cfg: RPackageConfig | null | undefined,
|
||||
): boolean {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
if (!normalized.startsWith(`${pkg}/R/`) && !normalized.includes(`/${pkg}/R/`)) return false;
|
||||
return !rFileBelongsToDiscoveredPackage(normalized, cfg);
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory names `<name>` of the `.../<name>/R/<file>` paths in `filePaths` that no
|
||||
* discovered package owns: the names {@link isUnderPackageRDir} would accept, read from all the
|
||||
* paths once. {@link rQualifierLocality} asks about every qualifier of a run; answering each from
|
||||
* this set instead of a scan of every path makes the work linear in the paths plus the
|
||||
* qualifiers instead of their product.
|
||||
*/
|
||||
export function rUnownedPackageDirNames(
|
||||
filePaths: Iterable<string>,
|
||||
cfg: RPackageConfig | null | undefined,
|
||||
): ReadonlySet<string> {
|
||||
const names = new Set<string>();
|
||||
for (const filePath of filePaths) {
|
||||
const parts = filePath.replace(/\\/g, '/').split('/');
|
||||
let owned: boolean | undefined;
|
||||
// `R` must be a directory (not the last segment), with the package directory before it.
|
||||
for (let i = 0; i + 2 < parts.length; i++) {
|
||||
if (parts[i + 1] !== 'R' || names.has(parts[i])) continue;
|
||||
owned ??= rFileBelongsToDiscoveredPackage(parts.join('/'), cfg);
|
||||
if (owned) break;
|
||||
names.add(parts[i]);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locality of the package named by a qualifier.
|
||||
*
|
||||
* - `local`: `pkg` is in `cfg.packages`, or a parsed file sits under a
|
||||
* `<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. 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".
|
||||
*/
|
||||
export function rQualifierLocality(
|
||||
pkg: string,
|
||||
cfg: RPackageConfig | null | undefined,
|
||||
parsedFilePaths: ReadonlySet<string>,
|
||||
/** {@link rUnownedPackageDirNames} of `parsedFilePaths`, when the caller asks many times. */
|
||||
unownedPackageDirNames?: ReadonlySet<string>,
|
||||
): RQualifierLocality {
|
||||
if (cfg?.packages.has(pkg) === true) return 'local';
|
||||
if (unownedPackageDirNames !== undefined) {
|
||||
if (unownedPackageDirNames.has(pkg)) return 'local';
|
||||
} else {
|
||||
for (const filePath of parsedFilePaths) {
|
||||
if (isUnderPackageRDir(pkg, filePath, cfg)) return 'local';
|
||||
}
|
||||
}
|
||||
if (cfg === null || cfg === undefined) return 'unknown';
|
||||
return cfg.truncated === true ? 'unknown' : 'external';
|
||||
}
|
||||
|
||||
/**
|
||||
* Call site → the single definition its `pkg::name()` qualifier names, for the
|
||||
* current run. Module state, replaced wholesale by every
|
||||
* {@link populateRQualifiedCalls} call. Keyed by file path and start position
|
||||
* (the key `free-call-fallback.ts` uses for its handled sites), not by object
|
||||
* identity, so it survives the frozen `ParsedFile` copies made after populate.
|
||||
*/
|
||||
let preciseBySite: Map<string, SymbolDefinition> = new Map();
|
||||
|
||||
const siteKey = (filePath: string, atRange: { startLine: number; startCol: number }): string =>
|
||||
`${filePath}:${atRange.startLine}:${atRange.startCol}`;
|
||||
|
||||
function isQualifiedCall(site: ReferenceSite): site is ReferenceSite & {
|
||||
readonly rawQualifiedName: string;
|
||||
} {
|
||||
return site.kind === 'call' && site.rawQualifiedName !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per symbol, every top-level function/class definition (one entry per binding
|
||||
* ref, so a same-file redefinition counts twice) of the given package files.
|
||||
* Read from Module-scope `local` bindings at extraction time, like
|
||||
* `rFileTopLevel`, but keeping the definitions rather than the names. The symbol
|
||||
* is the name without the backticks it may be written in, so `pkg::f()` finds
|
||||
* `` `f` <- `` and the reverse; definitions spelled both ways count together.
|
||||
*/
|
||||
function collectTopLevelDefinitions(files: readonly ParsedFile[]): Map<string, SymbolDefinition[]> {
|
||||
const byName = new Map<string, SymbolDefinition[]>();
|
||||
for (const parsed of files) {
|
||||
const moduleScope = parsed.scopes.find((s) => s.id === parsed.moduleScope);
|
||||
if (moduleScope === undefined) continue;
|
||||
for (const [name, refs] of moduleScope.bindings) {
|
||||
for (const ref of refs) {
|
||||
if (ref.origin !== 'local' || !TOP_LEVEL_BINDABLE_TYPES.has(ref.def.type)) continue;
|
||||
const symbol = rBareName(name);
|
||||
const list = byName.get(symbol);
|
||||
if (list === undefined) byName.set(symbol, [ref.def]);
|
||||
else list.push(ref.def);
|
||||
}
|
||||
}
|
||||
}
|
||||
return byName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide every qualified call site of `parsedFiles` as described in the file
|
||||
* header: drop external, unknown-locality, duplicate-definition and not-reachable sites (in
|
||||
* place, through frozen `ParsedFile` copies), record the precise target of every other site.
|
||||
* Resets all module state first, so a run never sees the previous run's answers, and builds
|
||||
* the pass's export-visibility memo (`rBuildSymbolVisibilityMemo`) before judging any site.
|
||||
*/
|
||||
export function populateRQualifiedCalls(
|
||||
parsedFiles: ParsedFile[],
|
||||
ctx: { readonly resolutionConfig?: unknown },
|
||||
): void {
|
||||
preciseBySite = new Map();
|
||||
const cfg = (ctx.resolutionConfig as RPackageConfig | null | undefined) ?? null;
|
||||
if (cfg !== null) {
|
||||
rRecordQualifiedDefinitionCounts(cfg, new Map());
|
||||
rBuildSymbolVisibilityMemo(parsedFiles, cfg);
|
||||
}
|
||||
|
||||
// Packages named by some qualified call, and how local each one is.
|
||||
let filePaths: Set<string> | undefined;
|
||||
let unownedDirNames: ReadonlySet<string> | undefined;
|
||||
const localityByPackage = new Map<string, RQualifierLocality>();
|
||||
const localityOf = (pkg: string): RQualifierLocality => {
|
||||
let locality = localityByPackage.get(pkg);
|
||||
if (locality === undefined) {
|
||||
filePaths ??= new Set(parsedFiles.map((p) => p.filePath));
|
||||
unownedDirNames ??= rUnownedPackageDirNames(filePaths, cfg);
|
||||
locality = rQualifierLocality(pkg, cfg, filePaths, unownedDirNames);
|
||||
localityByPackage.set(pkg, locality);
|
||||
}
|
||||
return locality;
|
||||
};
|
||||
let hasQualifiedSite = false;
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const site of parsed.referenceSites) {
|
||||
if (!isQualifiedCall(site)) continue;
|
||||
hasQualifiedSite = true;
|
||||
const pkg = parseRQualifier(site.rawQualifiedName);
|
||||
if (pkg !== undefined) localityOf(pkg);
|
||||
}
|
||||
}
|
||||
if (!hasQualifiedSite) return;
|
||||
|
||||
// Top-level definitions of a local package, by name (computed on first use, once per package).
|
||||
const definitionsByPackage = new Map<string, Map<string, SymbolDefinition[]>>();
|
||||
const definitionsOf = (pkg: string): Map<string, SymbolDefinition[]> => {
|
||||
let byName = definitionsByPackage.get(pkg);
|
||||
if (byName === undefined) {
|
||||
const files: ParsedFile[] = [];
|
||||
for (const parsed of parsedFiles) {
|
||||
// In the config a package owns its `R/` files (longest-dir wins); otherwise
|
||||
// it is local only through the `<pkg>/R/` path, as `rQualifierLocality` decided.
|
||||
const inPackage =
|
||||
cfg?.packages.has(pkg) === true
|
||||
? rPackageDirForFile(parsed.filePath, cfg)?.name === pkg
|
||||
: isUnderPackageRDir(pkg, parsed.filePath, cfg);
|
||||
if (inPackage) files.push(parsed);
|
||||
}
|
||||
byName = collectTopLevelDefinitions(files);
|
||||
definitionsByPackage.set(pkg, byName);
|
||||
}
|
||||
return byName;
|
||||
};
|
||||
|
||||
// Decide each site.
|
||||
for (let i = 0; i < parsedFiles.length; i++) {
|
||||
const parsed = parsedFiles[i];
|
||||
const kept: ReferenceSite[] = [];
|
||||
for (const site of parsed.referenceSites) {
|
||||
if (!isQualifiedCall(site)) {
|
||||
kept.push(site);
|
||||
continue;
|
||||
}
|
||||
const pkg = parseRQualifier(site.rawQualifiedName);
|
||||
const target =
|
||||
pkg === undefined || localityOf(pkg) !== 'local'
|
||||
? undefined
|
||||
: resolveLocalQualifiedSite(
|
||||
pkg,
|
||||
rBareName(site.name),
|
||||
parseRQualifierOperator(site.rawQualifiedName) === ':::',
|
||||
parsed.filePath,
|
||||
);
|
||||
if (target === undefined) continue;
|
||||
preciseBySite.set(siteKey(parsed.filePath, site.atRange), target);
|
||||
kept.push(site);
|
||||
}
|
||||
if (kept.length !== parsed.referenceSites.length) {
|
||||
parsedFiles[i] = Object.freeze({ ...parsed, referenceSites: Object.freeze(kept) });
|
||||
}
|
||||
}
|
||||
|
||||
if (cfg !== null) {
|
||||
rRecordQualifiedDefinitionCounts(
|
||||
cfg,
|
||||
new Map(
|
||||
[...definitionsByPackage].map(([pkg, byName]) => [
|
||||
pkg,
|
||||
new Map([...byName].map(([name, defs]) => [name, defs.length])),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The definition `pkg::symbol` (or `pkg:::symbol`) reaches from `callerPath`, or `undefined`
|
||||
* when the site must be dropped: no definition and no resolvable re-export, several
|
||||
* definitions, or a `::` to a name the package does not make public that the caller, outside
|
||||
* the package's tree, cannot reach.
|
||||
*/
|
||||
function resolveLocalQualifiedSite(
|
||||
pkg: string,
|
||||
symbol: string,
|
||||
internal: boolean,
|
||||
callerPath: string,
|
||||
): SymbolDefinition | undefined {
|
||||
const found = definitionsOf(pkg).get(symbol) ?? [];
|
||||
if (found.length >= 2) return undefined;
|
||||
if (found.length === 1) {
|
||||
if (internal) return found[0];
|
||||
const visibility = rSymbolVisibility(cfg, found[0].filePath, symbol);
|
||||
if (visibility === 'public') return found[0];
|
||||
const owner = rOwningPackageDir(found[0].filePath, cfg);
|
||||
return owner !== undefined && rEnclosingPackageDir(callerPath, cfg) === owner
|
||||
? found[0]
|
||||
: undefined;
|
||||
}
|
||||
// No definition: only a `::` re-export (`export(name)` + `importFrom(origin, name)`) resolves.
|
||||
const pkgDir = cfg?.packages.get(pkg);
|
||||
if (internal || cfg === null || pkgDir === undefined) return undefined;
|
||||
if (rPackageSymbolVisibility(cfg, pkgDir, symbol) !== 'public') return undefined;
|
||||
if (cfg.namespaceInfoByPackageDir.get(pkgDir)?.hasNamespaceFile !== true) return undefined;
|
||||
const origin = rReexportOrigin(cfg, pkgDir, pkg, symbol);
|
||||
if (origin === undefined || localityOf(origin) !== 'local') return undefined;
|
||||
const originDefs = definitionsOf(origin).get(symbol) ?? [];
|
||||
return originDefs.length === 1 ? originDefs[0] : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `ScopeResolver.resolveQualifiedFreeCall` for R: the definition
|
||||
* {@link populateRQualifiedCalls} recorded for this call site, if any.
|
||||
*
|
||||
* The contract types `site` without its position; the runtime object is the
|
||||
* `ReferenceSite`, which has `atRange`. That is read through a narrow cast
|
||||
* here instead of widening the shared contract.
|
||||
*/
|
||||
export function resolveRQualifiedFreeCall(
|
||||
site: object,
|
||||
callerParsed: ParsedFile,
|
||||
): SymbolDefinition | undefined {
|
||||
const atRange = (site as { readonly atRange?: { startLine: number; startCol: number } }).atRange;
|
||||
return atRange === undefined
|
||||
? undefined
|
||||
: preciseBySite.get(siteKey(callerParsed.filePath, atRange));
|
||||
}
|
||||
415
gitnexus/src/core/ingestion/languages/r/query.ts
Normal file
415
gitnexus/src/core/ingestion/languages/r/query.ts
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
/**
|
||||
* Tree-sitter query for R scope captures (RFC #909 Ring 3).
|
||||
*
|
||||
* R has no dedicated class syntax — R6 classes are `R6Class(...)` calls, S4/R5
|
||||
* classes are `setClass(...)`/`setRefClass(...)` calls — so this query mirrors
|
||||
* the shapes the legacy `R_QUERIES` (`tree-sitter-queries.ts`) already proved
|
||||
* queryable for structural extraction (the heritage shapes below have no
|
||||
* counterpart there), renamed onto the scope-resolution capture vocabulary
|
||||
* (`@scope.*`, `@declaration.*`, `@reference.*`, `@import.*`,
|
||||
* `@type-binding.*`).
|
||||
*
|
||||
* R specifics encoded here:
|
||||
*
|
||||
* - Every `binary_operator` pattern is an assignment: `operator: ["<-" "<<-" "="]`
|
||||
* (see `assignment.ts`). The grammar uses one node for every infix operator, so
|
||||
* without it `x + R6Class(...)` or `x == function() 1` would declare something.
|
||||
*
|
||||
* - A Class scope spans the ENTIRE `R6Class(...)`/`setClass(...)` call
|
||||
* (the whole `binary_operator` for the `name <- R6Class(...)` shape),
|
||||
* not just `public = list(...)` — the `inherit=`/`contains=` argument
|
||||
* must fall inside the scope's range so the `@reference.inherits` sites
|
||||
* resolve against the deriving class's own scope.
|
||||
*
|
||||
* - `@declaration.function`/`@declaration.class` anchor on the WHOLE
|
||||
* `name <- <rhs>` assignment (matching the legacy `@definition.*`
|
||||
* anchors exactly), so `resolveDefGraphId`'s position-based (Function)
|
||||
* and qualifiedName-keyed (Class) reconciliation lands on the SAME
|
||||
* graph node the legacy extractor already created.
|
||||
*
|
||||
* - `@declaration.method` anchors on the `name = function(...)` ARGUMENT
|
||||
* inside `public =`/`private =`/`active =`/`methods = list(...)`.
|
||||
*
|
||||
* - A def is owned by the innermost scope whose range EQUALS its anchor
|
||||
* range (`scope-extractor.ts` Pass 2), and `resolveCallerGraphId` walks
|
||||
* from a call site up to the first scope that owns a callable def. The
|
||||
* blanket `(function_definition) @scope.function` is strictly narrower
|
||||
* than the assignment/argument anchors, so on its own it never owns a
|
||||
* def: every named function/method would be owned by the module or
|
||||
* Class scope and every call in the file would be credited to the
|
||||
* FIRST callable in it. So each named-callable anchor also gets a
|
||||
* `@scope.function` on the SAME node, placed directly beside its
|
||||
* `@declaration.*` pattern below (the two must stay in lockstep — a
|
||||
* declaration anchor without a matching scope silently reverts that
|
||||
* shape to first-callable attribution). The def's own binding still
|
||||
* hoists to the PARENT scope (anchor range == scope range), so name
|
||||
* lookup is unchanged. The blanket rule is kept: it still covers
|
||||
* anonymous functions (`lapply(x, function(z) ...)`), which own nothing.
|
||||
* Calls in a Class body outside any method (an R6 field default, a
|
||||
* `setClass(validity = function...)` body) are credited to the Class.
|
||||
*
|
||||
* - No `@declaration.qualified_name` is emitted for `@declaration.class`
|
||||
* — R's legacy extractor has no `classExtractor`, so a Class node's
|
||||
* `qualifiedName` today is the bare class-name text. Omitting the
|
||||
* capture lets `buildDefFromDeclarationMatch` default `qualifiedName`
|
||||
* to the bare name, matching the legacy node so `resolveDefGraphId`'s
|
||||
* name-keyed fallback finds it.
|
||||
*
|
||||
* - S4/setGeneric/setMethod declarations are deliberately NOT captured
|
||||
* here — they are already produced by the legacy extractor via
|
||||
* R_QUERIES, independent of scope-resolution, and none of the target
|
||||
* scenarios needs a NEW declaration for them. Their inner
|
||||
* `function_definition` bodies still get a `@scope.function` from the
|
||||
* blanket rule below (an unnamed scope, harmless).
|
||||
*
|
||||
* - `library()`/`require()` are wildcard imports (`@import.wildcard`) —
|
||||
* the named local package's exported names become visible (every
|
||||
* top-level name when it has no NAMESPACE file; see
|
||||
* `rScopeResolver.expandsWildcardTo`, which filters by NAMESPACE).
|
||||
* `source()` is a side-effect import (`@import.side-effect`) — it
|
||||
* establishes a file dependency but binds no name; cross-file free
|
||||
* calls into a `source()`d file resolve via `allowGlobalFreeCallFallback`
|
||||
* instead (`rScopeResolver`).
|
||||
*
|
||||
* - `obj$method()` (the `extract_operator`, R's `$`) is a member call with
|
||||
* an explicit receiver — the same shape as Ruby's `obj.method`.
|
||||
* `Type$new(...)` is R6's constructor spelling: captured separately as
|
||||
* `@type-binding.constructor` so `x <- Type$new(...)` types `x`.
|
||||
*
|
||||
* - `a$b$m()` and `Cls$new()$m()` (the receiver is itself an `extract_operator` or a `call`)
|
||||
* are chained member calls. R never types through a chain (R6 fields and results carry no
|
||||
* declared type), so `emitRScopeCaptures` mints `@reference.receiver-chain` with the shared
|
||||
* codec's `truncated` marker: the shared fold refuses it and the call ends as an unresolved
|
||||
* receiver (an outcome, no edge) instead of reaching a same-named free function. A chain
|
||||
* with no variable base mints nothing at all (an invisible gap, see `rReceiverChain`).
|
||||
*
|
||||
* Exposes lazy `Parser` and `Query` singletons so callers don't pay
|
||||
* tree-sitter init cost per file.
|
||||
*/
|
||||
|
||||
import Parser from 'tree-sitter';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { getLanguageGrammar } from '../../../tree-sitter/parser-loader.js';
|
||||
|
||||
/**
|
||||
* Exported for `value-ref-dispatchability.test.ts`-style whole-query readers.
|
||||
* Not part of the provider surface — nothing else should import it.
|
||||
*/
|
||||
export const R_SCOPE_QUERY = `
|
||||
;; ── Scopes ───────────────────────────────────────────────────────────────
|
||||
|
||||
(program) @scope.module
|
||||
|
||||
(function_definition) @scope.function
|
||||
|
||||
;; R6 class via namespace call (ClassName <- R6::R6Class(...)) — the scope
|
||||
;; spans the WHOLE assignment, including inherit=, so heritage sites resolve
|
||||
;; inside the deriving class's own scope.
|
||||
(binary_operator
|
||||
lhs: (identifier)
|
||||
operator: ["<-" "<<-" "="]
|
||||
rhs: (call
|
||||
function: (namespace_operator
|
||||
rhs: (identifier) @_r6ns_scope
|
||||
(#match? @_r6ns_scope "^R6Class$")))) @scope.class
|
||||
|
||||
;; R6 class via bare call (ClassName <- R6Class(...))
|
||||
(binary_operator
|
||||
lhs: (identifier)
|
||||
operator: ["<-" "<<-" "="]
|
||||
rhs: (call
|
||||
function: (identifier) @_r6bare_scope
|
||||
(#match? @_r6bare_scope "^R6Class$"))) @scope.class
|
||||
|
||||
;; S4/R5 class (setClass(...) / setRefClass(...)) — scope spans the whole call.
|
||||
(call
|
||||
function: (identifier) @_s4_scope
|
||||
(#match? @_s4_scope "^(setClass|setRefClass)$")) @scope.class
|
||||
|
||||
;; Braces that R evaluates in an environment of their own (\`local({...})\`, \`with(data, {...})\`,
|
||||
;; \`within(data, {...})\`, testthat's \`test_that(desc, {...})\`, \`describe()\` and \`it()\`): a
|
||||
;; definition inside binds in the block, not in the module or the enclosing function, so it is
|
||||
;; reachable from those braces only. \`withr::with_*()\` and \`suppressWarnings({...})\` evaluate
|
||||
;; their braces in the caller's frame and get no scope. Bare or \`pkg::\`-qualified.
|
||||
(call
|
||||
function: (identifier) @_blkfn
|
||||
(#match? @_blkfn "^(local|test_that|describe|it|with|within)$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
value: (braced_expression) @scope.block)))
|
||||
|
||||
(call
|
||||
function: (namespace_operator
|
||||
rhs: (identifier) @_blkfn_ns
|
||||
(#match? @_blkfn_ns "^(local|test_that|describe|it|with|within)$"))
|
||||
arguments: (arguments
|
||||
(argument
|
||||
value: (braced_expression) @scope.block)))
|
||||
|
||||
;; ── Declarations — functions ──────────────────────────────────────────────
|
||||
|
||||
(binary_operator
|
||||
lhs: (identifier) @declaration.name
|
||||
operator: ["<-" "<<-" "="]
|
||||
rhs: (function_definition)) @declaration.function
|
||||
|
||||
;; Function scope on the SAME assignment node as the declaration above, so the
|
||||
;; def is owned by its own scope (and calls inside the body attribute to it).
|
||||
;; This pattern and the \`@declaration.function\` pattern above must stay in
|
||||
;; lockstep — identical structure, identical anchor node (see file header).
|
||||
(binary_operator
|
||||
lhs: (identifier)
|
||||
operator: ["<-" "<<-" "="]
|
||||
rhs: (function_definition)) @scope.function
|
||||
|
||||
;; ── Declarations — classes (name only; no qualified_name, see file header) ─
|
||||
;;
|
||||
;; The setClass/setRefClass declaration and the source/library/require imports
|
||||
;; below capture EVERY string (imports: identifier or string) argument of the call.
|
||||
;; \`emitRScopeCaptures\` keeps only the one that names the class or import
|
||||
;; (\`isRNonNamingScopeMatch\`): the argument spelled with the first formal, else the
|
||||
;; first unnamed one, so a leading comment or a named \`lib.loc =\` / \`local =\` does
|
||||
;; not change the answer. A query-level anchor cannot skip an unbounded run of
|
||||
;; leading comments and named arguments without a repeated sibling group, which
|
||||
;; is quadratic in the argument count on every call.
|
||||
|
||||
(binary_operator
|
||||
lhs: (identifier) @declaration.name
|
||||
operator: ["<-" "<<-" "="]
|
||||
rhs: (call
|
||||
function: (namespace_operator
|
||||
rhs: (identifier) @_r6ns_decl
|
||||
(#match? @_r6ns_decl "^R6Class$")))) @declaration.class
|
||||
|
||||
(binary_operator
|
||||
lhs: (identifier) @declaration.name
|
||||
operator: ["<-" "<<-" "="]
|
||||
rhs: (call
|
||||
function: (identifier) @_r6bare_decl
|
||||
(#match? @_r6bare_decl "^R6Class$"))) @declaration.class
|
||||
|
||||
(call
|
||||
function: (identifier) @_s4_decl
|
||||
(#match? @_s4_decl "^(setClass|setRefClass)$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
value: (string
|
||||
content: (string_content) @declaration.name)))) @declaration.class
|
||||
|
||||
;; ── Declarations — R6/R5 methods ──────────────────────────────────────────
|
||||
|
||||
;; R6 methods via namespace call (inside public/private/active = list(...))
|
||||
(binary_operator
|
||||
lhs: (identifier) @_class1
|
||||
operator: ["<-" "<<-" "="]
|
||||
rhs: (call
|
||||
function: (namespace_operator
|
||||
rhs: (identifier) @_r6ns_m
|
||||
(#match? @_r6ns_m "^R6Class$"))
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_section1
|
||||
(#match? @_section1 "^(public|private|active)$")
|
||||
value: (call
|
||||
function: (identifier) @_listfn1
|
||||
(#match? @_listfn1 "^list$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @declaration.name
|
||||
value: (function_definition)) @declaration.method))))))
|
||||
|
||||
;; R6 methods via bare call (inside public/private/active = list(...))
|
||||
(binary_operator
|
||||
lhs: (identifier) @_class2
|
||||
operator: ["<-" "<<-" "="]
|
||||
rhs: (call
|
||||
function: (identifier) @_r6bare_m
|
||||
(#match? @_r6bare_m "^R6Class$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_section2
|
||||
(#match? @_section2 "^(public|private|active)$")
|
||||
value: (call
|
||||
function: (identifier) @_listfn2
|
||||
(#match? @_listfn2 "^list$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @declaration.name
|
||||
value: (function_definition)) @declaration.method))))))
|
||||
|
||||
;; R5 methods (inside setRefClass(... methods = list(...))) — the call itself, assigned
|
||||
;; or not, so \`setRefClass("A", methods = list(...))\` defines them like
|
||||
;; \`A <- setRefClass(...)\`.
|
||||
(call
|
||||
function: (identifier) @_refFn_m
|
||||
(#match? @_refFn_m "^setRefClass$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_methods3
|
||||
(#match? @_methods3 "^methods$")
|
||||
value: (call
|
||||
function: (identifier) @_listfn3
|
||||
(#match? @_listfn3 "^list$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @declaration.name
|
||||
value: (function_definition)) @declaration.method)))))
|
||||
|
||||
;; Function scope on the SAME \`name = function(...)\` argument node that the
|
||||
;; three \`@declaration.method\` patterns above anchor on, so each R6/R5 method
|
||||
;; def is owned by its own Function scope (a child of the Class scope) rather
|
||||
;; than by the Class scope. Deliberately generic — any named function-valued
|
||||
;; argument — so it cannot drift from the three long nesting patterns above; it
|
||||
;; is inert for non-method arguments (\`FUN = function...\`, \`error = function(e)\`
|
||||
;; own no defs, and calls inside them walk up to the enclosing callable). The
|
||||
;; declaration patterns above and this pattern must stay in lockstep.
|
||||
(argument
|
||||
name: (identifier)
|
||||
value: (function_definition)) @scope.function
|
||||
|
||||
;; ── Imports ────────────────────────────────────────────────────────────────
|
||||
|
||||
;; source("path.R") — side-effect: establishes a file dependency, binds no name.
|
||||
(call
|
||||
function: (identifier) @_srcfn
|
||||
(#match? @_srcfn "^source$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
value: [(identifier) (string)] @import.source))) @import.side-effect
|
||||
|
||||
;; library("pkg") / require("pkg") — wildcard: the exported names of the
|
||||
;; named local package become visible (rScopeResolver.expandsWildcardTo).
|
||||
(call
|
||||
function: (identifier) @_libfn
|
||||
(#match? @_libfn "^(library|require)$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
value: [(identifier) (string)] @import.source))) @import.wildcard
|
||||
|
||||
;; ── Type bindings — R6 constructor (x <- Type$new(...)) ───────────────────
|
||||
|
||||
(binary_operator
|
||||
lhs: (identifier) @type-binding.name
|
||||
operator: ["<-" "<<-" "="]
|
||||
rhs: (call
|
||||
function: (extract_operator
|
||||
lhs: (identifier) @type-binding.type
|
||||
rhs: (identifier) @_newfn
|
||||
(#match? @_newfn "^new$")))) @type-binding.constructor
|
||||
|
||||
;; ── References — heritage: R6 inherit= ────────────────────────────────────
|
||||
|
||||
(binary_operator
|
||||
lhs: (identifier)
|
||||
operator: ["<-" "<<-" "="]
|
||||
rhs: (call
|
||||
function: (namespace_operator
|
||||
rhs: (identifier) @_r6ns_h
|
||||
(#match? @_r6ns_h "^R6Class$"))
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_inh1
|
||||
(#match? @_inh1 "^inherit$")
|
||||
value: (identifier) @reference.inherits))))
|
||||
|
||||
(binary_operator
|
||||
lhs: (identifier)
|
||||
operator: ["<-" "<<-" "="]
|
||||
rhs: (call
|
||||
function: (identifier) @_r6bare_h
|
||||
(#match? @_r6bare_h "^R6Class$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_inh2
|
||||
(#match? @_inh2 "^inherit$")
|
||||
value: (identifier) @reference.inherits))))
|
||||
|
||||
;; ── References — heritage: S4 contains= (single parent, incl. "VIRTUAL") ──
|
||||
|
||||
(call
|
||||
function: (identifier) @_s4h1
|
||||
(#match? @_s4h1 "^(setClass|setRefClass)$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_contains1
|
||||
(#match? @_contains1 "^(contains)$")
|
||||
value: (string
|
||||
content: (string_content) @reference.inherits))))
|
||||
|
||||
;; ── References — heritage: S4 contains=c(...) (multiple parents) ──────────
|
||||
|
||||
(call
|
||||
function: (identifier) @_s4h2
|
||||
(#match? @_s4h2 "^(setClass|setRefClass)$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_contains2
|
||||
(#match? @_contains2 "^(contains)$")
|
||||
value: (call
|
||||
function: (identifier) @_cfn2
|
||||
(#match? @_cfn2 "^c$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
value: (string
|
||||
content: (string_content) @reference.inherits)))))))
|
||||
|
||||
;; ── References — calls ────────────────────────────────────────────────────
|
||||
|
||||
;; Free calls: foo(x). emitRScopeCaptures renames the call to its setter (foo<-) when it sits in
|
||||
;; an assignment target (foo(x) <- v), see isRReplacementCall in assignment.ts.
|
||||
(call
|
||||
function: (identifier) @reference.name) @reference.call.free
|
||||
|
||||
;; Namespaced calls: pkg::func() and pkg:::func(). The whole namespace_operator
|
||||
;; is captured as the qualified name so the qualifier survives the parse cache.
|
||||
(call
|
||||
function: (namespace_operator
|
||||
rhs: (identifier) @reference.name) @reference.qualified-name) @reference.call.free
|
||||
|
||||
;; Member calls via $: obj$method(). Only $: the grammar uses extract_operator for @ too, and
|
||||
;; u@save() on an R6 instance is an error in R, not a member call (no site).
|
||||
(call
|
||||
function: (extract_operator
|
||||
lhs: (identifier) @reference.receiver
|
||||
operator: "$"
|
||||
rhs: (identifier) @reference.name)) @reference.call.member
|
||||
|
||||
;; Chained member calls: a$b$m(), Cls$new()$m(). The receiver is itself an extract_operator or a
|
||||
;; call; the pattern is disjoint from the one above (an identifier receiver), so no call is
|
||||
;; matched twice. emitRScopeCaptures turns the receiver into a truncated chain, or drops the match
|
||||
;; when the chain has no variable base (get_user()$m(), pkg::Cls$new()$m(), x[[1]]$f$m()).
|
||||
(call
|
||||
function: (extract_operator
|
||||
lhs: [(extract_operator) (call)] @reference.receiver
|
||||
operator: "$"
|
||||
rhs: (identifier) @reference.name)) @reference.call.member
|
||||
`;
|
||||
|
||||
let _parser: Parser | null = null;
|
||||
let _query: Parser.Query | null = null;
|
||||
|
||||
/**
|
||||
* The R grammar, loaded on first use through the shared loader rather than by a static import:
|
||||
* a native binding that fails to load must disable R parsing, not every language that shares
|
||||
* this module graph.
|
||||
*/
|
||||
const getRGrammar = (): Parameters<Parser['setLanguage']>[0] =>
|
||||
getLanguageGrammar(SupportedLanguages.R) as Parameters<Parser['setLanguage']>[0];
|
||||
|
||||
export function getRParser(): Parser {
|
||||
if (_parser === null) {
|
||||
// Cache the parser only once its language is set: a failed grammar load must leave nothing
|
||||
// behind, so the next call retries (or throws again) instead of returning a language-less parser.
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(getRGrammar());
|
||||
_parser = parser;
|
||||
}
|
||||
return _parser;
|
||||
}
|
||||
|
||||
export function getRScopeQuery(): Parser.Query {
|
||||
if (_query === null) {
|
||||
_query = new Parser.Query(getRGrammar(), R_SCOPE_QUERY);
|
||||
}
|
||||
return _query;
|
||||
}
|
||||
230
gitnexus/src/core/ingestion/languages/r/r6-self.ts
Normal file
230
gitnexus/src/core/ingestion/languages/r/r6-self.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
/**
|
||||
* What `self` is inside an R6 method.
|
||||
*
|
||||
* R6 methods are `name = function(...)` arguments of the `public`/`private`/`active` list of an
|
||||
* `R6Class(...)` call, and `self` in their body is the instance of that class. The class is the
|
||||
* name the `R6Class` call is assigned to (`Child <- R6::R6Class(...)`), the same name the
|
||||
* `Class` definition carries, so `self$m()` resolves through that class and its parents.
|
||||
* Anywhere else `self` is no R6 instance: a free function, an `R6Class` call that is assigned to
|
||||
* nothing, a `setRefClass` method (which uses `.self`). The scope there owns `self` without a
|
||||
* type, and a `self$m()` call is left unresolved rather than matched to a free function `m`.
|
||||
*
|
||||
* `self` is the public interface of the instance: a member of the `private` list is reached
|
||||
* through `private$m()`, never through `self$m()`. The definitions carry no section, so the
|
||||
* capture pass records where the private members are (`recordRR6PrivateMembers`), per file, and
|
||||
* the record crosses the worker boundary as the file's capture side channel.
|
||||
*/
|
||||
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import { definitionIdPosition } from '../../scope-resolution/utils/definition-id.js';
|
||||
import { assertCloneable } from '../../workers/clone-safety.js';
|
||||
import { isRLeftAssignment, rBoundName } from './assignment.js';
|
||||
|
||||
const R6_SECTIONS: ReadonlySet<string> = new Set(['public', 'private', 'active']);
|
||||
|
||||
/** The section name of an `argument` node (`public = list(...)`), or null. */
|
||||
const argumentName = (argument: SyntaxNode): string | null => {
|
||||
const name = argument.childForFieldName('name');
|
||||
return name?.type === 'identifier' ? name.text : null;
|
||||
};
|
||||
|
||||
/** Whether `call` is `R6Class(...)` or `R6::R6Class(...)`. */
|
||||
const isR6ClassCall = (call: SyntaxNode): boolean => {
|
||||
if (call.type !== 'call') return false;
|
||||
const fn = call.childForFieldName('function');
|
||||
if (fn?.type === 'identifier') return fn.text === 'R6Class';
|
||||
return fn?.type === 'namespace_operator' && fn.childForFieldName('rhs')?.text === 'R6Class';
|
||||
};
|
||||
|
||||
/** The `call` an `arguments` node's `argument` belongs to, or null. */
|
||||
const callOf = (argument: SyntaxNode): SyntaxNode | null => {
|
||||
const list = argument.parent;
|
||||
const call = list?.type === 'arguments' ? list.parent : null;
|
||||
return call?.type === 'call' ? call : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The class a method's `self` is, or null when `argument` is not an R6 method.
|
||||
*
|
||||
* `argument` is a `name = function(...)` argument of `list(...)`, itself the value of a
|
||||
* `public`/`private`/`active` argument of an `R6Class(...)` call that is the right-hand side of
|
||||
* an assignment to a plain identifier. The name is returned as written, backticks included, as
|
||||
* the `Class` definition keeps it.
|
||||
*/
|
||||
export function rR6MethodClassName(argument: SyntaxNode): string | null {
|
||||
return rR6Method(argument)?.className ?? null;
|
||||
}
|
||||
|
||||
/** {@link rR6MethodClassName} and the section (`public`, `private`, `active`) the method is in. */
|
||||
export function rR6Method(
|
||||
argument: SyntaxNode,
|
||||
): { readonly className: string; readonly section: string } | null {
|
||||
if (argument.type !== 'argument') return null;
|
||||
if (argument.childForFieldName('name')?.type !== 'identifier') return null;
|
||||
if (argument.childForFieldName('value')?.type !== 'function_definition') return null;
|
||||
|
||||
const listCall = callOf(argument);
|
||||
if (listCall === null) return null;
|
||||
const listFn = listCall.childForFieldName('function');
|
||||
if (listFn?.type !== 'identifier' || listFn.text !== 'list') return null;
|
||||
|
||||
const section = listCall.parent;
|
||||
if (section?.type !== 'argument') return null;
|
||||
if (section.childForFieldName('value')?.id !== listCall.id) return null;
|
||||
const sectionName = argumentName(section);
|
||||
if (sectionName === null || !R6_SECTIONS.has(sectionName)) return null;
|
||||
|
||||
const r6Call = callOf(section);
|
||||
if (r6Call === null || !isR6ClassCall(r6Call)) return null;
|
||||
|
||||
const assignment = r6Call.parent;
|
||||
if (assignment === null || !isRLeftAssignment(assignment)) return null;
|
||||
if (assignment.childForFieldName('rhs')?.id !== r6Call.id) return null;
|
||||
const target = assignment.childForFieldName('lhs');
|
||||
if (target?.type !== 'identifier' || target.text.length === 0) return null;
|
||||
return { className: target.text, section: sectionName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the file binds the name `self` at top level (`self <- Foo$new()`, `self = ...`,
|
||||
* `... -> self`). Its free functions then read that variable, which may be typed, so they are not
|
||||
* frames that own `self` untyped.
|
||||
*/
|
||||
export function fileBindsSelf(program: SyntaxNode): boolean {
|
||||
for (let i = 0; i < program.namedChildCount; i++) {
|
||||
const child = program.namedChild(i);
|
||||
if (child !== null && rBoundName(child) === 'self') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-file classifier for {@link rFunctionOwnsSelf}-style questions: whether a
|
||||
* `function_definition` starts a frame in which `self` is not an R6 instance. That holds when it
|
||||
* is neither the function of an R6 method nor nested inside another function (a closure in a
|
||||
* method sees the method's `self`; a closure in a free function sees the free function's, which
|
||||
* is none). The answer for an ancestor is remembered, so the work over a file is linear in its
|
||||
* size however deeply functions nest.
|
||||
*/
|
||||
export function createSelfOwnerClassifier(): (fn: SyntaxNode) => boolean {
|
||||
const insideFunction = new Map<number, boolean>();
|
||||
const isInsideFunction = (node: SyntaxNode): boolean => {
|
||||
const path: number[] = [];
|
||||
let answer = false;
|
||||
for (let up = node.parent; up !== null; up = up.parent) {
|
||||
if (up.type === 'function_definition') {
|
||||
answer = true;
|
||||
break;
|
||||
}
|
||||
const known = insideFunction.get(up.id);
|
||||
if (known !== undefined) {
|
||||
answer = known;
|
||||
break;
|
||||
}
|
||||
path.push(up.id);
|
||||
}
|
||||
for (const id of path) insideFunction.set(id, answer);
|
||||
return answer;
|
||||
};
|
||||
return (fn) => {
|
||||
if (fn.type !== 'function_definition') return false;
|
||||
const parent = fn.parent;
|
||||
if (parent !== null && rR6MethodClassName(parent) !== null) return false;
|
||||
return !isInsideFunction(fn);
|
||||
};
|
||||
}
|
||||
|
||||
/** The capture side channel of an R file: where its R6 private members are defined. */
|
||||
interface RCaptureSideChannel {
|
||||
readonly kind: 'r';
|
||||
/** `line:column` of each private member's definition anchor, as its definition id encodes it. */
|
||||
readonly r6PrivateMembers: readonly string[];
|
||||
}
|
||||
|
||||
const r6PrivateMembersByFile = new Map<string, ReadonlySet<string>>();
|
||||
|
||||
/** The `line:column` key of a node, as `nodeToCapture` and the definition id write it. */
|
||||
export const rDefinitionPositionKey = (node: SyntaxNode): string =>
|
||||
`${node.startPosition.row + 1}:${node.startPosition.column}`;
|
||||
|
||||
/**
|
||||
* Record the private R6 members of `filePath` (keys from {@link rDefinitionPositionKey}),
|
||||
* replacing what an earlier parse of a file at that path recorded.
|
||||
*/
|
||||
export function recordRR6PrivateMembers(filePath: string, keys: ReadonlySet<string>): void {
|
||||
if (keys.size === 0) r6PrivateMembersByFile.delete(filePath);
|
||||
else r6PrivateMembersByFile.set(filePath, keys);
|
||||
}
|
||||
|
||||
/** `LanguageProvider.collectCaptureSideChannel` for R. */
|
||||
export function collectRCaptureSideChannel(filePath: string): RCaptureSideChannel | undefined {
|
||||
const keys = r6PrivateMembersByFile.get(filePath);
|
||||
if (keys === undefined) return undefined;
|
||||
return assertCloneable<RCaptureSideChannel>({ kind: 'r', r6PrivateMembers: [...keys] });
|
||||
}
|
||||
|
||||
/** `ScopeResolver.applyCaptureSideChannel` for R: restore the record of a reused file. */
|
||||
export function applyRCaptureSideChannel(parsed: ParsedFile): void {
|
||||
const facts = parsed.captureSideChannel as RCaptureSideChannel | undefined;
|
||||
recordRR6PrivateMembers(
|
||||
parsed.filePath,
|
||||
new Set(facts?.kind === 'r' ? facts.r6PrivateMembers : []),
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether `def` is a member of an R6 class's `private` list. */
|
||||
function isRR6PrivateMember(def: SymbolDefinition): boolean {
|
||||
const position = definitionIdPosition(def.nodeId, def.filePath);
|
||||
if (position === undefined) return false;
|
||||
const keys = r6PrivateMembersByFile.get(def.filePath);
|
||||
return keys?.has(`${position.line}:${position.column}`) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ScopeResolver.resolveReceiverMember` for R: every typed receiver, `self` in an R6 method and a
|
||||
* local typed by a constructor (`u <- User$new(); u$m()`) alike. The name is historical.
|
||||
*
|
||||
* `x$m()` reads `m` off the public interface of the instance: the public (and active) members of
|
||||
* the class and its parents. A private member is not on the instance, so it is skipped, and a
|
||||
* parent's public member of that name is the answer even when the class has a private one. When
|
||||
* no public member is found, the lookup is reported ambiguous with no candidate.
|
||||
*
|
||||
* The generic lookup keeps a lexical route for `self` (a class body binds its own members, so the
|
||||
* name is also looked up in the enclosing scopes), and in R that walk reaches a free function of
|
||||
* the same name: `self$nothere()` would bind a top-level `nothere`. For any typed receiver, a
|
||||
* member the owner lookup misses (a dotted member such as `to.json`, which the registry keys by
|
||||
* its last segment) would fall to a global lookup of the bare name that ignores the receiver. R
|
||||
* reads the member off the instance, so a free function is never the answer. Answering
|
||||
* `ambiguous` makes the pass record a suppressed outcome and mark the site handled, which stops
|
||||
* those fallbacks; the outcome reason is `member-lookup-ambiguous`, where `member-not-found` would
|
||||
* be more exact. A member found before any private one was skipped is left to the generic MRO walk
|
||||
* (`undefined`), which finds the same one; past a skipped private member the answer is given here,
|
||||
* because that walk would stop at it.
|
||||
*/
|
||||
export const rResolveSelfMember: NonNullable<ScopeResolver['resolveReceiverMember']> = (
|
||||
owner,
|
||||
name,
|
||||
site,
|
||||
scopes,
|
||||
model,
|
||||
) => {
|
||||
if (site.kind !== 'call') return undefined;
|
||||
let skippedPrivate = false;
|
||||
for (const ownerId of [owner.nodeId, ...scopes.methodDispatch.mroFor(owner.nodeId)]) {
|
||||
const methods = model.methods.lookupAllByOwner(ownerId, name);
|
||||
const visible = methods.filter((def) => !isRR6PrivateMember(def));
|
||||
if (visible.length < methods.length) skippedPrivate = true;
|
||||
if (visible.length > 0) {
|
||||
if (!skippedPrivate) return undefined;
|
||||
const [only] = visible;
|
||||
return visible.length === 1 && only !== undefined
|
||||
? { kind: 'resolved', definition: only }
|
||||
: { kind: 'ambiguous', candidateIds: visible.map((def) => def.nodeId) };
|
||||
}
|
||||
if (model.fields.lookupAllByOwner(ownerId, name).length > 0) {
|
||||
return skippedPrivate ? { kind: 'ambiguous', candidateIds: [] } : undefined;
|
||||
}
|
||||
}
|
||||
return { kind: 'ambiguous', candidateIds: [] };
|
||||
};
|
||||
266
gitnexus/src/core/ingestion/languages/r/receiver-agreement.ts
Normal file
266
gitnexus/src/core/ingestion/languages/r/receiver-agreement.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
/**
|
||||
* Which constructor type bindings (`x <- T$new()`) may type a receiver: only those whose name the
|
||||
* frame binds to nothing but that constructor.
|
||||
*
|
||||
* The scope query emits one `@type-binding.constructor` per `x <- T$new()`, `x = T$new()` and
|
||||
* `x <<- T$new()`, and the shared extractor keeps one type per name per scope, the later binding
|
||||
* winning. On its own that types `x` as `T` however else the frame binds `x`: a second class, an
|
||||
* untyped value, a loop variable, a formal, `rm(x)`. R calls the method of whatever `x` holds at
|
||||
* the call, which the order of bindings alone does not decide (branches, loops, closures). So a
|
||||
* constructor types `x` only when every binding of `x` in its frame agrees with it, and otherwise
|
||||
* the receiver is left untyped: an unresolved call, never an edge to another class's method.
|
||||
*
|
||||
* A frame is the file's top level (`program`) or a `function_definition`. The braces of
|
||||
* `local({...})` and of every other call belong to the enclosing frame. Frames are per file: a
|
||||
* package namespace spans files, so a rebinding in another file is not seen.
|
||||
*
|
||||
* Each binding of a name gives one signature in its frame:
|
||||
* - `ctor:T` for `x <- T$new()`, `x = T$new()`, `x <<- T$new()` and the right-assignment forms
|
||||
* (`T$new() -> x` binds no type, but it still counts), with `T` read as the query reads it;
|
||||
* - neutral for `NULL` and a constant (`x$m()` on one always errors, so it never names another
|
||||
* class for a call that runs) and for a write through `x` that leaves it by `$`, `@`, `[[` or
|
||||
* `[` (`x$a <- v`, `x[[i]] <- v`, `names(x$a) <- v`): an R6 instance is a reference, so the
|
||||
* write keeps it the same object, as for `self`;
|
||||
* - `other` for anything else: another value, a formal, a `for` variable, `rm(x)` or
|
||||
* `remove(x)` (a name, a string, or `list = "x"`), `assign("x", ...)` and
|
||||
* `delayedAssign("x", ...)` with a literal name, and a replacement call applied to `x` itself
|
||||
* (`class(x) <- v` binds `x` to what the setter returns).
|
||||
*
|
||||
* `<<-` and `->>` never bind in their own frame: they record their signature into every strictly
|
||||
* enclosing frame up to the top level (at the top level, into the top level). So in
|
||||
* `function() { x <<- User$new(); x$save() }` the name is free in the function and the call stays
|
||||
* untyped.
|
||||
*
|
||||
* `with()` and `within()` are not modelled, nor `assign()` with a computed name or another
|
||||
* environment (read as the current frame).
|
||||
*/
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { rBareName } from './bare-name.js';
|
||||
import { isRConstantValue } from './assignment.js';
|
||||
|
||||
const OTHER = 'other';
|
||||
const NEUTRAL = 'neutral';
|
||||
|
||||
/** `T$new()` (any extract operator, as the type-binding query reads it): `ctor:T`, else null. */
|
||||
function constructorSignature(value: SyntaxNode): string | null {
|
||||
if (value.type !== 'call') return null;
|
||||
const fn = value.childForFieldName('function');
|
||||
if (fn?.type !== 'extract_operator') return null;
|
||||
const lhs = fn.childForFieldName('lhs');
|
||||
const rhs = fn.childForFieldName('rhs');
|
||||
if (lhs?.type !== 'identifier' || rhs?.type !== 'identifier' || rhs.text !== 'new') return null;
|
||||
return 'ctor:' + rBareName(lhs.text);
|
||||
}
|
||||
|
||||
/** The signature an assigned value gives its name. */
|
||||
function valueSignature(value: SyntaxNode | null): string {
|
||||
if (value === null) return OTHER;
|
||||
if (value.type === 'null' || isRConstantValue(value)) return NEUTRAL;
|
||||
return constructorSignature(value) ?? OTHER;
|
||||
}
|
||||
|
||||
/** The first argument value of a call (`x` of `class(x)`), or null. */
|
||||
function firstArgumentValue(call: SyntaxNode): SyntaxNode | null {
|
||||
const args = call.childForFieldName('arguments');
|
||||
if (args === null) return null;
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const arg = args.namedChild(i);
|
||||
if (arg?.type === 'argument') return arg.childForFieldName('value');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The base identifier a complex assignment target writes through, and whether the step that
|
||||
* leaves it is a replacement call (`class(x)`: `x` is rebound to what the setter returns) rather
|
||||
* than a field step (`x$a`, `x@a`, `x[[i]]`, `x[i]`, also under a call: `names(x$a)`).
|
||||
*/
|
||||
function complexTargetBase(target: SyntaxNode): { base: SyntaxNode; viaCall: boolean } | null {
|
||||
let viaCall = false;
|
||||
for (let node: SyntaxNode | null = target; node !== null; ) {
|
||||
switch (node.type) {
|
||||
case 'identifier':
|
||||
return { base: node, viaCall };
|
||||
case 'extract_operator':
|
||||
viaCall = false;
|
||||
node = node.childForFieldName('lhs');
|
||||
break;
|
||||
case 'subset':
|
||||
case 'subset2':
|
||||
viaCall = false;
|
||||
node = node.childForFieldName('function');
|
||||
break;
|
||||
case 'call':
|
||||
viaCall = true;
|
||||
node = firstArgumentValue(node);
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The name a string literal spells (its content as written), or null. */
|
||||
function stringName(node: SyntaxNode): string | null {
|
||||
if (node.type !== 'string') return null;
|
||||
return node.childForFieldName('content')?.text ?? null;
|
||||
}
|
||||
|
||||
/** The names `rm()`/`remove()` unbind, and the one `assign()`/`delayedAssign()` binds. */
|
||||
function unboundOrAssignedNames(call: SyntaxNode): string[] {
|
||||
const fn = call.childForFieldName('function');
|
||||
if (fn?.type !== 'identifier') return [];
|
||||
const callee = fn.text;
|
||||
const isRemove = callee === 'rm' || callee === 'remove';
|
||||
if (!isRemove && callee !== 'assign' && callee !== 'delayedAssign') return [];
|
||||
const args = call.childForFieldName('arguments');
|
||||
if (args === null) return [];
|
||||
const names: string[] = [];
|
||||
let firstUnnamed: SyntaxNode | null = null;
|
||||
let namedX: SyntaxNode | null = null;
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const arg = args.namedChild(i);
|
||||
if (arg?.type !== 'argument') continue;
|
||||
const value = arg.childForFieldName('value');
|
||||
if (value === null) continue;
|
||||
const argName = arg.childForFieldName('name')?.text;
|
||||
if (isRemove) {
|
||||
if (argName === undefined) {
|
||||
const name = value.type === 'identifier' ? rBareName(value.text) : stringName(value);
|
||||
if (name !== null) names.push(name);
|
||||
} else if (argName === 'list') {
|
||||
const name = stringName(value);
|
||||
if (name !== null) names.push(name);
|
||||
}
|
||||
} else if (argName === 'x') {
|
||||
namedX = value;
|
||||
} else if (argName === undefined && firstUnnamed === null) {
|
||||
firstUnnamed = value;
|
||||
}
|
||||
}
|
||||
const literal = namedX ?? firstUnnamed;
|
||||
if (!isRemove && literal !== null) {
|
||||
const name = stringName(literal);
|
||||
if (name !== null) names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
interface Anchor {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly signature: string;
|
||||
readonly frame: number;
|
||||
/** For `<<-`: the frames it records into, which must agree too. */
|
||||
readonly enclosing: readonly number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids of the `@type-binding.constructor` anchors (the assignment nodes) whose type binding
|
||||
* is kept: every non-neutral binding of the name in the anchor's frame is the same constructor
|
||||
* (and, for `<<-`, in every frame it assigns into). One top-down pass with an explicit stack;
|
||||
* no `.parent` call, so the work is linear in the size of the tree.
|
||||
*/
|
||||
export function rAgreedConstructorAnchors(root: SyntaxNode): Set<number> {
|
||||
/** frame id -> name -> signatures. */
|
||||
const signatures = new Map<number, Map<string, Set<string>>>();
|
||||
/** frame id -> enclosing frame id (absent for the top level). */
|
||||
const enclosingFrame = new Map<number, number>();
|
||||
const record = (frame: number, name: string, signature: string): void => {
|
||||
let names = signatures.get(frame);
|
||||
if (names === undefined) signatures.set(frame, (names = new Map()));
|
||||
let set = names.get(name);
|
||||
if (set === undefined) names.set(name, (set = new Set()));
|
||||
set.add(signature);
|
||||
};
|
||||
/** The frames `<<-` assigns into from `frame`: every strictly enclosing one, or the top level. */
|
||||
const outwardFrames = (frame: number): number[] => {
|
||||
const frames: number[] = [];
|
||||
for (let f = enclosingFrame.get(frame); f !== undefined; f = enclosingFrame.get(f)) {
|
||||
frames.push(f);
|
||||
}
|
||||
return frames.length > 0 ? frames : [frame];
|
||||
};
|
||||
const anchors: Anchor[] = [];
|
||||
|
||||
const stack: { node: SyntaxNode; frame: number }[] = [{ node: root, frame: root.id }];
|
||||
for (let item = stack.pop(); item !== undefined; item = stack.pop()) {
|
||||
const { node } = item;
|
||||
let frame = item.frame;
|
||||
if (node.type === 'function_definition') {
|
||||
enclosingFrame.set(node.id, frame);
|
||||
frame = node.id;
|
||||
const parameters = node.childForFieldName('parameters');
|
||||
for (let i = 0; parameters !== null && i < parameters.namedChildCount; i++) {
|
||||
const parameter = parameters.namedChild(i);
|
||||
if (parameter?.type !== 'parameter') continue;
|
||||
const name = parameter.childForFieldName('name');
|
||||
if (name?.type === 'identifier') record(frame, rBareName(name.text), OTHER);
|
||||
}
|
||||
} else if (node.type === 'for_statement') {
|
||||
const variable = node.childForFieldName('variable');
|
||||
if (variable?.type === 'identifier') record(frame, rBareName(variable.text), OTHER);
|
||||
} else if (node.type === 'binary_operator') {
|
||||
const operator = node.childForFieldName('operator')?.text;
|
||||
let target: SyntaxNode | null = null;
|
||||
let value: SyntaxNode | null = null;
|
||||
if (operator === '<-' || operator === '=' || operator === '<<-') {
|
||||
target = node.childForFieldName('lhs');
|
||||
value = node.childForFieldName('rhs');
|
||||
} else if (operator === '->' || operator === '->>') {
|
||||
target = node.childForFieldName('rhs');
|
||||
value = node.childForFieldName('lhs');
|
||||
}
|
||||
if (target !== null) {
|
||||
const outward = operator === '<<-' || operator === '->>';
|
||||
const into = outward ? outwardFrames(frame) : [frame];
|
||||
if (target.type === 'identifier') {
|
||||
const name = rBareName(target.text);
|
||||
const signature = valueSignature(value);
|
||||
for (const f of into) record(f, name, signature);
|
||||
// The query emits a constructor binding for the left-assignment forms only.
|
||||
if (operator !== '->' && operator !== '->>' && signature.startsWith('ctor:')) {
|
||||
anchors.push({ id: node.id, name, signature, frame, enclosing: outward ? into : [] });
|
||||
}
|
||||
} else {
|
||||
const written = complexTargetBase(target);
|
||||
if (written?.viaCall === true) {
|
||||
for (const f of into) record(f, rBareName(written.base.text), OTHER);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'call') {
|
||||
for (const name of unboundOrAssignedNames(node)) record(frame, name, OTHER);
|
||||
}
|
||||
for (let i = node.namedChildCount - 1; i >= 0; i--) {
|
||||
const child = node.namedChild(i);
|
||||
if (child !== null) stack.push({ node: child, frame });
|
||||
}
|
||||
}
|
||||
|
||||
/** The non-neutral signatures of `name` in `frame`. */
|
||||
const real = (frame: number, name: string): string[] => {
|
||||
const set = signatures.get(frame)?.get(name);
|
||||
return set === undefined ? [] : [...set].filter((signature) => signature !== NEUTRAL);
|
||||
};
|
||||
const kept = new Set<number>();
|
||||
for (const anchor of anchors) {
|
||||
const own = real(anchor.frame, anchor.name);
|
||||
if (own.length !== 1 || own[0] !== anchor.signature) continue;
|
||||
if (anchor.enclosing.some((f) => real(f, anchor.name).some((s) => s !== anchor.signature))) {
|
||||
continue;
|
||||
}
|
||||
kept.add(anchor.id);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* A predicate over `@type-binding.constructor` anchor nodes, built on its first use: the tree is
|
||||
* walked once per file, and only for a file that has a constructor binding at all.
|
||||
*/
|
||||
export function rConstructorAnchorTest(root: SyntaxNode): (anchor: SyntaxNode) => boolean {
|
||||
let kept: Set<number> | undefined;
|
||||
return (anchor) => (kept ??= rAgreedConstructorAnchors(root)).has(anchor.id);
|
||||
}
|
||||
1014
gitnexus/src/core/ingestion/languages/r/rename.ts
Normal file
1014
gitnexus/src/core/ingestion/languages/r/rename.ts
Normal file
File diff suppressed because it is too large
Load diff
337
gitnexus/src/core/ingestion/languages/r/scope-resolver.ts
Normal file
337
gitnexus/src/core/ingestion/languages/r/scope-resolver.ts
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
/**
|
||||
* R `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by the
|
||||
* generic `runScopeResolution` orchestrator.
|
||||
*
|
||||
* Thin wiring: import resolution reuses `resolveRImportTarget`
|
||||
* (`languages/r/import-target.ts`), an adapter over the existing
|
||||
* `resolveRImportInternal`, with the repo's local R packages
|
||||
* threaded through `loadRPackageConfig`. Heritage (R6 `inherit=`, S4
|
||||
* `contains=`) mostly rides the generic `@reference.inherits` mechanism —
|
||||
* R6/S4 base names are ordinary scope-bound class names, unlike Ruby's
|
||||
* `include`/`extend`, which are method calls. The one exception is S4's
|
||||
* `contains = "VIRTUAL"` sentinel (`setClass(x, contains = "VIRTUAL", ...)`,
|
||||
* R's spelling for an abstract base): no fixture ever declares
|
||||
* `setClass("VIRTUAL", ...)`, so the generic scope-based lookup
|
||||
* (`resolveInheritanceBaseInScope` / `findClassBindingInScope`) can never
|
||||
* find a binding for it — that lookup is scope-model-only and never
|
||||
* consults graph nodes directly. `emitRVirtualHeritageEdges` below mints
|
||||
* the placeholder `VIRTUAL` graph node and its EXTENDS edge directly,
|
||||
* mirroring Ruby's `emitRubyMixinEdges` shape for the same reason: a
|
||||
* heritage target that R code names but never declares.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
|
||||
import {
|
||||
findEnclosingClassDef,
|
||||
populateClassOwnedMembers,
|
||||
} from '../../scope-resolution/scope/walkers.js';
|
||||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
|
||||
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import {
|
||||
loadRPackageConfig,
|
||||
recordedRPackageConfig,
|
||||
recordRPackageConfig,
|
||||
rSymbolVisibility,
|
||||
} from './package-config.js';
|
||||
import { rBareName } from './bare-name.js';
|
||||
import { applyRCaptureSideChannel, rResolveSelfMember } from './r6-self.js';
|
||||
import { perFileSet } from '../../utils/per-file-set.js';
|
||||
import { resolveRImportTarget } from './import-target.js';
|
||||
import { collectRUnindexedSources } from './unindexed-sources.js';
|
||||
import {
|
||||
isRGlobalNameFallbackPlausible,
|
||||
populateRNamespaceImports,
|
||||
rFileTopLevel,
|
||||
} from './namespace-imports.js';
|
||||
import { rProvider } from '../r.js';
|
||||
import { populateRQualifiedCalls, resolveRQualifiedFreeCall } from './qualified-call.js';
|
||||
import { rArityCompatibility, rMergeBindings } from './simple-hooks.js';
|
||||
|
||||
/** Literal string `"VIRTUAL"` graph id — one node shared by every S4 class
|
||||
* that names it as a base, minted lazily on first use. */
|
||||
const VIRTUAL_CLASS_NAME = 'VIRTUAL';
|
||||
|
||||
/**
|
||||
* Whether `def` is declared by a call that carries the class name as a string
|
||||
* (`setClass("A", ...)`, `setRefClass("A", ...)`): the name starts after the declaration
|
||||
* does. `A <- R6Class(...)` starts at its name. Only the call form can write the S4
|
||||
* sentinel `contains = "VIRTUAL"`; an R6 `inherit = VIRTUAL` yields an `inherits` reference
|
||||
* of the same name that is an ordinary base class.
|
||||
*/
|
||||
function isDeclaredByNamingCall(def: SymbolDefinition): boolean {
|
||||
const name = def.nameRange;
|
||||
const declaration = def.declarationRange;
|
||||
if (name === undefined || declaration === undefined) return false;
|
||||
return (
|
||||
name.startLine > declaration.startLine ||
|
||||
(name.startLine === declaration.startLine && name.startCol > declaration.startCol)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint the `VIRTUAL` placeholder Class node (once) and an EXTENDS edge from
|
||||
* every S4 class whose `contains=` names it (not an R6 class whose `inherit=` names a
|
||||
* class called `VIRTUAL`). Scope is deliberately limited to
|
||||
* the literal string `"VIRTUAL"`: other S4 base-type sentinels (`"numeric"`,
|
||||
* `"list"`, …) are not treated as placeholder classes, which would be a
|
||||
* separate design decision.
|
||||
*
|
||||
* Idempotent: re-seeds its dedup set from any `EXTENDS` edge already
|
||||
* targeting a node named `VIRTUAL` before emitting, and re-uses the graph's
|
||||
* existing `VIRTUAL` node instead of minting a second one when the
|
||||
* orchestrator re-runs resolution.
|
||||
*/
|
||||
function emitRVirtualHeritageEdges(
|
||||
graph: KnowledgeGraph,
|
||||
_parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
scopes?: ScopeResolutionIndexes,
|
||||
): void {
|
||||
if (scopes === undefined) return;
|
||||
|
||||
const virtualGraphId = generateId('Class', VIRTUAL_CLASS_NAME);
|
||||
const emitted = new Set<string>();
|
||||
for (const rel of graph.iterRelationshipsByType('EXTENDS')) {
|
||||
if (rel.targetId === virtualGraphId) emitted.add(rel.sourceId);
|
||||
}
|
||||
|
||||
let virtualNodeMinted = graph.getNode(virtualGraphId) !== undefined;
|
||||
|
||||
for (const site of scopes.referenceSites) {
|
||||
if (site.kind !== 'inherits' || site.name !== VIRTUAL_CLASS_NAME) continue;
|
||||
const callerClass = findEnclosingClassDef(site.inScope, scopes);
|
||||
if (callerClass === undefined || !isDeclaredByNamingCall(callerClass)) continue;
|
||||
const callerGraphId = resolveDefGraphId(callerClass.filePath, callerClass, nodeLookup);
|
||||
if (callerGraphId === undefined || emitted.has(callerGraphId)) continue;
|
||||
|
||||
if (!virtualNodeMinted) {
|
||||
graph.addNode({
|
||||
id: virtualGraphId,
|
||||
label: 'Class',
|
||||
properties: { name: VIRTUAL_CLASS_NAME, filePath: callerClass.filePath },
|
||||
});
|
||||
virtualNodeMinted = true;
|
||||
}
|
||||
emitted.add(callerGraphId);
|
||||
graph.addRelationship({
|
||||
id: generateId('EXTENDS', `${callerGraphId}->${virtualGraphId}`),
|
||||
sourceId: callerGraphId,
|
||||
targetId: virtualGraphId,
|
||||
type: 'EXTENDS',
|
||||
confidence: 0.85,
|
||||
reason: 'scope-resolution: inherits (virtual)',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `moduleScope` → its `ParsedFile`, built once per pass and keyed on the identity of the pass's
|
||||
* `parsedFiles` array, which finalize passes unchanged to every `expandsWildcardTo` call. The
|
||||
* lookup used to be `parsedFiles.find`, one scan of all files per `library()` edge: 450,000 edges
|
||||
* over 3,000 files took 5.9 s. As in the C, C++ and Python providers, the first file wins on a
|
||||
* repeated scope, as `find` did.
|
||||
*/
|
||||
const parsedFileByModuleScope = perFileSet(
|
||||
(parsedFiles: readonly ParsedFile[]): Map<ScopeId, ParsedFile> => {
|
||||
const index = new Map<ScopeId, ParsedFile>();
|
||||
for (const parsed of parsedFiles) {
|
||||
if (!index.has(parsed.moduleScope)) index.set(parsed.moduleScope, parsed);
|
||||
}
|
||||
return index;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Enumerate the top-level names a `library()`/`require()` of a target module scope's file brings
|
||||
* into scope (module-scope function and class bindings; functions nested in another function are
|
||||
* not). `library()`/`require()` are R's wildcard imports, and R attaches only a package's
|
||||
* EXPORTS: a name is kept when the target's package has no NAMESPACE, or the NAMESPACE provably
|
||||
* exports it (`rSymbolVisibility` is `public`). A name the NAMESPACE does not export, or may
|
||||
* export but cannot be proven to (unreadable NAMESPACE, a dropped or cut-short
|
||||
* `exportPattern()`), is left out: strict, because the edge this binding yields is a precise
|
||||
* 0.85 one. A bare call of such a name is not lost to the other tiers: the 0.5 unique-name guess
|
||||
* skips it only when it is proven private (`isRGlobalNameFallbackPlausible`).
|
||||
*
|
||||
* The package config is read from the target's `ParsedFile` (`recordedRPackageConfig`); without
|
||||
* one (hand-built input) nothing is filtered. The hook has no importer, so a `library(ownpkg)`
|
||||
* issued from inside the package loses the package's private names as well (a documented gap).
|
||||
* This reverses the earlier `load_all()` reading ("every name visible"): see
|
||||
* docs/languages/r-provider.md.
|
||||
*
|
||||
* A bare name is dropped when the same file also defines a dotted top-level
|
||||
* name with that tail (`foo` next to `print.foo`). The shared finalize indexes
|
||||
* every def by the text after its last `.`, so `foo` would be bound to
|
||||
* whichever of the two comes first in the file, and `print.foo` (an S3 method,
|
||||
* never callable as `foo()`) can win. The dropped name resolves through the
|
||||
* free-call fallback instead, where {@link rIsCallableVisibleFromCaller}
|
||||
* removes the dotted def from the candidates. Order-blind, like the matching
|
||||
* guard in `languages/r/import-target.ts`.
|
||||
*/
|
||||
export function expandRWildcardNames(
|
||||
targetModuleScope: ScopeId,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
): readonly string[] {
|
||||
const target = parsedFileByModuleScope(parsedFiles).get(targetModuleScope);
|
||||
if (target === undefined) return [];
|
||||
const { names: topLevel, dottedTails } = rFileTopLevel(target);
|
||||
const cfg = recordedRPackageConfig(target);
|
||||
const seen = new Set<string>();
|
||||
const names: string[] = [];
|
||||
for (const def of target.localDefs) {
|
||||
const qn = def.qualifiedName;
|
||||
if (qn === undefined || qn.length === 0 || qn.includes('.')) continue; // no dotted names
|
||||
// `localDefs` is flat: it also lists functions nested in other functions, which a
|
||||
// `library()` does not bring into scope. Only module-scope bindings are top-level.
|
||||
// `topLevel` holds symbols and `qn` is the name as written, backticks included.
|
||||
if (!topLevel.has(rBareName(qn))) continue;
|
||||
if (dottedTails.has(qn)) continue; // shares its tail with an S3 method: see above (as written, as finalize keys it)
|
||||
if (cfg !== undefined && rSymbolVisibility(cfg, target.filePath, rBareName(qn)) !== 'public') {
|
||||
continue; // not an export of the package (or not provably one)
|
||||
}
|
||||
if (seen.has(qn)) continue;
|
||||
seen.add(qn);
|
||||
names.push(qn);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* The free-call name fallback must not bind a bare call `foo(x)` to a def whose
|
||||
* name contains a `.`: `print.foo` is reached as `print.foo(...)` or through S3
|
||||
* dispatch, yet the fallback's simple-name index keys it under `foo`. Refuse
|
||||
* such candidates so `foo(x)` cannot collide with `print.foo`. This is not a
|
||||
* claim that a bare call never runs a dotted function: `my.helper(x)` and
|
||||
* `read.config(x)` are ordinary calls. They skip this fallback and bind in the
|
||||
* same file locally (0.85) or elsewhere by qualified-name lookup at 0.35
|
||||
* (`scope-resolution: call`), which `docs/languages/r-provider.md` describes.
|
||||
*
|
||||
* Applies to every candidate the hook sees, including R6/R5 members whose
|
||||
* qualified name is `Class.member`. A bare `member(x)` never reaches an R6
|
||||
* member (it is called through `self$member()` or `obj$member()`), so the
|
||||
* refusal is right for R6. RefClass methods are different: a method calls its
|
||||
* siblings and inherited methods by bare name, and those calls get no edge
|
||||
* here (see "Limits" in `docs/languages/r-provider.md`).
|
||||
*/
|
||||
export function rIsCallableVisibleFromCaller(ctx: {
|
||||
readonly candidate: SymbolDefinition;
|
||||
}): boolean {
|
||||
return !(ctx.candidate.qualifiedName ?? '').includes('.');
|
||||
}
|
||||
|
||||
export const rScopeResolver: ScopeResolver = {
|
||||
language: SupportedLanguages.R,
|
||||
languageProvider: rProvider,
|
||||
importEdgeReason: 'r-scope: import',
|
||||
|
||||
loadResolutionConfig: (repoPath: string) => loadRPackageConfig(repoPath),
|
||||
|
||||
// The rename preview reloads the config uncached (as the contract requires), and also records
|
||||
// the repository's R files that are not indexed sources, for the rename guard (`checkSafety`
|
||||
// refuses a name such a file uses; see `collectRUnindexedSources`).
|
||||
refreshResolutionConfig: async (repoPath: string, filePaths: readonly string[]) => ({
|
||||
...(await loadRPackageConfig(repoPath)),
|
||||
unindexedRSources: await collectRUnindexedSources(repoPath, filePaths),
|
||||
}),
|
||||
|
||||
resolveImportTarget: resolveRImportTarget,
|
||||
|
||||
expandsWildcardTo: (targetModuleScope, parsedFiles) =>
|
||||
expandRWildcardNames(targetModuleScope, parsedFiles),
|
||||
|
||||
mergeBindings: rMergeBindings,
|
||||
arityCompatibility: rArityCompatibility,
|
||||
|
||||
buildMro: (graph, parsedFiles, nodeLookup) =>
|
||||
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
|
||||
|
||||
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
|
||||
|
||||
emitHeritageEdges: (graph, parsedFiles, nodeLookup, scopes) =>
|
||||
emitRVirtualHeritageEdges(graph, parsedFiles, nodeLookup, scopes),
|
||||
|
||||
// A typed receiver (`self$m()`, `u <- User$new(); u$m()`) reaches the public members of the
|
||||
// class and its parents; where none defines `m` the call is unresolved, not a call of a free
|
||||
// function `m`.
|
||||
resolveReceiverMember: rResolveSelfMember,
|
||||
// An R6 or RefClass generator object (`User` in `User$save()`) is not an instance: it carries
|
||||
// `new()` and the class metadata, never the class's instance methods. So a class name is never
|
||||
// the receiver of a member call here.
|
||||
isBareClassReceiverCandidate: () => false,
|
||||
// A dotted class name (`My.Class`) is read by the shared receiver pass as a qualified one, which
|
||||
// does not walk the class's parents and lets a lexically visible free function of the member's
|
||||
// name win. Such a receiver gets no lookup: the call is recorded as `receiver-unresolved`, which
|
||||
// also gives up the class's own and inherited methods (R-13).
|
||||
suppressReceiverLookup: (typeRef) => typeRef.rawName.includes('.'),
|
||||
// Restores which R6 members are private for a file reused from a worker or the parse cache.
|
||||
applyCaptureSideChannel: applyRCaptureSideChannel,
|
||||
|
||||
// R6's `super$method()` is not resolved: nothing binds `super` to the parent class, so there
|
||||
// is no receiver type to walk from. Off, rather than answering a wrong parent. (`self` is
|
||||
// bound, to the enclosing class, by `emitRScopeCaptures`; `private$` is not.)
|
||||
isSuperReceiver: () => false,
|
||||
|
||||
// R's dynamic-dispatch `self$field$method()` chains and R6/S4 field access
|
||||
// benefit from the same heuristic Ruby/Python rely on. It does not bear on `self$m()` itself:
|
||||
// that is settled by the typed `self` and `resolveReceiverMember` above.
|
||||
fieldFallbackOnMethodLookup: true,
|
||||
|
||||
// `source()` is a side-effect import (binds no name) — a cross-file free
|
||||
// call into a `source()`d file (e.g. `HelperFunc(x)`) has no lexical or
|
||||
// import binding to resolve through, so it resolves via a unique
|
||||
// workspace-wide name match instead. `library()`/`require()`'s own
|
||||
// wildcard-bound names still resolve through `expandsWildcardTo` above;
|
||||
// this only covers what that path cannot reach.
|
||||
allowGlobalFreeCallFallback: true,
|
||||
|
||||
// A named import binds only to a MODULE-LEVEL definition of the target
|
||||
// file. Without this, `findExportByName` indexes every `localDefs` entry by
|
||||
// simple name, so an R6/R5 method named like an imported symbol could win
|
||||
// the binding over the top-level function.
|
||||
namedImportsBindTopLevelOnly: true,
|
||||
|
||||
// NAMESPACE `importFrom()` names provided by a LOCAL package become
|
||||
// synthesised `named` imports (`resolveRImportTarget`'s named branch binds
|
||||
// them). Runs before finalize and over warm-cache ParsedFiles too.
|
||||
//
|
||||
// Followed by `populateRQualifiedCalls`: it decides each
|
||||
// `pkg::name()` site by the qualifier — drops external / ambiguous ones and
|
||||
// records the single definition a local one names for
|
||||
// `resolveQualifiedFreeCall` below. Import synthesis is unchanged and first.
|
||||
populateWorkspaceReferences: (parsedFiles, ctx) => {
|
||||
// The rename guard reads the NAMESPACE this pass was configured with (see `checkSafety`),
|
||||
// and looks it up by the ParsedFile objects it was handed. Both populate passes below
|
||||
// replace entries of `parsedFiles` with copies (import synthesis; any dropped `pkg::fn`
|
||||
// site), so the config is recorded against the objects as received, before any
|
||||
// replacement, and again against the final array.
|
||||
recordRPackageConfig(parsedFiles, ctx.resolutionConfig);
|
||||
populateRNamespaceImports(parsedFiles, ctx);
|
||||
populateRQualifiedCalls(parsedFiles, ctx);
|
||||
recordRPackageConfig(parsedFiles, ctx.resolutionConfig);
|
||||
},
|
||||
resolveQualifiedFreeCall: resolveRQualifiedFreeCall,
|
||||
|
||||
// `populateRNamespaceImports` and `populateRQualifiedCalls` read only ParsedFile scopes, never source
|
||||
// text: without this, merely declaring the hook makes the pipeline read the
|
||||
// text of every R file on the main thread.
|
||||
postExtractSourceTextPolicy: 'uncached-files',
|
||||
|
||||
// A bare call whose name the caller package's NAMESPACE imports from a
|
||||
// package other than the one the unique-name guess landed in is impossible
|
||||
// in R (see `isRGlobalNameFallbackPlausible` for the exact rule, the
|
||||
// qualifier-first handling of `pkg::name()` calls and the documented
|
||||
// `import()` limitation).
|
||||
isGlobalNameFallbackPlausible: isRGlobalNameFallbackPlausible,
|
||||
|
||||
// The free-call fallback keys defs by the text after the last `.` of their
|
||||
// qualified name, so an S3 method `print.foo` sits next to a bare `foo` and
|
||||
// makes the bare call ambiguous (no edge). Drop those candidates from the
|
||||
// name fallback; a call that spells the full dotted name binds by
|
||||
// qualified-name lookup instead (see `rIsCallableVisibleFromCaller`).
|
||||
isCallableVisibleFromCaller: rIsCallableVisibleFromCaller,
|
||||
};
|
||||
121
gitnexus/src/core/ingestion/languages/r/simple-hooks.ts
Normal file
121
gitnexus/src/core/ingestion/languages/r/simple-hooks.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import type { BindingRef, Callsite, Range, SymbolDefinition } from 'gitnexus-shared';
|
||||
|
||||
const TIER: Record<BindingRef['origin'], number> = {
|
||||
local: 0,
|
||||
namespace: 1,
|
||||
import: 2,
|
||||
reexport: 3,
|
||||
wildcard: 4,
|
||||
};
|
||||
|
||||
/** `a` before `b` by the line, then the column, at which each starts. */
|
||||
const startsBefore = (a: Range, b: Range): boolean =>
|
||||
a.startLine !== b.startLine ? a.startLine < b.startLine : a.startCol < b.startCol;
|
||||
|
||||
/** A package's binding of a name and the position of the `library()` call that first attached it. */
|
||||
interface Attachment {
|
||||
readonly ref: BindingRef;
|
||||
readonly at: Range;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a merge result remembers: every package that defines the name and where it was first
|
||||
* attached, including the ones that result masks. The pipeline folds the bindings of a name in
|
||||
* one at a time, so a package masked at one step would otherwise be forgotten, and a later
|
||||
* `library()` of it would look like a fresh attachment. Keyed by the surviving bindings (not by
|
||||
* the result array), so a copy of the array keeps it. A binding that has lost its entry is read as
|
||||
* if nothing were masked.
|
||||
*/
|
||||
const attachments = new WeakMap<BindingRef, ReadonlyMap<string, Attachment>>();
|
||||
|
||||
/**
|
||||
* Local declarations shadow imports; deterministic order within a tier.
|
||||
* Same shape as Zig's `zigMergeBindings`.
|
||||
*
|
||||
* Within the wildcard tier (`library()` / `require()` of a local package) a name that several
|
||||
* attached packages define is masked as R masks it: the package attached last wins, so only the
|
||||
* bindings of the `library()` call that comes last in the file are kept. A call that attaches a
|
||||
* package of several files keeps all of that package's bindings for the name, because a name
|
||||
* defined in two of its files is a real ambiguity the order cannot settle. The order is the
|
||||
* position of the call in the file, which is the order the file attaches them in when it runs
|
||||
* top to bottom; a `library()` inside a function, run later or never, is not told apart.
|
||||
*
|
||||
* A package counts at the call that attached it first: R leaves an attached package where it is,
|
||||
* so `library(a); library(b); library(a)` keeps `b` masking `a`. When any of the bindings has no
|
||||
* call position the whole tier is kept, as it was before.
|
||||
*/
|
||||
export function rMergeBindings(
|
||||
existing: readonly BindingRef[],
|
||||
incoming: readonly BindingRef[],
|
||||
_scopeId: string,
|
||||
): BindingRef[] {
|
||||
const all = [...existing, ...incoming];
|
||||
if (all.length === 0) return [];
|
||||
let bestTier = 99;
|
||||
for (const b of all) {
|
||||
const t = TIER[b.origin] ?? 99;
|
||||
if (t < bestTier) bestTier = t;
|
||||
}
|
||||
const tierRefs = all.filter((b) => (TIER[b.origin] ?? 99) === bestTier);
|
||||
const kept = bestTier === TIER.wildcard ? firstAttached(tierRefs) : firstPerNode(tierRefs);
|
||||
// By code unit, not `localeCompare`: the order must not depend on the machine's locale.
|
||||
return kept.sort((a, b) =>
|
||||
a.def.nodeId < b.def.nodeId ? -1 : a.def.nodeId > b.def.nodeId ? 1 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
/** One binding per node: the first one seen. */
|
||||
function firstPerNode(bindings: readonly BindingRef[]): BindingRef[] {
|
||||
const byNode = new Map<string, BindingRef>();
|
||||
for (const b of bindings) if (!byNode.has(b.def.nodeId)) byNode.set(b.def.nodeId, b);
|
||||
return [...byNode.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* The bindings that the `library()` call attached last brought in, a package counting at the call
|
||||
* that attached it first. The packages that earlier merges masked take part, from their record.
|
||||
*/
|
||||
function firstAttached(bindings: readonly BindingRef[]): BindingRef[] {
|
||||
const positioned: Array<{ ref: BindingRef; at: Range }> = [];
|
||||
for (const ref of bindings) {
|
||||
const at = ref.via?.atRange;
|
||||
// Without a call position on every binding the order is unknown: keep the whole tier.
|
||||
if (at === undefined) return firstPerNode(bindings);
|
||||
positioned.push({ ref, at });
|
||||
}
|
||||
const byNode = new Map<string, Attachment>();
|
||||
const offer = (a: Attachment): void => {
|
||||
const seen = byNode.get(a.ref.def.nodeId);
|
||||
if (seen === undefined || startsBefore(a.at, seen.at)) byNode.set(a.ref.def.nodeId, a);
|
||||
};
|
||||
for (const a of positioned) {
|
||||
for (const remembered of attachments.get(a.ref)?.values() ?? []) offer(remembered);
|
||||
offer(a);
|
||||
}
|
||||
let last: Range | undefined;
|
||||
for (const { at } of byNode.values()) if (last === undefined || startsBefore(last, at)) last = at;
|
||||
const lastAt = last;
|
||||
const kept = [...byNode.values()].filter(
|
||||
({ at }) =>
|
||||
lastAt !== undefined && at.startLine === lastAt.startLine && at.startCol === lastAt.startCol,
|
||||
);
|
||||
for (const { ref } of kept) attachments.set(ref, byNode);
|
||||
return kept.map(({ ref }) => ref);
|
||||
}
|
||||
|
||||
/** R declarations carry no synthesized arity metadata (no `...`/default-arg
|
||||
* capture exists yet), so the comparison is always 'unknown' — a real bounds
|
||||
* check that turns on if arity captures are added later. Same rationale as
|
||||
* Zig's `zigArityCompatibility`. */
|
||||
export function rArityCompatibility(
|
||||
callsite: Callsite,
|
||||
def: SymbolDefinition,
|
||||
): 'compatible' | 'unknown' | 'incompatible' {
|
||||
const max = def.parameterCount;
|
||||
const min = def.requiredParameterCount;
|
||||
if (max === undefined && min === undefined) return 'unknown';
|
||||
if (!Number.isFinite(callsite.arity) || callsite.arity! < 0) return 'unknown';
|
||||
if (min !== undefined && callsite.arity! < min) return 'incompatible';
|
||||
if (max !== undefined && callsite.arity! > max) return 'incompatible';
|
||||
return 'compatible';
|
||||
}
|
||||
129
gitnexus/src/core/ingestion/languages/r/unindexed-sources.ts
Normal file
129
gitnexus/src/core/ingestion/languages/r/unindexed-sources.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* R files of a repository that the index does not cover, for the rename guard.
|
||||
*
|
||||
* The indexer skips whole directories (`bin/`, `build/`, `tmp/`, ...), `.gitignore`d paths and
|
||||
* files over its size cap, and indexes only `.R`/`.r` files, so a script there that calls a
|
||||
* function would keep the old name after a rename. R itself still runs such files (`Rscript
|
||||
* bin/run.R`, `.Rprofile`, the `.S`/`.q`/`.s` code files R installs from `R/`). The rename preview
|
||||
* reloads its configuration (`refreshResolutionConfig`), which records each such file with the
|
||||
* identifier tokens it contains (tokens, not text, to keep the retained plan small); the guard
|
||||
* refuses a rename whose old name one of them uses, and the plan's resolution snapshot makes an
|
||||
* edit of such a file between preview and apply a `resolution_changed`.
|
||||
*/
|
||||
import type { Dirent } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import ignore, { type Ignore } from 'ignore';
|
||||
|
||||
export interface RUnindexedSource {
|
||||
/** POSIX path relative to the repository root. */
|
||||
readonly path: string;
|
||||
/** The distinct identifier tokens of the file, sorted (backticks removed). */
|
||||
readonly words: readonly string[];
|
||||
/** The file is over {@link MAX_UNINDEXED_R_SOURCE_BYTES} or could not be read. */
|
||||
readonly unreadable?: true;
|
||||
}
|
||||
|
||||
/** Larger files are not read; the guard then fails closed. */
|
||||
export const MAX_UNINDEXED_R_SOURCE_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/** A repository-relative path as graph File nodes spell it: forward slashes, no leading `./`. */
|
||||
export function rPosixRelativePath(filePath: string): string {
|
||||
let normalized = filePath.replace(/\\/g, '/');
|
||||
while (normalized.startsWith('./')) normalized = normalized.slice(2);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** `.R`/`.r` files, `.Rprofile`, and the `.S`/`.q`/`.s` code files under an `R/` directory. */
|
||||
export function isRSourcePath(relative: string): boolean {
|
||||
const base = relative.slice(relative.lastIndexOf('/') + 1);
|
||||
return (
|
||||
/\.[Rr]$/.test(base) || base === '.Rprofile' || /(^|\/)R\/(.*\/)?[^/]*\.[Sqs]$/.test(relative)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Directories that hold no code of this repository: version control, the index store, installed
|
||||
* packages (`node_modules`, `renv/library`, `packrat/lib*`) and `R CMD check` output (`*.Rcheck`,
|
||||
* which holds a copy of the package's own sources).
|
||||
*/
|
||||
function isSkippedDirectory(relative: string): boolean {
|
||||
const segments = relative.split('/');
|
||||
const name = segments[segments.length - 1];
|
||||
const parent = segments[segments.length - 2];
|
||||
return (
|
||||
name === '.git' ||
|
||||
name === '.gitnexus' ||
|
||||
name === 'node_modules' ||
|
||||
name.endsWith('.Rcheck') ||
|
||||
(parent === 'renv' && name === 'library') ||
|
||||
(parent === 'packrat' && name.startsWith('lib'))
|
||||
);
|
||||
}
|
||||
|
||||
/** Identifier tokens: plain names (`helper`, `.helper`, `to.json`) and backticked ones. */
|
||||
const TOKEN = /`([^`\n]+)`|[A-Za-z.][A-Za-z0-9._]*/g;
|
||||
|
||||
function tokens(text: string): string[] {
|
||||
const words = new Set<string>();
|
||||
for (const match of text.matchAll(TOKEN)) words.add(match[1] ?? match[0]);
|
||||
return [...words].sort();
|
||||
}
|
||||
|
||||
/** The repository's `.gitnexusignore` rules (the user's explicit "not mine"), if any. */
|
||||
async function gitnexusIgnore(repoRoot: string): Promise<Ignore | undefined> {
|
||||
try {
|
||||
const file = path.join(repoRoot, '.gitnexusignore');
|
||||
if (!(await fs.lstat(file)).isFile()) return undefined;
|
||||
return ignore().add(await fs.readFile(file, 'utf8'));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every regular R file under `repoRoot` that is not in `indexedPaths`, with its tokens. The walk
|
||||
* does not follow symbolic links, skips the directories of {@link isSkippedDirectory} and what
|
||||
* `.gitnexusignore` excludes, and deliberately ignores the indexer's built-in ignore list,
|
||||
* `.gitignore` and its size cap (those are what hide the files). Sorted by path.
|
||||
*/
|
||||
export async function collectRUnindexedSources(
|
||||
repoRoot: string,
|
||||
indexedPaths: readonly string[],
|
||||
): Promise<RUnindexedSource[]> {
|
||||
const indexed = new Set(indexedPaths.map(rPosixRelativePath));
|
||||
const ignored = await gitnexusIgnore(repoRoot);
|
||||
const result: RUnindexedSource[] = [];
|
||||
const pending: string[] = [''];
|
||||
for (let directory = pending.pop(); directory !== undefined; directory = pending.pop()) {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(path.join(repoRoot, directory), { withFileTypes: true });
|
||||
} catch {
|
||||
// It may hold R files: fail closed.
|
||||
result.push({ path: directory, words: [], unreadable: true });
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const relative = directory === '' ? entry.name : `${directory}/${entry.name}`;
|
||||
if (entry.isDirectory()) {
|
||||
if (isSkippedDirectory(relative) || ignored?.ignores(`${relative}/`)) continue;
|
||||
pending.push(relative);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !isRSourcePath(relative) || indexed.has(relative)) continue;
|
||||
if (ignored?.ignores(relative)) continue;
|
||||
const absolute = path.join(repoRoot, relative);
|
||||
try {
|
||||
if ((await fs.stat(absolute)).size > MAX_UNINDEXED_R_SOURCE_BYTES) {
|
||||
result.push({ path: relative, words: [], unreadable: true });
|
||||
continue;
|
||||
}
|
||||
result.push({ path: relative, words: tokens(await fs.readFile(absolute, 'utf8')) });
|
||||
} catch {
|
||||
result.push({ path: relative, words: [], unreadable: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
||||
}
|
||||
266
gitnexus/src/core/ingestion/method-extractors/r.ts
Normal file
266
gitnexus/src/core/ingestion/method-extractors/r.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
// gitnexus/src/core/ingestion/method-extractors/r.ts
|
||||
|
||||
/**
|
||||
* R method extractor — hand-written because R expresses classes and methods
|
||||
* as function calls rather than dedicated syntax.
|
||||
*
|
||||
* Handles:
|
||||
* - R6 methods: function entries in public/private/active list()
|
||||
* - S4 setMethod(): owner hints for method implementations dispatched on signature
|
||||
* - R5/setRefClass methods: methods = list(name = function(...) {...})
|
||||
*/
|
||||
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { isRLeftAssignment } from '../languages/r/assignment.js';
|
||||
import { getRMatchedArgument } from '../languages/r/naming-argument.js';
|
||||
import { getRClassNameArgument } from '../field-extractors/r.js';
|
||||
import type {
|
||||
MethodExtractor,
|
||||
MethodExtractorContext,
|
||||
ExtractedMethods,
|
||||
MethodInfo,
|
||||
MethodVisibility,
|
||||
ParameterInfo,
|
||||
} from '../method-types.js';
|
||||
|
||||
/** `setMethod`'s formals, in order (methods package). */
|
||||
const SET_METHOD_FORMALS: readonly string[] = [
|
||||
'f',
|
||||
'signature',
|
||||
'definition',
|
||||
'where',
|
||||
'valueClass',
|
||||
'sealed',
|
||||
];
|
||||
|
||||
export const rMethodExtractor: MethodExtractor = {
|
||||
language: SupportedLanguages.R,
|
||||
|
||||
isTypeDeclaration(node: SyntaxNode): boolean {
|
||||
return isR6Class(node) || isRefClass(node);
|
||||
},
|
||||
|
||||
extract(node: SyntaxNode, context: MethodExtractorContext): ExtractedMethods | null {
|
||||
if (isR6Class(node)) return extractR6Methods(node, context);
|
||||
if (isRefClass(node)) return extractRefClassMethods(node, context);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
/** Resolve the owning class name for a top-level R setMethod("foo", "Bar", ...) definition. */
|
||||
export function getRTopLevelMethodOwnerName(node: SyntaxNode): string | null {
|
||||
if (!isS4SetMethod(node)) return null;
|
||||
const args = node.childForFieldName('arguments');
|
||||
if (!args) return null;
|
||||
|
||||
// The owner is the `signature` argument, as R matches it (named first, then by position).
|
||||
const signature = getRMatchedArgument(args, SET_METHOD_FORMALS, 'signature');
|
||||
const value = signature?.childForFieldName('value') ?? null;
|
||||
return extractQuotedString(value) ?? extractFirstSignatureClass(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The class a `signature("A", "B")`, `signature(object = "A")` or `c("A", "B")` value dispatches
|
||||
* on first: the string of its first argument, named or not. A method on several classes (multiple
|
||||
* dispatch) belongs to the first one, so methods of one generic on different first classes keep
|
||||
* distinct ids and an owner; two methods that differ only in a later class share an id and the
|
||||
* first written wins. A first argument that is not a string (a variable, a call) names no owner.
|
||||
*/
|
||||
function extractFirstSignatureClass(value: SyntaxNode | null): string | null {
|
||||
if (!value || value.type !== 'call') return null;
|
||||
const fn = value.childForFieldName('function');
|
||||
const callee = fn?.type === 'namespace_operator' ? fn.childForFieldName('rhs') : fn;
|
||||
if (callee?.type !== 'identifier' || (callee.text !== 'signature' && callee.text !== 'c')) {
|
||||
return null;
|
||||
}
|
||||
const first = value
|
||||
.childForFieldName('arguments')
|
||||
?.namedChildren.find((c) => c.type === 'argument');
|
||||
return extractQuotedString(first?.childForFieldName('value') ?? null);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// R6 method extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractR6Methods(
|
||||
node: SyntaxNode,
|
||||
context: MethodExtractorContext,
|
||||
): ExtractedMethods | null {
|
||||
const ownerName = node.childForFieldName('lhs')?.text;
|
||||
if (!ownerName) return null;
|
||||
|
||||
const call = node.childForFieldName('rhs');
|
||||
if (!call) return null;
|
||||
|
||||
const args = call.childForFieldName('arguments');
|
||||
if (!args) return null;
|
||||
|
||||
const methods: MethodInfo[] = [];
|
||||
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const arg = args.namedChild(i);
|
||||
if (!arg || arg.type !== 'argument') continue;
|
||||
|
||||
const section = arg.childForFieldName('name')?.text;
|
||||
if (section !== 'public' && section !== 'private' && section !== 'active') continue;
|
||||
|
||||
const visibility: MethodVisibility = section === 'private' ? 'private' : 'public';
|
||||
|
||||
const listCall = arg.childForFieldName('value');
|
||||
if (!listCall || listCall.type !== 'call') continue;
|
||||
|
||||
const listArgs = listCall.childForFieldName('arguments');
|
||||
if (!listArgs) continue;
|
||||
|
||||
for (let j = 0; j < listArgs.namedChildCount; j++) {
|
||||
const entry = listArgs.namedChild(j);
|
||||
if (!entry || entry.type !== 'argument') continue;
|
||||
|
||||
const nameNode = entry.childForFieldName('name');
|
||||
const valueNode = entry.childForFieldName('value');
|
||||
if (!nameNode || valueNode?.type !== 'function_definition') continue;
|
||||
|
||||
methods.push({
|
||||
name: nameNode.text,
|
||||
receiverType: null,
|
||||
returnType: null,
|
||||
parameters: extractRParameters(valueNode),
|
||||
visibility,
|
||||
isStatic: false,
|
||||
isAbstract: false,
|
||||
isFinal: false,
|
||||
annotations: section === 'active' ? ['@active'] : [],
|
||||
sourceFile: context.filePath,
|
||||
line: entry.startPosition.row + 1,
|
||||
column: entry.startPosition.column,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return methods.length > 0 ? { ownerName, methods } : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S4 / R5 (setRefClass) method extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractRefClassMethods(
|
||||
node: SyntaxNode,
|
||||
context: MethodExtractorContext,
|
||||
): ExtractedMethods | null {
|
||||
const args = node.childForFieldName('arguments');
|
||||
if (!args) return null;
|
||||
|
||||
// The class name is the argument that takes the `Class` formal (by name, else the
|
||||
// first unnamed one), not the first string: `contains = "Base"` may precede it.
|
||||
const ownerName = getRClassNameArgument(args);
|
||||
if (!ownerName) return null;
|
||||
|
||||
const methods: MethodInfo[] = [];
|
||||
|
||||
// setRefClass(... methods = list(name = function(...) { ... }))
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const arg = args.namedChild(i);
|
||||
if (!arg || arg.type !== 'argument') continue;
|
||||
|
||||
const paramName = arg.childForFieldName('name')?.text;
|
||||
if (paramName !== 'methods') continue;
|
||||
|
||||
const listCall = arg.childForFieldName('value');
|
||||
if (!listCall || listCall.type !== 'call') continue;
|
||||
|
||||
const listArgs = listCall.childForFieldName('arguments');
|
||||
if (!listArgs) continue;
|
||||
|
||||
for (let j = 0; j < listArgs.namedChildCount; j++) {
|
||||
const entry = listArgs.namedChild(j);
|
||||
if (!entry || entry.type !== 'argument') continue;
|
||||
|
||||
const nameNode = entry.childForFieldName('name');
|
||||
const valueNode = entry.childForFieldName('value');
|
||||
if (!nameNode || valueNode?.type !== 'function_definition') continue;
|
||||
|
||||
methods.push({
|
||||
name: nameNode.text,
|
||||
receiverType: null,
|
||||
returnType: null,
|
||||
parameters: extractRParameters(valueNode),
|
||||
visibility: 'public',
|
||||
isStatic: false,
|
||||
isAbstract: false,
|
||||
isFinal: false,
|
||||
annotations: [],
|
||||
sourceFile: context.filePath,
|
||||
line: entry.startPosition.row + 1,
|
||||
column: entry.startPosition.column,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return methods.length > 0 ? { ownerName, methods } : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isR6Class(node: SyntaxNode): boolean {
|
||||
if (!isRLeftAssignment(node)) return false;
|
||||
const rhs = node.childForFieldName('rhs');
|
||||
if (!rhs || rhs.type !== 'call') return false;
|
||||
const fn = rhs.childForFieldName('function');
|
||||
if (!fn) return false;
|
||||
if (fn.type === 'identifier' && fn.text === 'R6Class') return true;
|
||||
if (fn.type === 'namespace_operator') {
|
||||
return fn.childForFieldName('rhs')?.text === 'R6Class';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isRefClass(node: SyntaxNode): boolean {
|
||||
if (node.type !== 'call') return false;
|
||||
const fn = node.childForFieldName('function');
|
||||
return fn?.type === 'identifier' && fn.text === 'setRefClass';
|
||||
}
|
||||
|
||||
function isS4SetMethod(node: SyntaxNode): boolean {
|
||||
if (node.type !== 'call') return false;
|
||||
const fn = node.childForFieldName('function');
|
||||
return fn?.type === 'identifier' && fn.text === 'setMethod';
|
||||
}
|
||||
|
||||
function extractQuotedString(node: SyntaxNode | null): string | null {
|
||||
if (!node || node.type !== 'string') return null;
|
||||
const content = node.namedChildren.find((c) => c.type === 'string_content');
|
||||
return content?.text ?? node.text.replace(/^["']|["']$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the formals of an R function_definition node. The grammar emits one
|
||||
* `parameter` node per formal (`name` is an `identifier`, or `dots` for `...`);
|
||||
* a formal with a `default` is optional, and `...` is variadic.
|
||||
*/
|
||||
function extractRParameters(fnNode: SyntaxNode): ParameterInfo[] {
|
||||
const params: ParameterInfo[] = [];
|
||||
const paramList = fnNode.childForFieldName('parameters');
|
||||
if (!paramList) return params;
|
||||
|
||||
for (let i = 0; i < paramList.namedChildCount; i++) {
|
||||
const param = paramList.namedChild(i);
|
||||
if (!param || param.type !== 'parameter') continue;
|
||||
|
||||
const nameNode = param.childForFieldName('name');
|
||||
if (!nameNode) continue;
|
||||
|
||||
params.push({
|
||||
name: nameNode.text,
|
||||
type: null,
|
||||
isOptional: param.childForFieldName('default') !== null,
|
||||
isVariadic: nameNode.type === 'dots',
|
||||
});
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
|
@ -63,6 +63,7 @@ import { readFileContents } from '../filesystem-walker.js';
|
|||
import {
|
||||
isLanguageAvailable,
|
||||
isGrammarRuntimeSkipped,
|
||||
grammarRebuildPackage,
|
||||
createParserForLanguage,
|
||||
} from '../../tree-sitter/parser-loader.js';
|
||||
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
|
||||
|
|
@ -108,7 +109,7 @@ import {
|
|||
import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
|
||||
import { resolveOperands } from '../route-extractors/python-const-resolver.js';
|
||||
import type { ModuleConstants } from '../route-extractors/constant-resolver.js';
|
||||
import { prepareRouteConstantsByProvider } from '../language-provider.js';
|
||||
import { prepareRouteConstantsByProvider, runPostParseHooks } from '../language-provider.js';
|
||||
import {
|
||||
resolveInheritedSpringRoutes,
|
||||
type SharedSpringType,
|
||||
|
|
@ -541,7 +542,7 @@ export async function runChunkedParseAndResolve(
|
|||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Skipping ${count} ${lang} file(s) — ${lang} parser not available (native binding may not have built). Try: npm rebuild tree-sitter-${lang}`,
|
||||
`Skipping ${count} ${lang} file(s) — ${lang} parser not available (native binding may not have built). Try: npm rebuild ${grammarRebuildPackage(lang)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1553,6 +1554,15 @@ export async function runChunkedParseAndResolve(
|
|||
}
|
||||
}
|
||||
|
||||
// Language post-parse hooks: whole-graph work a language can only do once every chunk is merged
|
||||
// and before scope resolution (e.g. deferred owner resolution). Only languages with parsed files pay.
|
||||
const presentLanguages = new Set<SupportedLanguages>();
|
||||
for (const file of parseableScanned) {
|
||||
const language = languageForScannedFile(file);
|
||||
if (language !== null) presentLanguages.add(language);
|
||||
}
|
||||
await runPostParseHooks(presentLanguages, getProvider, { graph, model, repoPath });
|
||||
|
||||
// Worker-path enrichment: if exportedTypeMap is empty (e.g. the worker pool
|
||||
// built TypeEnv inside workers without access to SymbolTable), reconstruct
|
||||
// the map from graph nodes + SymbolTable here in the main thread before
|
||||
|
|
|
|||
|
|
@ -1455,11 +1455,16 @@ export interface ScopeResolver {
|
|||
* risk LOW with 0 affected processes).
|
||||
*
|
||||
* `emitFreeCallFallback` invokes this BEFORE the implicit-`this` and
|
||||
* scope-chain lookups, so an explicit path outranks a lexical shadow.
|
||||
* scope-chain lookups, so an explicit path outranks a lexical shadow. That
|
||||
* includes a lexical NAME CLAIM (`lexicalNames`, a parameter or a local of the
|
||||
* same name): the contract left open whether such a claim blocks the call
|
||||
* before the hook runs, and it does not — a defined result for a site with
|
||||
* `rawQualifiedName` bypasses the block, because the written path names the
|
||||
* module and a local binding of the bare tail cannot be what it means.
|
||||
* Returning `undefined` (unqualified call, unknown module, or no such
|
||||
* member in the named module) falls through to the unchanged chain — the
|
||||
* hook is strictly additive and never removes an edge the prior tiers
|
||||
* would have produced.
|
||||
* member in the named module) falls through to the unchanged chain, name-claim
|
||||
* block included — the hook never removes an edge the prior tiers would have
|
||||
* produced.
|
||||
*
|
||||
* Languages whose qualified calls carry a value/type receiver (`x.foo()`,
|
||||
* `Type::foo()`) are served by the receiver-bound-calls pass and leave
|
||||
|
|
|
|||
|
|
@ -285,7 +285,14 @@ export function emitFreeCallFallback(
|
|||
b.def.type === 'Struct' ||
|
||||
b.def.type === 'Record',
|
||||
)));
|
||||
if (blocksCall) {
|
||||
// A module-qualified free call (`mod::fn()`) names its module explicitly, so the path is
|
||||
// evidence the lexical name claim is not: a local `fn` binds the bare tail, never `mod::fn`.
|
||||
// The hook therefore runs before the block, and a defined hit bypasses it.
|
||||
const qualifiedFnDef =
|
||||
options.resolveQualifiedFreeCall !== undefined && site.rawQualifiedName !== undefined
|
||||
? options.resolveQualifiedFreeCall(site, parsed, scopes, workspaceIndex, allFilePaths())
|
||||
: undefined;
|
||||
if (blocksCall && qualifiedFnDef === undefined) {
|
||||
options.recordSourceBinding?.(site, {
|
||||
status: 'blocked',
|
||||
targetDefIds: [],
|
||||
|
|
@ -421,20 +428,9 @@ export function emitFreeCallFallback(
|
|||
// the caller's own file and emits a self-loop instead of the real
|
||||
// cross-module edge (#2730). Only fires when the language populated
|
||||
// `rawQualifiedName` AND provides the hook; `undefined` falls through to
|
||||
// the unchanged chain, so this tier is strictly additive.
|
||||
if (
|
||||
fnDef === undefined &&
|
||||
options.resolveQualifiedFreeCall !== undefined &&
|
||||
site.rawQualifiedName !== undefined
|
||||
) {
|
||||
fnDef = options.resolveQualifiedFreeCall(
|
||||
site,
|
||||
parsed,
|
||||
scopes,
|
||||
workspaceIndex,
|
||||
allFilePaths(),
|
||||
);
|
||||
}
|
||||
// the unchanged chain, so this tier is strictly additive. Resolved above, before the
|
||||
// name-claim block, so that a hit also outranks a lexical name claim.
|
||||
if (fnDef === undefined) fnDef = qualifiedFnDef;
|
||||
// Implicit-this overload narrowing: an unqualified call inside
|
||||
// a method body might be calling a sibling overload on the
|
||||
// enclosing class. When the workspace has multiple methods of
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { dartScopeResolver } from '../../languages/dart/scope-resolver.js';
|
|||
import { vueScopeResolver } from '../../languages/vue/scope-resolver.js';
|
||||
import { zigScopeResolver } from '../../languages/zig/scope-resolver.js';
|
||||
import { objectiveCScopeResolver } from '../../languages/objective-c/scope-resolver.js';
|
||||
import { rScopeResolver } from '../../languages/r/scope-resolver.js';
|
||||
|
||||
/** Map of `SupportedLanguages` → `ScopeResolver`. The scope-resolution phase
|
||||
* iterates this map directly — every registered resolver runs. This is the
|
||||
|
|
@ -55,4 +56,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
|
|||
[SupportedLanguages.Vue, vueScopeResolver],
|
||||
[SupportedLanguages.Zig, zigScopeResolver],
|
||||
[SupportedLanguages.ObjectiveC, objectiveCScopeResolver],
|
||||
[SupportedLanguages.R, rScopeResolver],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -3064,6 +3064,232 @@ export const ZIG_QUERIES = `
|
|||
function: (field_expression
|
||||
member: (identifier) @call.name)) @call
|
||||
`;
|
||||
/**
|
||||
* Every named expression kind the R grammar allows as an argument value, except
|
||||
* `function_definition` (an R6 entry holding a function is a method, not a field).
|
||||
* Mirrors RFieldExtractor, which accepts any non-function value, so the query and
|
||||
* the extractor agree on what a field is.
|
||||
*/
|
||||
const R6_FIELD_VALUE = `[
|
||||
(binary_operator) (braced_expression) (break) (call) (complex) (dot_dot_i) (dots)
|
||||
(extract_operator) (false) (float) (for_statement) (identifier) (if_statement) (inf)
|
||||
(integer) (na) (namespace_operator) (nan) (next) (null) (parenthesized_expression)
|
||||
(repeat_statement) (return) (string) (subset) (subset2) (true) (unary_operator)
|
||||
(while_statement)
|
||||
]`;
|
||||
// Every R `binary_operator` anchor below is an assignment (`operator: ["<-" "<<-" "="]`, see
|
||||
// languages/r/assignment.ts): the grammar has one node for all infix operators.
|
||||
export const R_QUERIES = `
|
||||
; ── Functions (name <- function(...) or name = function(...)) ────────────────
|
||||
(binary_operator
|
||||
lhs: (identifier) @name
|
||||
operator: ["<-" "<<-" "="]
|
||||
rhs: (function_definition)) @definition.function
|
||||
|
||||
; ── S4 Classes (setClass("ClassName", ...)) ──────────────────────────────────
|
||||
; The S4 patterns below capture EVERY string argument of the call on purpose.
|
||||
; Which argument names the definition is decided by the provider's
|
||||
; \`shouldSkipDefinitionCapture\` (languages/r/naming-argument.ts): a query cannot skip an
|
||||
; unbounded run of leading comments and named arguments without a repeated
|
||||
; sibling group, which is quadratic in the argument count.
|
||||
(call
|
||||
function: (identifier) @_fn
|
||||
(#match? @_fn "^(setClass|setRefClass)$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
value: (string
|
||||
content: (string_content) @name)))) @definition.class
|
||||
|
||||
; ── R6 Classes via namespace (ClassName <- R6::R6Class(...)) ─────────────────
|
||||
(binary_operator
|
||||
lhs: (identifier) @name
|
||||
operator: ["<-" "<<-" "="]
|
||||
rhs: (call
|
||||
function: (namespace_operator
|
||||
rhs: (identifier) @_nsfn
|
||||
(#match? @_nsfn "^R6Class$")))) @definition.class
|
||||
|
||||
; ── R6 Classes via bare call (ClassName <- R6Class(...)) ─────────────────────
|
||||
(binary_operator
|
||||
lhs: (identifier) @name
|
||||
operator: ["<-" "<<-" "="]
|
||||
rhs: (call
|
||||
function: (identifier) @_r6fn
|
||||
(#match? @_r6fn "^R6Class$"))) @definition.class
|
||||
|
||||
; ── R6 Methods via namespace (inside public/private/active = list(...)) ─────
|
||||
(binary_operator
|
||||
lhs: (identifier) @_class
|
||||
operator: ["<-" "<<-" "="]
|
||||
rhs: (call
|
||||
function: (namespace_operator
|
||||
rhs: (identifier) @_nsfn2
|
||||
(#match? @_nsfn2 "^R6Class$"))
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_section
|
||||
(#match? @_section "^(public|private|active)$")
|
||||
value: (call
|
||||
function: (identifier) @_listfn
|
||||
(#match? @_listfn "^list$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @name
|
||||
value: (function_definition)) @definition.method))))))
|
||||
|
||||
; ── R6 Methods via bare call (inside public/private/active = list(...)) ─────
|
||||
(binary_operator
|
||||
lhs: (identifier) @_class2
|
||||
operator: ["<-" "<<-" "="]
|
||||
rhs: (call
|
||||
function: (identifier) @_r6fn2
|
||||
(#match? @_r6fn2 "^R6Class$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_section2
|
||||
(#match? @_section2 "^(public|private|active)$")
|
||||
value: (call
|
||||
function: (identifier) @_listfn2
|
||||
(#match? @_listfn2 "^list$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @name
|
||||
value: (function_definition)) @definition.method))))))
|
||||
|
||||
; ── R5 Methods (inside setRefClass(... methods = list(...))) ─────────────────
|
||||
; The call itself, assigned or not: \`Person <- setRefClass(...)\` and a bare
|
||||
; \`setRefClass(...)\` define the same methods.
|
||||
(call
|
||||
function: (identifier) @_refFn
|
||||
(#match? @_refFn "^setRefClass$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_methods
|
||||
(#match? @_methods "^methods$")
|
||||
value: (call
|
||||
function: (identifier) @_listfn3
|
||||
(#match? @_listfn3 "^list$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @name
|
||||
value: (function_definition)) @definition.method)))))
|
||||
|
||||
; ── S4 setGeneric() (generic function declaration) ───────────────────────────
|
||||
(call
|
||||
function: (identifier) @_fn
|
||||
(#match? @_fn "^setGeneric$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
value: (string
|
||||
content: (string_content) @name)))) @definition.function
|
||||
|
||||
; ── S4 setMethod() (method implementation for a class) ──────────────────────
|
||||
(call
|
||||
function: (identifier) @_fn
|
||||
(#match? @_fn "^setMethod$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
value: (string
|
||||
content: (string_content) @name)))) @definition.method
|
||||
|
||||
; ── All function calls ───────────────────────────────────────────────────────
|
||||
(call
|
||||
function: (identifier) @call.name) @call
|
||||
|
||||
; ── Namespaced calls (pkg::func()) ───────────────────────────────────────────
|
||||
(call
|
||||
function: (namespace_operator
|
||||
lhs: (identifier) @call.namespace
|
||||
rhs: (identifier) @call.name)) @call
|
||||
|
||||
; ── Member calls via $ (obj$method()) ───────────────────────────────────────
|
||||
(call
|
||||
function: (extract_operator
|
||||
rhs: (identifier) @call.name)) @call
|
||||
|
||||
; ── Imports (library/require/source) ─────────────────────────────────────────
|
||||
(call
|
||||
function: (identifier) @_fn
|
||||
(#match? @_fn "^(library|require|source)$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
value: [(identifier) (string)] @import.source))) @import
|
||||
|
||||
; ── R6 Fields via namespace (non-function entries in public/private list()) ──
|
||||
(binary_operator
|
||||
lhs: (identifier) @_class
|
||||
operator: ["<-" "<<-" "="]
|
||||
rhs: (call
|
||||
function: (namespace_operator
|
||||
rhs: (identifier) @_nsfn
|
||||
(#match? @_nsfn "^R6Class$"))
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_section
|
||||
(#match? @_section "^(public|private)$")
|
||||
value: (call
|
||||
function: (identifier) @_listfn
|
||||
(#match? @_listfn "^list$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @name
|
||||
value: ${R6_FIELD_VALUE} @_val) @definition.property))))))
|
||||
|
||||
; ── R6 Fields via bare call ──────────────────────────────────────────────────
|
||||
(binary_operator
|
||||
lhs: (identifier) @_class2
|
||||
operator: ["<-" "<<-" "="]
|
||||
rhs: (call
|
||||
function: (identifier) @_r6fn
|
||||
(#match? @_r6fn "^R6Class$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_section2
|
||||
(#match? @_section2 "^(public|private)$")
|
||||
value: (call
|
||||
function: (identifier) @_listfn2
|
||||
(#match? @_listfn2 "^list$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @name
|
||||
value: ${R6_FIELD_VALUE} @_val2) @definition.property))))))
|
||||
|
||||
; ── S4 slots / representation ────────────────────────────────────────────────
|
||||
(call
|
||||
function: (identifier) @_fn
|
||||
(#match? @_fn "^(setClass|setRefClass)$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_slotArg
|
||||
(#match? @_slotArg "^(representation|slots|fields)$")
|
||||
value: (call
|
||||
function: (identifier) @_listfn
|
||||
(#match? @_listfn "^(list|representation|c)$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @name) @definition.property)))))
|
||||
|
||||
; ── S4 / RefClass unnamed names: slots = c("a", "b"), fields = c("a", "b") ──
|
||||
; An unnamed character vector names the slots or fields (their class is ANY). Only
|
||||
; c(...) reads this way: an unnamed string in representation(...) is a superclass.
|
||||
; The callee is captured as @_vecOwner, not @_fn, so these string captures are not
|
||||
; mistaken for the class-naming argument by isRNonNamingArgumentCapture.
|
||||
(call
|
||||
function: (identifier) @_vecOwner
|
||||
(#match? @_vecOwner "^(setClass|setRefClass)$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_vecArg
|
||||
(#match? @_vecArg "^(slots|fields)$")
|
||||
value: (call
|
||||
function: (identifier) @_vecfn
|
||||
(#match? @_vecfn "^c$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
!name
|
||||
value: (string
|
||||
content: (string_content) @name)) @definition.property)))))
|
||||
`;
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
|
||||
|
|
@ -3086,6 +3312,7 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
|
|||
[SupportedLanguages.Swift]: SWIFT_QUERIES,
|
||||
[SupportedLanguages.Dart]: DART_QUERIES,
|
||||
[SupportedLanguages.Vue]: TYPESCRIPT_QUERIES, // Vue <script> blocks are parsed as TypeScript
|
||||
[SupportedLanguages.R]: R_QUERIES,
|
||||
[SupportedLanguages.Cobol]: '', // Standalone regex processor — no tree-sitter queries
|
||||
[SupportedLanguages.Zig]: ZIG_QUERIES,
|
||||
};
|
||||
|
|
|
|||
298
gitnexus/src/core/ingestion/type-extractors/r.ts
Normal file
298
gitnexus/src/core/ingestion/type-extractors/r.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
import type {
|
||||
LanguageTypeConfig,
|
||||
ParameterExtractor,
|
||||
TypeBindingExtractor,
|
||||
InitializerExtractor,
|
||||
ConstructorBindingScanner,
|
||||
} from './types.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { getRClassNameArgument } from '../field-extractors/r.js';
|
||||
import { isRLeftAssignment } from '../languages/r/assignment.js';
|
||||
import { rBareName } from '../languages/r/bare-name.js';
|
||||
|
||||
/**
|
||||
* R type extractor — roxygen2 annotation parsing.
|
||||
*
|
||||
* R has no static type system and roxygen2 has no type syntax: a `@param`
|
||||
* description is free prose. A parameter is therefore typed from a roxygen
|
||||
* comment only when the type is stated in an anchored form AND names a class
|
||||
* the file defines:
|
||||
*
|
||||
* #' @param repo UserRepo (the description is the type alone)
|
||||
* #' @param backup {UserRepo} a spare (the type in braces, prose after it)
|
||||
* save <- function(repo, backup) { ... }
|
||||
*
|
||||
* Prose that merely starts with a capitalised word (`@param df Data frame of
|
||||
* observations`) binds nothing, even when a class called `Data` exists, and a
|
||||
* description may run over several lines: it is read up to the next tag.
|
||||
* The type is bound for a formal of the documented function, inside that
|
||||
* function only.
|
||||
*
|
||||
* Resolution tiers:
|
||||
* - Tier 0: roxygen2 @param annotations (extractParameter binds each documented formal)
|
||||
* - Tier 1: Constructor inference via `obj <- ClassName$new()` (R6) or `obj <- new("ClassName")` (S4)
|
||||
*/
|
||||
|
||||
/** A `@param` tag: the parameter name and the description text that follows it. */
|
||||
interface RoxygenParam {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
/** The whole description is a single type word: `UserRepo`. */
|
||||
const BARE_TYPE_RE = /^([A-Z][\w.]*)$/;
|
||||
|
||||
/** The description starts with a braced type: `{UserRepo} the repository`. */
|
||||
const BRACED_TYPE_RE = /^\{([A-Z][\w.]*)\}(?:\s|$)/;
|
||||
|
||||
/**
|
||||
* Walk backwards through preceding sibling nodes collecting consecutive
|
||||
* roxygen2 comment lines (`#'`). Returns the joined comment block text.
|
||||
*/
|
||||
const collectRoxygenBlock = (node: SyntaxNode): string => {
|
||||
const commentTexts: string[] = [];
|
||||
let sibling = node.previousSibling;
|
||||
while (sibling) {
|
||||
if (sibling.type === 'comment' && sibling.text.startsWith("#'")) {
|
||||
commentTexts.unshift(sibling.text);
|
||||
} else if (sibling.type === 'comment') {
|
||||
// Regular comment (not roxygen2) — skip, don't break
|
||||
} else if (sibling.isNamed) {
|
||||
break;
|
||||
}
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
return commentTexts.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Split a roxygen block into its `@param` tags. A tag's description is the
|
||||
* rest of its line plus every following line up to the next `@tag`, joined by
|
||||
* single spaces. A tag whose name is not one identifier (`x,y`, `...`) is skipped,
|
||||
* and its continuation lines belong to no tag. Dots are part of an R name
|
||||
* (`na.rm`, `.data`).
|
||||
*/
|
||||
const parseRoxygenParams = (block: string): RoxygenParam[] => {
|
||||
const params: RoxygenParam[] = [];
|
||||
let name: string | undefined;
|
||||
let parts: string[] = [];
|
||||
const flush = (): void => {
|
||||
if (name !== undefined) params.push({ name, description: parts.join(' ').trim() });
|
||||
name = undefined;
|
||||
parts = [];
|
||||
};
|
||||
|
||||
for (const rawLine of block.split('\n')) {
|
||||
const line = rawLine.replace(/^#'[ \t]*/, '').trim();
|
||||
const tag = /^@(\w+)(?:[ \t]+(.*))?$/.exec(line);
|
||||
if (tag) {
|
||||
flush();
|
||||
if (tag[1] !== 'param') continue;
|
||||
// `[\w.]+` then a word-character check, not `[\w.]*\w[\w.]*`, which backtracks
|
||||
// quadratically on a long name that is followed by something else.
|
||||
const head = /^([\w.]+)(?:[ \t]+(.*))?$/.exec(tag[2] ?? '');
|
||||
if (!head || !/\w/.test(head[1])) continue;
|
||||
name = head[1];
|
||||
if (head[2]) parts.push(head[2]);
|
||||
} else if (name !== undefined && line.length > 0) {
|
||||
parts.push(line);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return params;
|
||||
};
|
||||
|
||||
/** The type a `@param` description states in an anchored form, if any. */
|
||||
const anchoredType = (description: string): string | undefined =>
|
||||
(BARE_TYPE_RE.exec(description) ?? BRACED_TYPE_RE.exec(description))?.[1];
|
||||
|
||||
/**
|
||||
* The class a node declares, in the shapes `query.ts` captures as
|
||||
* `@declaration.class`: `X <- R6Class(...)` / `X <- R6::R6Class(...)`, and
|
||||
* `setClass("X", ...)` / `setRefClass("X", ...)`.
|
||||
*/
|
||||
const declaredClassName = (node: SyntaxNode): string | undefined => {
|
||||
if (node.type === 'binary_operator') {
|
||||
if (!isRLeftAssignment(node)) return undefined;
|
||||
const lhs = node.childForFieldName('lhs');
|
||||
const rhs = node.childForFieldName('rhs');
|
||||
if (lhs?.type !== 'identifier' || rhs?.type !== 'call') return undefined;
|
||||
const fn = rhs.childForFieldName('function');
|
||||
const calleeName =
|
||||
fn?.type === 'namespace_operator' ? fn.childForFieldName('rhs')?.text : fn?.text;
|
||||
// A backticked name is the same class, as for a formal (see extractParameter).
|
||||
return calleeName === 'R6Class' ? rBareName(lhs.text) : undefined;
|
||||
}
|
||||
if (node.type !== 'call') return undefined;
|
||||
const fn = node.childForFieldName('function');
|
||||
if (fn?.type !== 'identifier' || (fn.text !== 'setClass' && fn.text !== 'setRefClass')) {
|
||||
return undefined;
|
||||
}
|
||||
// The class name is the argument that takes the `Class` formal, by name or else
|
||||
// the first unnamed one; `contains = "Base"` ahead of `Class = "A"` is not it.
|
||||
const args = node.childForFieldName('arguments');
|
||||
return (args && getRClassNameArgument(args)) ?? undefined;
|
||||
};
|
||||
|
||||
/** Class names defined anywhere in the file, built on first use and kept per tree. */
|
||||
const localClassNamesByTree = new WeakMap<object, ReadonlySet<string>>();
|
||||
const localClassNames = (node: SyntaxNode): ReadonlySet<string> => {
|
||||
const cached = localClassNamesByTree.get(node.tree);
|
||||
if (cached) return cached;
|
||||
const names = new Set<string>();
|
||||
for (const candidate of node.tree.rootNode.descendantsOfType(['binary_operator', 'call'])) {
|
||||
const name = declaredClassName(candidate);
|
||||
if (name !== undefined) names.add(name);
|
||||
}
|
||||
localClassNamesByTree.set(node.tree, names);
|
||||
return names;
|
||||
};
|
||||
|
||||
/**
|
||||
* The roxygen2 `@param` types documented for an assigned function: the
|
||||
* comment block directly above `name <- function(...)`, keyed by parameter
|
||||
* name. Returns a map of paramName → typeName.
|
||||
*/
|
||||
const collectRoxygenParams = (assignment: SyntaxNode): ReadonlyMap<string, string> => {
|
||||
const params = new Map<string, string>();
|
||||
for (const { name, description } of parseRoxygenParams(collectRoxygenBlock(assignment))) {
|
||||
const typeName = anchoredType(description);
|
||||
if (typeName !== undefined && localClassNames(assignment).has(typeName)) {
|
||||
params.set(name, typeName);
|
||||
}
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
/**
|
||||
* {@link collectRoxygenParams} per assignment, kept per tree. `extractParameter` asks once per
|
||||
* formal, and each answer reads the whole comment block above the function: without this, a
|
||||
* function of N formals under a block of M lines costs N x M (2,000 x 2,000 took 11.9 s).
|
||||
*/
|
||||
const roxygenParamsByTree = new WeakMap<object, Map<number, ReadonlyMap<string, string>>>();
|
||||
const roxygenParamsOf = (assignment: SyntaxNode): ReadonlyMap<string, string> => {
|
||||
let byAssignment = roxygenParamsByTree.get(assignment.tree);
|
||||
if (byAssignment === undefined) {
|
||||
byAssignment = new Map();
|
||||
roxygenParamsByTree.set(assignment.tree, byAssignment);
|
||||
}
|
||||
let params = byAssignment.get(assignment.id);
|
||||
if (params === undefined) {
|
||||
params = collectRoxygenParams(assignment);
|
||||
byAssignment.set(assignment.id, params);
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
/**
|
||||
* R node types that may carry type bindings.
|
||||
* - `binary_operator`: function definitions use `name <- function(...)` which
|
||||
* tree-sitter-r parses as binary_operator nodes. Also used for constructor
|
||||
* assignments like `obj <- ClassName$new()`.
|
||||
*/
|
||||
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set(['binary_operator']);
|
||||
|
||||
/**
|
||||
* R declaration extraction. Roxygen types are bound per formal by
|
||||
* `extractParameter`, so a function definition binds nothing here: this
|
||||
* hook runs in the scope that CONTAINS the assignment (the file, for a
|
||||
* top-level function), and a type bound there would be visible in every
|
||||
* function of the file.
|
||||
*/
|
||||
const extractDeclaration: TypeBindingExtractor = (
|
||||
_node: SyntaxNode,
|
||||
_env: Map<string, string>,
|
||||
): void => {
|
||||
// Constructor bindings are made by extractInitializer; roxygen types by extractParameter.
|
||||
};
|
||||
|
||||
/**
|
||||
* R parameter extraction: the roxygen2 `@param` type of a formal.
|
||||
*
|
||||
* Runs once per formal with the env of the function that owns it, so the
|
||||
* binding is visible inside that function only. The roxygen block is the one
|
||||
* above the assignment `name <- function(...)` the formal belongs to; a name
|
||||
* the block documents but the function does not have is never visited, and a
|
||||
* function that is not the right-hand side of an assignment (a callback)
|
||||
* is documented by nothing. The `...` formal is not an identifier and is
|
||||
* skipped.
|
||||
*/
|
||||
const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
|
||||
if (node.type !== 'parameter') return;
|
||||
const name = node.childForFieldName('name');
|
||||
if (name?.type !== 'identifier') return;
|
||||
|
||||
const fn = node.parent?.parent;
|
||||
if (fn?.type !== 'function_definition') return;
|
||||
const assignment = fn.parent;
|
||||
if (!assignment || !isRLeftAssignment(assignment)) return;
|
||||
const rhs = assignment.childForFieldName('rhs');
|
||||
if (rhs?.startIndex !== fn.startIndex || rhs.endIndex !== fn.endIndex) return;
|
||||
|
||||
// The tag names the symbol (`@param x`); a formal written `x` in backticks is the same one.
|
||||
const typeName = roxygenParamsOf(assignment).get(rBareName(name.text));
|
||||
if (typeName !== undefined) env.set(name.text, typeName);
|
||||
};
|
||||
|
||||
/**
|
||||
* R constructor inference:
|
||||
* - R6: `obj <- ClassName$new(...)` → type is ClassName
|
||||
* - S4: `obj <- new("ClassName", ...)` → type is ClassName
|
||||
*
|
||||
* Resolves against locally-known class names.
|
||||
*/
|
||||
const extractInitializer: InitializerExtractor = (node, env, classNames): void => {
|
||||
const result = scanConstructorBinding(node);
|
||||
if (!result) return;
|
||||
if (env.has(result.varName)) return;
|
||||
if (classNames.has(result.calleeName)) {
|
||||
env.set(result.varName, result.calleeName);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* R constructor binding scanner: captures both R6 `obj <- ClassName$new()`
|
||||
* and S4 `obj <- new("ClassName", ...)` patterns.
|
||||
*/
|
||||
const scanConstructorBinding: ConstructorBindingScanner = (node) => {
|
||||
if (!isRLeftAssignment(node)) return undefined;
|
||||
const lhs = node.childForFieldName('lhs');
|
||||
const rhs = node.childForFieldName('rhs');
|
||||
if (!lhs || !rhs) return undefined;
|
||||
if (lhs.type !== 'identifier') return undefined;
|
||||
if (rhs.type !== 'call') return undefined;
|
||||
|
||||
const fn = rhs.childForFieldName('function');
|
||||
if (!fn) return undefined;
|
||||
|
||||
// R6 pattern: obj <- ClassName$new(...)
|
||||
// tree-sitter-r parses `ClassName$new` as an `extract_operator` node
|
||||
if (fn.type === 'extract_operator') {
|
||||
const children = fn.namedChildren;
|
||||
if (children.length >= 2) {
|
||||
const className = children[0];
|
||||
const method = children[1];
|
||||
if (className?.type === 'identifier' && method?.text === 'new') {
|
||||
return { varName: lhs.text, calleeName: rBareName(className.text) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// S4 pattern: obj <- new("ClassName", ...)
|
||||
if (fn.type === 'identifier' && fn.text === 'new') {
|
||||
// `new(Class, ...)`: the class is the argument that takes the `Class` formal.
|
||||
const args = rhs.childForFieldName('arguments');
|
||||
const className = args && getRClassNameArgument(args);
|
||||
if (className) return { varName: lhs.text, calleeName: className };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const typeConfig: LanguageTypeConfig = {
|
||||
declarationNodeTypes: DECLARATION_NODE_TYPES,
|
||||
extractDeclaration,
|
||||
extractParameter,
|
||||
extractInitializer,
|
||||
scanConstructorBinding,
|
||||
};
|
||||
|
|
@ -68,7 +68,7 @@ const CSharp = getLanguageGrammar(SupportedLanguages.CSharp);
|
|||
// `isLanguageAvailable` must re-introduce the gate here. (The cleaner end-state
|
||||
// — routing this table through `parser-loader.getLanguageGrammar` so there is
|
||||
// one loader — is the deferred Tier-1 consolidation.)
|
||||
// Swift/Dart/Kotlin/C/Zig are vendored grammars loaded from `vendor/` by absolute
|
||||
// Swift/Dart/Kotlin/C/Zig/ObjC are vendored grammars loaded from `vendor/` by absolute
|
||||
// path (NEVER copied into node_modules — see vendored-grammars.ts / #2111). Each
|
||||
// may be absent on a platform without a prebuild or a toolchain-less /
|
||||
// `--ignore-scripts` install, so every load is guarded so a missing binding
|
||||
|
|
@ -98,6 +98,16 @@ try {
|
|||
Zig = requireVendoredGrammar('tree-sitter-zig') as TreeSitterLanguage;
|
||||
} catch {}
|
||||
|
||||
// R is an npm dependency, not a vendored grammar, but its native binding can fail to load in the
|
||||
// same ways (no prebuild for the platform, a corrupt or ABI-mismatched install). A static import
|
||||
// would take the whole worker down at module load and with it the parse of every language, so it
|
||||
// goes through the shared loader behind the same guard: a failure leaves R out of `languageMap`
|
||||
// and the main thread's `isLanguageAvailable` filter skips R files.
|
||||
let R: TreeSitterLanguage | null = null;
|
||||
try {
|
||||
R = getLanguageGrammar(SupportedLanguages.R) as TreeSitterLanguage;
|
||||
} catch {}
|
||||
|
||||
let ObjectiveC: TreeSitterLanguage | null = null;
|
||||
try {
|
||||
ObjectiveC = requireVendoredGrammar('tree-sitter-objc') as TreeSitterLanguage;
|
||||
|
|
@ -632,6 +642,7 @@ const languageMap: Record<string, TreeSitterLanguage> = {
|
|||
...(Kotlin ? { [SupportedLanguages.Kotlin]: Kotlin } : {}),
|
||||
[SupportedLanguages.PHP]: PHP.php_only,
|
||||
[SupportedLanguages.Ruby]: Ruby,
|
||||
...(R ? { [SupportedLanguages.R]: R } : {}),
|
||||
[SupportedLanguages.Vue]: TypeScript.typescript,
|
||||
...(Dart ? { [SupportedLanguages.Dart]: Dart } : {}),
|
||||
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
|
||||
|
|
@ -712,7 +723,9 @@ function findEnclosingClassNode(node: SyntaxNode): SyntaxNode | null {
|
|||
* a type (`resolveFileTypeOwner`, e.g. a Zig file-struct), the tree root is
|
||||
* the owner node the method/field extractors should read members from. Same
|
||||
* root, same name as `findEnclosingClassInfo`'s root branch, so member ids and
|
||||
* owner ids agree.
|
||||
* owner ids agree. Failing both, the provider's `resolveMemberOwnerNode` gets
|
||||
* the last word (languages whose members belong to a call/assignment rather
|
||||
* than an enclosing container node, e.g. R's `R6Class(...)`).
|
||||
*/
|
||||
function findEnclosingClassNodeOrFileOwner(
|
||||
node: SyntaxNode,
|
||||
|
|
@ -721,10 +734,12 @@ function findEnclosingClassNodeOrFileOwner(
|
|||
): SyntaxNode | null {
|
||||
const container = findEnclosingClassNode(node);
|
||||
if (container !== null) return container;
|
||||
if (provider.resolveFileTypeOwner === undefined) return null;
|
||||
let root: SyntaxNode = node;
|
||||
while (root.parent) root = root.parent;
|
||||
return provider.resolveFileTypeOwner(root, filePath) !== null ? root : null;
|
||||
if (provider.resolveFileTypeOwner !== undefined) {
|
||||
let root: SyntaxNode = node;
|
||||
while (root.parent) root = root.parent;
|
||||
if (provider.resolveFileTypeOwner(root, filePath) !== null) return root;
|
||||
}
|
||||
return provider.resolveMemberOwnerNode?.(node) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -191,6 +191,14 @@ export const GRAMMAR_SOURCES: Record<string, GrammarSource> = {
|
|||
unavailableNote:
|
||||
'Ruby parsing requires `tree-sitter-ruby`. Check the install and native binding.',
|
||||
},
|
||||
[SupportedLanguages.R]: {
|
||||
packageName: '@eagleoutice/tree-sitter-r',
|
||||
load() {
|
||||
return _require(this.packageName);
|
||||
},
|
||||
unavailableNote:
|
||||
'R parsing requires `@eagleoutice/tree-sitter-r`. Check the install and native binding.',
|
||||
},
|
||||
[SupportedLanguages.Vue]: {
|
||||
packageName: 'tree-sitter-typescript',
|
||||
load() {
|
||||
|
|
|
|||
|
|
@ -172,6 +172,16 @@ export const isGrammarRuntimeSkipped = (
|
|||
return source !== undefined && isRuntimeSkippedGrammar(key, source);
|
||||
};
|
||||
|
||||
// Grammars published under a name other than `tree-sitter-<language>`; every other language
|
||||
// keeps that default in the `npm rebuild` hint.
|
||||
const REBUILD_PACKAGES: ReadonlyMap<string, string> = new Map([
|
||||
[SupportedLanguages.R, '@eagleoutice/tree-sitter-r'],
|
||||
]);
|
||||
|
||||
/** The npm package to name in the `npm rebuild` hint for `language`'s unavailable grammar. */
|
||||
export const grammarRebuildPackage = (language: string): string =>
|
||||
REBUILD_PACKAGES.get(language) ?? `tree-sitter-${language}`;
|
||||
|
||||
export const getLanguageGrammar = (
|
||||
language: SupportedLanguages,
|
||||
filePath?: string,
|
||||
|
|
|
|||
|
|
@ -7300,7 +7300,8 @@ export class LocalBackend {
|
|||
`MATCH (n) WHERE n.filePath IS NOT NULL AND n.filePath <> ''
|
||||
AND labels(n) <> 'BasicBlock'
|
||||
RETURN n.id AS id, n.name AS name, n.filePath AS filePath,
|
||||
n.startLine AS startLine, n.endLine AS endLine`,
|
||||
n.startLine AS startLine, n.endLine AS endLine,
|
||||
n.isExported AS isExported`,
|
||||
);
|
||||
const graphNodes: GraphNode[] = rows.map((row: any) => ({
|
||||
id: row.id,
|
||||
|
|
@ -7311,6 +7312,9 @@ export class LocalBackend {
|
|||
filePath: row.filePath,
|
||||
startLine: row.startLine ?? undefined,
|
||||
endLine: row.endLine ?? undefined,
|
||||
// The indexed export verdict, read by providers whose refinement lives outside the
|
||||
// indexed sources (R rename's second privacy check). Absent on tables without the column.
|
||||
...(typeof row.isExported === 'boolean' ? { isExported: row.isExported } : {}),
|
||||
},
|
||||
}));
|
||||
const referenceRows = await executeParameterized(
|
||||
|
|
|
|||
1
gitnexus/test/fixtures/lang-resolution/qualified-over-local-php/composer.json
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/qualified-over-local-php/composer.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "autoload": { "psr-4": { "App\\": "src/" }, "files": ["lib/functions.php"] } }
|
||||
7
gitnexus/test/fixtures/lang-resolution/qualified-over-local-php/lib/functions.php
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/qualified-over-local-php/lib/functions.php
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
namespace Lib;
|
||||
|
||||
function make(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
21
gitnexus/test/fixtures/lang-resolution/qualified-over-local-php/src/Caller.php
vendored
Normal file
21
gitnexus/test/fixtures/lang-resolution/qualified-over-local-php/src/Caller.php
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
namespace App;
|
||||
|
||||
use function Ext\make;
|
||||
|
||||
function make(): int
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
// The qualified callee names `Lib\make`; neither the unresolved `use function Ext\make` nor the
|
||||
// namespace's own `make` is what it means.
|
||||
function qualified(): int
|
||||
{
|
||||
return \Lib\make();
|
||||
}
|
||||
|
||||
function control(): int
|
||||
{
|
||||
return make();
|
||||
}
|
||||
4
gitnexus/test/fixtures/lang-resolution/qualified-over-local-rust/Cargo.toml
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/qualified-over-local-rust/Cargo.toml
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[package]
|
||||
name = "qualified_over_local"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
28
gitnexus/test/fixtures/lang-resolution/qualified-over-local-rust/src/main.rs
vendored
Normal file
28
gitnexus/test/fixtures/lang-resolution/qualified-over-local-rust/src/main.rs
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
mod tools;
|
||||
|
||||
use tools::ToolCtx;
|
||||
|
||||
// A local binding of the same name as the module's function: the path names the module,
|
||||
// so `tools::dispatch` is the module's function, not the closure.
|
||||
fn with_let(ctx: &ToolCtx, name: &str) -> usize {
|
||||
let dispatch = |n: usize| n + 1;
|
||||
let _bumped = dispatch(1);
|
||||
tools::dispatch(ctx, name)
|
||||
}
|
||||
|
||||
// A parameter of the same name.
|
||||
fn with_parameter(ctx: &ToolCtx, name: &str, dispatch: usize) -> usize {
|
||||
dispatch + tools::dispatch(ctx, name)
|
||||
}
|
||||
|
||||
// Control: no local binding.
|
||||
fn plain(ctx: &ToolCtx, name: &str) -> usize {
|
||||
tools::dispatch(ctx, name)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let ctx = ToolCtx { depth: 0 };
|
||||
with_let(&ctx, "a");
|
||||
with_parameter(&ctx, "b", 1);
|
||||
plain(&ctx, "c");
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/qualified-over-local-rust/src/tools.rs
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/qualified-over-local-rust/src/tools.rs
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub struct ToolCtx {
|
||||
pub depth: usize,
|
||||
}
|
||||
|
||||
pub fn dispatch(ctx: &ToolCtx, name: &str) -> usize {
|
||||
ctx.depth + name.len()
|
||||
}
|
||||
1
gitnexus/test/fixtures/lang-resolution/r-assignment-locals-module/R/d1.R
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/r-assignment-locals-module/R/d1.R
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
helper <- function(x) 1
|
||||
4
gitnexus/test/fixtures/lang-resolution/r-assignment-locals-module/R/d2.R
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/r-assignment-locals-module/R/d2.R
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# d10: a top-level assignment in the caller's own file is not modelled (documented, R-02): the
|
||||
# call below reads this value in R, but the unique-name fallback binds the function of d1.R.
|
||||
helper <- identity
|
||||
d10_f <- function(x) helper(x)
|
||||
4
gitnexus/test/fixtures/lang-resolution/r-assignment-locals-module/R/d3.R
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/r-assignment-locals-module/R/d3.R
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# d14: the same with a constant. R skips a non-function at a call, so the outer function is the
|
||||
# right target here; the edge is the same guess (0.5).
|
||||
helper <- 5
|
||||
d14_f <- function(x) helper(x)
|
||||
2
gitnexus/test/fixtures/lang-resolution/r-assignment-locals/pkga/DESCRIPTION
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/r-assignment-locals/pkga/DESCRIPTION
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Package: pkga
|
||||
Version: 0.1
|
||||
1
gitnexus/test/fixtures/lang-resolution/r-assignment-locals/pkga/NAMESPACE
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/r-assignment-locals/pkga/NAMESPACE
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export(helper)
|
||||
1
gitnexus/test/fixtures/lang-resolution/r-assignment-locals/pkga/R/a.R
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/r-assignment-locals/pkga/R/a.R
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
helper <- function(x) 1
|
||||
42
gitnexus/test/fixtures/lang-resolution/r-assignment-locals/scripts/d.R
vendored
Normal file
42
gitnexus/test/fixtures/lang-resolution/r-assignment-locals/scripts/d.R
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Ordinary assignments bind a name in the function's own frame (probe cases d1-d16).
|
||||
# Every caller is named `dN_f`; `helper` below is the outer function the edges must or must not reach.
|
||||
helper <- function(x) 1
|
||||
some_fn <- function() identity
|
||||
missing <- function(x) 1
|
||||
|
||||
# d1: `<-` of a computed value
|
||||
d1_f <- function(x) { helper <- identity; helper(x) }
|
||||
# d2: `=`
|
||||
d2_f <- function(x) { helper = identity; helper(x) }
|
||||
# d3: a call result
|
||||
d3_f <- function(x) { helper <- some_fn(); helper(x) }
|
||||
# d4: the call comes before the assignment (flow-insensitive: documented as unresolved)
|
||||
d4_f <- function(x) { helper(x); helper <- identity }
|
||||
# d5: a conditional assignment
|
||||
d5_f <- function(x) { if (x) helper <- identity; helper(x) }
|
||||
# d6: control, the assigned value is a function literal (a real definition)
|
||||
d6_f <- function(x) { helper <- function(y) 2; helper(x) }
|
||||
# d7: right assignment
|
||||
d7_f <- function(x) { identity -> helper; helper(x) }
|
||||
# d9: control, a parameter
|
||||
d9_f <- function(x, helper) { helper(x) }
|
||||
# d11: a qualified call is not read through the local name
|
||||
d11_f <- function(x) { helper <- identity; pkga::helper(x) }
|
||||
# d12: the qualifier names nothing exported, and the bare call is shadowed
|
||||
d12_f <- function(x) { missing <- identity; pkga::missing(x); missing(x) }
|
||||
# d13: control, a constant is not a function, so R skips it at a call
|
||||
d13_f <- function(x) { helper <- 2; helper(x) }
|
||||
# d15: control, `<<-` assigns outward and does not bind here
|
||||
d15_f <- function(x) { helper <<- identity; helper(x) }
|
||||
# d16: a nested closure reads the enclosing function's local
|
||||
d16_f <- function(x) { helper <- identity; d16_g <- function() helper(x); d16_g() }
|
||||
# d17/d18: a parameter or a `for` variable of the qualified name does not hide the qualified call
|
||||
d17_f <- function(x, helper) { pkga::helper(x) }
|
||||
d18_f <- function(xs) { for (helper in xs) pkga::helper(1) }
|
||||
# d19-d21: bindings the provider does not model (documented, R-02). In R each one binds `helper`
|
||||
# in the frame, so the call below does not reach the outer function; the provider still binds it.
|
||||
d19_f <- function(x) { assign("helper", identity); helper(x) }
|
||||
d20_f <- function(x) { local({ helper <- identity; helper(x) }) }
|
||||
d21_f <- function(x) { with(list(helper = identity), helper(x)) }
|
||||
d22_f <- function(x) { delayedAssign("helper", identity); helper(x) }
|
||||
d23_f <- function(x) { list2env(list(helper = identity), environment()); helper(x) }
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/consumer/DESCRIPTION
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/consumer/DESCRIPTION
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Package: consumer
|
||||
Title: Backtick Import Caller
|
||||
Version: 0.1.0
|
||||
6
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/consumer/NAMESPACE
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/consumer/NAMESPACE
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export(run_all)
|
||||
importFrom(provlib, `%||%`)
|
||||
importFrom(provlib, "odd fn")
|
||||
importFrom(provlib, plain_fn)
|
||||
# The caller package defines this name itself: its own namespace masks the import.
|
||||
importFrom(provlib, shadowed_op)
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/consumer/R/own.R
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/consumer/R/own.R
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
`shadowed_op` <- function() 99
|
||||
|
||||
use_own <- function() `shadowed_op`()
|
||||
6
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/consumer/R/use.R
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/consumer/R/use.R
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
run_all <- function(x) {
|
||||
`%||%`(x, 1)
|
||||
`odd fn`()
|
||||
plain_fn()
|
||||
provlib::qualified_op()
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/consumer/scripts/lib.R
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/consumer/scripts/lib.R
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
library(provlib)
|
||||
|
||||
from_library <- function(x) `%||%`(x, 2)
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/decoylib/DESCRIPTION
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/decoylib/DESCRIPTION
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Package: decoylib
|
||||
Title: Backtick Import Decoy
|
||||
Version: 0.1.0
|
||||
5
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/decoylib/NAMESPACE
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/decoylib/NAMESPACE
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export(`%||%`)
|
||||
export(`odd fn`)
|
||||
export(plain_fn)
|
||||
export(`shadowed_op`)
|
||||
export(qualified_op)
|
||||
11
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/decoylib/R/ops.R
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/decoylib/R/ops.R
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Same names as provlib: a name-only guess cannot tell the two apart, so a call is attributed
|
||||
# to provlib only through the importFrom() or the qualifier.
|
||||
`%||%` <- function(a, b) b
|
||||
|
||||
`odd fn` <- function() 10
|
||||
|
||||
plain_fn <- function() 20
|
||||
|
||||
`shadowed_op` <- function() 30
|
||||
|
||||
`qualified_op` <- function() 40
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/provlib/DESCRIPTION
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/provlib/DESCRIPTION
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Package: provlib
|
||||
Title: Backtick Import Provider
|
||||
Version: 0.1.0
|
||||
5
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/provlib/NAMESPACE
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/provlib/NAMESPACE
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export(`%||%`)
|
||||
export(`odd fn`)
|
||||
export(plain_fn)
|
||||
export(`shadowed_op`)
|
||||
export(qualified_op)
|
||||
11
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/provlib/R/ops.R
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/r-backtick-imports/provlib/R/ops.R
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Definitions written in backticks keep their backticks on the node; NAMESPACE names the symbol.
|
||||
`%||%` <- function(a, b) if (is.null(a)) b else a
|
||||
|
||||
`odd fn` <- function() 1
|
||||
|
||||
plain_fn <- function() 2
|
||||
|
||||
`shadowed_op` <- function() 3
|
||||
|
||||
# Called as provlib::qualified_op(), written without backticks.
|
||||
`qualified_op` <- function() 4
|
||||
2
gitnexus/test/fixtures/lang-resolution/r-backtick-names/pkg/DESCRIPTION
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/r-backtick-names/pkg/DESCRIPTION
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Package: pkg
|
||||
Version: 1.0
|
||||
4
gitnexus/test/fixtures/lang-resolution/r-backtick-names/pkg/NAMESPACE
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/r-backtick-names/pkg/NAMESPACE
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export(`%+%`)
|
||||
export(`odd name`)
|
||||
export(plain_op)
|
||||
exportPattern("^pat_")
|
||||
9
gitnexus/test/fixtures/lang-resolution/r-backtick-names/pkg/R/ops.R
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/r-backtick-names/pkg/R/ops.R
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
`%+%` <- function(a, b) a
|
||||
|
||||
`odd name` <- function() 1
|
||||
|
||||
hidden_op <- function() 2
|
||||
|
||||
plain_op <- function() 3
|
||||
|
||||
`pat_hit` <- function() 4
|
||||
2
gitnexus/test/fixtures/lang-resolution/r-backtick-names/spaced/DESCRIPTION
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/r-backtick-names/spaced/DESCRIPTION
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Package: spaced
|
||||
Version: 1.0
|
||||
1
gitnexus/test/fixtures/lang-resolution/r-backtick-names/spaced/NAMESPACE
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/r-backtick-names/spaced/NAMESPACE
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export(" a ")
|
||||
2
gitnexus/test/fixtures/lang-resolution/r-backtick-names/spaced/R/f.R
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/r-backtick-names/spaced/R/f.R
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
a <- function() 1
|
||||
` a ` <- function() 2
|
||||
4
gitnexus/test/fixtures/lang-resolution/r-block-scopes/R/inner.R
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/r-block-scopes/R/inner.R
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
e1 <- function() {
|
||||
local({ outerh <- function() "inner" })
|
||||
outerh()
|
||||
}
|
||||
4
gitnexus/test/fixtures/lang-resolution/r-block-scopes/R/models.R
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/r-block-scopes/R/models.R
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
User <- R6::R6Class("User", public = list(
|
||||
save = function() 1,
|
||||
greet = function() 2
|
||||
))
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-block-scopes/R/real.R
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-block-scopes/R/real.R
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
mockme <- function() "real"
|
||||
outerh <- function() "outer top-level"
|
||||
stubonly <- function() "real"
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue