Files
codegraph/__tests__/cpp-member-chain-project-calls.test.ts
T
Colby MchenryandClaude Opus 5.5 e8ec7fd847 fix(cpp): a call on a member reached through other members, like thread->rand.Next() on a Random64, links to the method of the member's class (#2627)
Step 2 of member chains; #2626 was step 1. matchCppMemberChainCall (formerly
matchCppOutsideMemberChainCall) now also resolves a chain whose member is
of a project class, in the same steps matchCppUnspelledReceiverCall takes
for `it->second.Flush()`:
- the class C++ lookup finds for the member's type, where that type is
  written;
- then its method, or one it inherits (cppMethodOf, with overloads picked
  by argument types);
- or no edge when the class surely lacks it (cppLacksMethod).

Before, such a call went by its bare name to whichever class had a method
of that name. Inside a method it could also be taken for an implicit
`this` call on the method's own class.

A member is now read from the class node C++ lookup found (cppDataMemberIn),
not from the first class of that qualified name that declares it
(cppDataMemberType, removed). rocksdb's db_bench_tool.cc and
db_stress_shared_state.h each declare a ROCKSDB_NAMESPACE::ThreadState,
whose `rand` is a Random64 in one and a Random in the other.

Kit A/B against main 6e884d7b:

| repo | added | retargeted | same target, now typed | removed |
|---|---|---|---|---|
| rocksdb | 571 | 2,008 | 1,738 | 2 |
| protobuf | 42 | 95 | 142 | 0 |
| leveldb | 42 | 30 | 141 | 0 |
| fmt | 0 | 56 | 20 | 0 |
| redis | 0 | 0 | 0 | 0 (identical) |

The most common rocksdb retargets are DBImpl -> DB (calls on a `DB*`),
BlobContents -> Slice, BlobFileReader -> FileDescriptor and
EnvWrapper -> Env. Hand-reading random samples found one wrong
retarget (db_bench's `thread->rand.Next()`, the ThreadState namesake),
which led to the member-lookup fix above. After that fix, all 76 sites
sampled across the four repos were correct:
- 30 retargets and additions, plus both removals, in rocksdb;
- 20 in protobuf, 14 in leveldb and 10 in fmt.

The two removed calls are `task_reader->rep_->GetCoroutine(…)` on a
`SstFileReader::Rep`, which has no such method. They had linked to
`CoroDB`'s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:13:48 +00:00

177 lines
5.6 KiB
TypeScript

/**
* A C++ call on a data member reached through a member chain, like rocksdb's
* `thread->rnd.Next()` on a `Random rnd` or `meta.smallest.user_key()` on an
* `InternalKey smallest`, calls the method of the class the member is
* declared as, or of a class it derives from. The extractor records such a
* call by its bare name, so it went to whichever class had a method of that
* name: `ArenaWrappedDBIter::Next`, `ClippingIterator::user_key`, or the
* calling method's own class.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import type { Node } from '../src/types';
const lines = (...l: string[]): string => [...l, ''].join('\n');
describe('a C++ call on a member chain of a project class', () => {
let root: string;
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-member-chain-project-'));
const write = (file: string, text: string): void => {
fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
fs.writeFileSync(path.join(root, file), text);
};
// Each namesake comes first: a call matched by its bare name lands on it.
write('src/types.h', lines(
'class ArenaWrappedDBIter {',
' public:',
' void Next() {}',
'};',
'class Random {',
' public:',
' unsigned Next() { return 0; }',
'};',
'class ClippingIterator {',
' public:',
' int user_key() const { return 1; }',
'};',
'class InternalKey {',
' public:',
' int user_key() const { return 0; }',
'};',
'class InMemoryEnv {',
' public:',
' void NewWritableFile() {}',
'};',
'class Env {',
' public:',
' virtual void NewWritableFile() {}',
'};',
'class Other {',
' public:',
' void Shared() {}',
'};',
'class Base {',
' public:',
' void Shared() {}',
'};',
'class Derived : public Base {};',
'class Free {',
' public:',
' void Missing() {}',
'};',
'class Lacking {',
' public:',
' void Something() {}',
'};',
'class Cache {',
' public:',
' void Put(int k, int v) {}',
'};',
'class Table {',
' public:',
' void Put(int k) {}',
' void Put(int k, int v) {}',
'};',
'class Random64 {',
' public:',
' unsigned Next() { return 1; }',
'};',
));
// Two files each declare a `SharedState` of their own, with members of the same name.
write('src/a_shared.h', lines(
'#include "src/types.h"',
'struct SharedState {',
' Random rand;',
'};',
));
write('src/bench.cc', lines(
'#include "src/types.h"',
'struct SharedState {',
' Random64 rand;',
'};',
'void Bench(SharedState* shared) { shared->rand.Next(); }',
));
write('src/use.cc', lines(
'#include "src/types.h"',
'struct ThreadState {',
' Random rnd;',
'};',
'struct FileMeta {',
' InternalKey smallest;',
'};',
'struct Options {',
' Env* env;',
'};',
'struct Holder {',
' Derived d;',
' Lacking l;',
' Table table;',
'};',
'struct Outer {',
' ThreadState inner;',
'};',
'void Rand(ThreadState* thread) { thread->rnd.Next(); }',
'void Key(FileMeta& meta) { meta.smallest.user_key(); }',
'void EnvCall(Options& options) { options.env->NewWritableFile(); }',
'void TwoHops(Outer* o) { o->inner.rnd.Next(); }',
'void Inherited(Holder& h) { h.d.Shared(); }',
'void Lacks(Holder& h) { h.l.Missing(); }',
'void Overload(Holder& h) { h.table.Put(1, 2); }',
'class Worker {',
' public:',
' void Next() {}',
' void Step() { this->state_.rnd.Next(); }',
' private:',
' ThreadState state_;',
'};',
));
cg = await CodeGraph.init(root, { index: true });
}, 120_000);
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
/** `calls` callees of the function or method `name`, as `qualifiedName` (`:line` with `withLine`). */
const calls = (name: string, withLine = false): string[] => {
const caller = [...cg.getNodesByKind('function'), ...cg.getNodesByKind('method')].find((n: Node) => n.name === name);
expect(caller, name).toBeDefined();
return cg.getCallees(caller!.id).filter((r) => r.edge.kind === 'calls')
.map((r) => (withLine ? `${r.node.qualifiedName}:${r.node.startLine}` : r.node.qualifiedName)).sort();
};
it("calls the method of the member's class, not a namesake's", () => {
expect(calls('Rand')).toEqual(['Random::Next']);
expect(calls('Key')).toEqual(['InternalKey::user_key']);
expect(calls('EnvCall')).toEqual(['Env::NewWritableFile']);
});
it('through two members', () => {
expect(calls('TwoHops')).toEqual(['Random::Next']);
});
it("a method the member's class inherits, and none when the class lacks it", () => {
expect(calls('Inherited')).toEqual(['Base::Shared']);
expect(calls('Lacks')).toEqual([]);
});
it("of the class's overloads, the one the arguments fit", () => {
expect(calls('Overload', true)).toEqual(['Table::Put:49']);
});
it("a chain through `this` is no call on the calling method's own class", () => {
expect(calls('Step')).toEqual(['Random::Next']);
});
it("a member of the file's own class, not of another file's class of that name", () => {
expect(calls('Bench')).toEqual(['Random64::Next']);
});
});