Vendored WhatsApp/tree-sitter-erlang 0.19 (the ELP grammar, ABI 14) with an Erlang-shaped extractor: multi-clause/multi-arity functions merged into one symbol, -spec signatures, records with fields, -type/-opaque aliases, -define macros, -include/-include_lib file edges, and -export-driven visibility. Modules wrap in a namespace so remote mod:fn(...) calls resolve through the existing qualified-name matcher as mod::fn with zero resolver changes. -behaviour declarations link to the behaviour module — gated to namespace targets only (bare-name fallthrough linked -behaviour(supervisor) to an unrelated macro constant on emqx). OTP indirection with static targets is followed: spawn/apply/proc_lib/timer/rpc MFA-argument callees, and gen_server:call/cast(?MODULE | ?SERVER) to the module's own handle_call/handle_cast. Var-module dispatch and message sends stay deliberately unlinked. codegraph_explore also normalizes Erlang-native query spelling (mod:fn/3, init/2) so named symbols resolve as typed. Benchmarked on cowboy (189 files), ejabberd (414), emqx (2,447): extraction PASS on all three; with-codegraph arms reached 2/2/0 file Reads vs 10/5+/19 without, fastest on the largest repo. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
63e1b5a23a
commit
6511722250
@@ -10,7 +10,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { getExploreOutputBudget, getExploreBudget, ToolHandler } from '../src/mcp/tools';
|
||||
import { getExploreOutputBudget, getExploreBudget, normalizeQuerySpelling, ToolHandler } from '../src/mcp/tools';
|
||||
import CodeGraph from '../src/index';
|
||||
|
||||
describe('getExploreOutputBudget', () => {
|
||||
@@ -254,3 +254,27 @@ describe('codegraph_explore output respects the adaptive budget', () => {
|
||||
expect(hasMethodBody).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeQuerySpelling (Erlang mod:fn/arity)', () => {
|
||||
it('rewrites Erlang-native symbol spellings to pipeline shapes', () => {
|
||||
expect(normalizeQuerySpelling('cowboy_stream_h:request_process/3'))
|
||||
.toBe('cowboy_stream_h.request_process');
|
||||
expect(normalizeQuerySpelling('ejabberd_router:route/1 do_route/1 session'))
|
||||
.toBe('ejabberd_router.route do_route session');
|
||||
expect(normalizeQuerySpelling('init/2 handle_call/3')).toBe('init handle_call');
|
||||
});
|
||||
|
||||
it('leaves query-language field prefixes and other spellings alone', () => {
|
||||
expect(normalizeQuerySpelling('kind:function lang:erlang route'))
|
||||
.toBe('kind:function lang:erlang route');
|
||||
expect(normalizeQuerySpelling('path:src/api name:auth')).toBe('path:src/api name:auth');
|
||||
expect(normalizeQuerySpelling('Foo::bar baz')).toBe('Foo::bar baz');
|
||||
expect(normalizeQuerySpelling('https://example.com/docs')).toBe('https://example.com/docs');
|
||||
expect(normalizeQuerySpelling('meeting at 12:30')).toBe('meeting at 12:30');
|
||||
expect(normalizeQuerySpelling('src/2fa/handler.ts')).toBe('src/2fa/handler.ts');
|
||||
});
|
||||
|
||||
it('maps Lua colon-method spelling onto the qualified form', () => {
|
||||
expect(normalizeQuerySpelling('logger:log message')).toBe('logger.log message');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,11 @@ describe('Language Detection', () => {
|
||||
expect(isSourceFile('Renderer/Shaders.metal')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Erlang files', () => {
|
||||
expect(detectLanguage('src/my_server.erl')).toBe('erlang');
|
||||
expect(detectLanguage('include/records.hrl')).toBe('erlang');
|
||||
});
|
||||
|
||||
it('should return unknown for unsupported extensions', () => {
|
||||
expect(detectLanguage('styles.css')).toBe('unknown');
|
||||
expect(detectLanguage('data.json')).toBe('unknown');
|
||||
@@ -8753,3 +8758,382 @@ End Class
|
||||
expect(calls).toContain('Parser.GetSteamNameAndID');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Erlang (vendored WhatsApp/tree-sitter-erlang grammar — the ELP grammar)
|
||||
// =============================================================================
|
||||
|
||||
describe('Erlang Extraction', () => {
|
||||
describe('Language detection', () => {
|
||||
it('should report Erlang as supported', () => {
|
||||
expect(isLanguageSupported('erlang')).toBe(true);
|
||||
expect(getSupportedLanguages()).toContain('erlang');
|
||||
expect(isSourceFile('apps/app/src/foo.erl')).toBe(true);
|
||||
expect(isSourceFile('include/foo.hrl')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Function extraction', () => {
|
||||
it('should merge multi-clause functions into one node spanning all clauses', () => {
|
||||
const code = `-module(m).
|
||||
-export([classify/1]).
|
||||
|
||||
classify(X) when is_atom(X) ->
|
||||
atom;
|
||||
classify(X) when is_binary(X) ->
|
||||
binary;
|
||||
classify(_X) ->
|
||||
other.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const fns = result.nodes.filter((n) => n.kind === 'function' && n.name === 'classify');
|
||||
expect(fns).toHaveLength(1);
|
||||
expect(fns[0]!.startLine).toBe(4);
|
||||
expect(fns[0]!.endLine).toBe(9);
|
||||
expect(fns[0]!.language).toBe('erlang');
|
||||
});
|
||||
|
||||
it('should qualify functions with the module namespace', () => {
|
||||
const code = `-module(my_server).
|
||||
-export([start/0]).
|
||||
|
||||
start() -> ok.
|
||||
helper() -> ok.
|
||||
`;
|
||||
const result = extractFromSource('src/my_server.erl', code);
|
||||
const ns = result.nodes.find((n) => n.kind === 'namespace');
|
||||
expect(ns?.name).toBe('my_server');
|
||||
const start = result.nodes.find((n) => n.kind === 'function' && n.name === 'start');
|
||||
expect(start?.qualifiedName).toBe('my_server::start');
|
||||
});
|
||||
|
||||
it('should flag exported functions and honor -compile(export_all)', () => {
|
||||
const code = `-module(m).
|
||||
-export([api/0]).
|
||||
|
||||
api() -> internal().
|
||||
internal() -> ok.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const api = result.nodes.find((n) => n.name === 'api');
|
||||
const internal = result.nodes.find((n) => n.name === 'internal');
|
||||
expect(api?.isExported).toBe(true);
|
||||
expect(internal?.isExported).toBe(false);
|
||||
|
||||
const all = extractFromSource('src/all.erl', `-module(all).
|
||||
-compile(export_all).
|
||||
|
||||
anything() -> ok.
|
||||
`);
|
||||
expect(all.nodes.find((n) => n.name === 'anything')?.isExported).toBe(true);
|
||||
});
|
||||
|
||||
it('should use the preceding -spec as the signature and capture doc comments', () => {
|
||||
const code = `-module(m).
|
||||
|
||||
%% Fetches a value by key.
|
||||
-spec fetch(binary()) -> {ok, term()} | not_found.
|
||||
fetch(Key) ->
|
||||
lookup(Key).
|
||||
|
||||
lookup(_K) -> not_found.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const fetch = result.nodes.find((n) => n.name === 'fetch');
|
||||
expect(fetch?.signature).toBe('-spec fetch(binary()) -> {ok, term()} | not_found.');
|
||||
expect(fetch?.docstring).toBe('Fetches a value by key.');
|
||||
});
|
||||
|
||||
it('should fall back to the clause header as the signature', () => {
|
||||
const code = `-module(m).
|
||||
|
||||
resize(W, H) when W > 0, H > 0 ->
|
||||
{W, H}.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const resize = result.nodes.find((n) => n.name === 'resize');
|
||||
expect(resize?.signature).toBe('resize(W, H) when W > 0, H > 0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Record and type extraction', () => {
|
||||
it('should extract records as structs with fields', () => {
|
||||
const code = `-module(m).
|
||||
|
||||
-record(state, {
|
||||
store = #{} :: map(),
|
||||
counter = 0 :: non_neg_integer()
|
||||
}).
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const rec = result.nodes.find((n) => n.kind === 'struct');
|
||||
expect(rec?.name).toBe('state');
|
||||
const fields = result.nodes.filter((n) => n.kind === 'field').map((n) => n.name);
|
||||
expect(fields).toContain('store');
|
||||
expect(fields).toContain('counter');
|
||||
});
|
||||
|
||||
it('should extract -type and -opaque as type aliases, without bogus type-call refs', () => {
|
||||
const code = `-module(m).
|
||||
|
||||
-type key() :: atom() | binary().
|
||||
-opaque handle() :: reference().
|
||||
-spec noop(key()) -> ok.
|
||||
noop(_K) -> ok.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const aliases = result.nodes.filter((n) => n.kind === 'type_alias').map((n) => n.name);
|
||||
expect(aliases).toContain('key');
|
||||
expect(aliases).toContain('handle');
|
||||
// Type-position expressions parse as `call` nodes — the spec/type subtrees
|
||||
// must not leak `calls` refs to type names like atom()/binary().
|
||||
const bogus = result.unresolvedReferences.filter(
|
||||
(r) => r.referenceKind === 'calls' && ['atom', 'binary', 'reference', 'key'].includes(r.referenceName)
|
||||
);
|
||||
expect(bogus).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should extract -define macros as constants', () => {
|
||||
const code = `-module(m).
|
||||
|
||||
-define(TIMEOUT, 5000).
|
||||
-define(WRAP(X), {ok, X}).
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const consts = result.nodes.filter((n) => n.kind === 'constant').map((n) => n.name);
|
||||
expect(consts).toContain('TIMEOUT');
|
||||
expect(consts).toContain('WRAP');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Import extraction', () => {
|
||||
it('should extract -include/-include_lib and -import', () => {
|
||||
const code = `-module(m).
|
||||
|
||||
-include("records.hrl").
|
||||
-include_lib("kernel/include/logger.hrl").
|
||||
-import(lists, [map/2]).
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
|
||||
expect(imports).toContain('records.hrl');
|
||||
expect(imports).toContain('kernel/include/logger.hrl');
|
||||
expect(imports).toContain('lists');
|
||||
const ref = result.unresolvedReferences.find(
|
||||
(r) => r.referenceKind === 'imports' && r.referenceName === 'records.hrl'
|
||||
);
|
||||
expect(ref).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Call extraction', () => {
|
||||
it('should record local calls bare and remote calls module-qualified', () => {
|
||||
const code = `-module(m).
|
||||
-export([run/1]).
|
||||
|
||||
run(X) ->
|
||||
Y = prepare(X),
|
||||
other_mod:process(Y).
|
||||
|
||||
prepare(X) -> X.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
|
||||
expect(calls).toContain('prepare');
|
||||
// `mod:fn(...)` is emitted as `mod::fn` — the same shape the module
|
||||
// namespace gives every function's qualifiedName, so it resolves via
|
||||
// the qualified-name matcher.
|
||||
expect(calls).toContain('other_mod::process');
|
||||
});
|
||||
|
||||
it('should not emit calls for dynamic dispatch (var module / var fun)', () => {
|
||||
const code = `-module(m).
|
||||
-export([run/2]).
|
||||
|
||||
run(Mod, F) ->
|
||||
Mod:handle(x),
|
||||
F(y).
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
|
||||
expect(calls).not.toContain('handle');
|
||||
expect(calls).not.toContain('Mod::handle');
|
||||
expect(calls).not.toContain('F');
|
||||
});
|
||||
|
||||
it('should connect gen_server self-calls to the module handlers', () => {
|
||||
const code = `-module(kv_store).
|
||||
-behaviour(gen_server).
|
||||
-export([get/1, put/2, drop/1]).
|
||||
-export([init/1, handle_call/3, handle_cast/2]).
|
||||
|
||||
-define(SERVER, ?MODULE).
|
||||
|
||||
get(Key) ->
|
||||
gen_server:call(?SERVER, {get, Key}).
|
||||
|
||||
put(Key, Value) ->
|
||||
gen_server:cast(?MODULE, {put, Key, Value}).
|
||||
|
||||
drop(Key) ->
|
||||
gen_server:call(kv_store, {drop, Key}).
|
||||
|
||||
init(_) -> {ok, #{}}.
|
||||
handle_call({get, K}, _From, S) -> {reply, maps:find(K, S), S};
|
||||
handle_call({drop, K}, _From, S) -> {reply, ok, maps:remove(K, S)}.
|
||||
handle_cast({put, K, V}, S) -> {noreply, maps:put(K, V, S)}.
|
||||
`;
|
||||
const result = extractFromSource('src/kv_store.erl', code);
|
||||
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
|
||||
// ?SERVER (defined as ?MODULE), ?MODULE, and the module's own atom all
|
||||
// count as self — public API wrappers connect to their handlers.
|
||||
expect(calls.filter((c) => c === 'kv_store::handle_call')).toHaveLength(2);
|
||||
expect(calls).toContain('kv_store::handle_cast');
|
||||
});
|
||||
|
||||
it('should not connect gen_server calls to other processes', () => {
|
||||
const code = `-module(m).
|
||||
-export([go/2]).
|
||||
|
||||
go(Pid, Msg) ->
|
||||
gen_server:call(Pid, Msg),
|
||||
gen_server:call(other_registered_name, Msg),
|
||||
gen_server:cast({global, some_name}, Msg).
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
|
||||
expect(calls.filter((c) => c.includes('handle_call') || c.includes('handle_cast'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should lift static MFA arguments of the spawn/apply family into call refs', () => {
|
||||
const code = `-module(m).
|
||||
-export([boot/2]).
|
||||
|
||||
boot(Req, Env) ->
|
||||
Pid = proc_lib:spawn_link(?MODULE, request_process, [Req, Env]),
|
||||
spawn(?MODULE, monitor_loop, [Pid]),
|
||||
apply(other_mod, handle, [Req]),
|
||||
timer:apply_after(500, other_mod, tick, []),
|
||||
Pid.
|
||||
|
||||
request_process(_R, _E) -> ok.
|
||||
monitor_loop(_P) -> ok.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
|
||||
expect(calls).toContain('request_process'); // ?MODULE → bare, same-file resolution
|
||||
expect(calls).toContain('monitor_loop');
|
||||
expect(calls).toContain('other_mod::handle');
|
||||
expect(calls).toContain('other_mod::tick');
|
||||
});
|
||||
|
||||
it('should stay silent on dynamic spawn/apply (var module, fun value, or plain fun)', () => {
|
||||
const code = `-module(m).
|
||||
-export([go/3]).
|
||||
|
||||
go(M, F, A) ->
|
||||
spawn(M, F, A),
|
||||
spawn(fun() -> helper() end),
|
||||
apply(M, F, A).
|
||||
|
||||
helper() -> ok.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
|
||||
// The fun body's call is still walked; no phantom MFA targets appear.
|
||||
expect(calls).toContain('helper');
|
||||
expect(calls.filter((c) => c !== 'spawn' && c !== 'apply' && c !== 'helper')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should treat ?MODULE:fn calls as local calls', () => {
|
||||
const code = `-module(m).
|
||||
-export([kick/0]).
|
||||
|
||||
kick() ->
|
||||
?MODULE:work().
|
||||
|
||||
work() -> ok.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
|
||||
expect(calls).toContain('work');
|
||||
});
|
||||
|
||||
it('should capture fun name/arity values as function references', () => {
|
||||
const code = `-module(m).
|
||||
-export([wire/1]).
|
||||
|
||||
wire(Pids) ->
|
||||
lists:foreach(fun notify/1, Pids),
|
||||
lists:map(fun m:notify/1, Pids).
|
||||
|
||||
notify(_P) -> ok.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references').map((r) => r.referenceName);
|
||||
expect(refs).toContain('notify');
|
||||
expect(refs).toContain('m::notify');
|
||||
});
|
||||
|
||||
it('should reference records used in bodies and argument patterns', () => {
|
||||
const code = `-module(m).
|
||||
-export([mk/1, get_id/1]).
|
||||
|
||||
-record(req, {id, payload}).
|
||||
|
||||
mk(Id) -> #req{id = Id}.
|
||||
get_id(#req{id = Id}) -> Id.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const refs = result.unresolvedReferences.filter(
|
||||
(r) => r.referenceKind === 'references' && r.referenceName === 'req'
|
||||
);
|
||||
expect(refs.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should attribute calls from every clause of a multi-clause function', () => {
|
||||
const code = `-module(m).
|
||||
-export([handle/1]).
|
||||
|
||||
handle({a, X}) ->
|
||||
first(X);
|
||||
handle({b, X}) ->
|
||||
second(X).
|
||||
|
||||
first(X) -> X.
|
||||
second(X) -> X.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const handle = result.nodes.find((n) => n.kind === 'function' && n.name === 'handle');
|
||||
const calls = result.unresolvedReferences.filter(
|
||||
(r) => r.referenceKind === 'calls' && r.fromNodeId === handle?.id
|
||||
).map((r) => r.referenceName);
|
||||
expect(calls).toContain('first');
|
||||
expect(calls).toContain('second');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Behaviour extraction', () => {
|
||||
it('should emit an implements reference for -behaviour', () => {
|
||||
const code = `-module(m).
|
||||
-behaviour(gen_server).
|
||||
|
||||
init(_) -> {ok, #{}}.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const impl = result.unresolvedReferences.find((r) => r.referenceKind === 'implements');
|
||||
expect(impl?.referenceName).toBe('gen_server');
|
||||
});
|
||||
|
||||
it('should not create symbols from -callback declarations', () => {
|
||||
const code = `-module(b).
|
||||
|
||||
-callback handle_thing(term()) -> ok.
|
||||
-callback init(list()) -> {ok, term()}.
|
||||
`;
|
||||
const result = extractFromSource('src/b.erl', code);
|
||||
const fns = result.nodes.filter((n) => n.kind === 'function');
|
||||
expect(fns).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,6 +83,67 @@ describe('Resolution Module', () => {
|
||||
expect(result?.resolvedBy).toBe('exact-match');
|
||||
});
|
||||
|
||||
it('should resolve Erlang -behaviour refs only to module namespaces', () => {
|
||||
// On emqx, `-behaviour(supervisor)` (OTP behaviour, not in the repo)
|
||||
// fell through to bare-name matching and resolved to a
|
||||
// `-define(supervisor, ...)` macro constant in an unrelated app.
|
||||
const macroConstant: Node = {
|
||||
id: 'constant:apps/bridge/src/impl.erl:supervisor:61',
|
||||
kind: 'constant',
|
||||
name: 'supervisor',
|
||||
qualifiedName: 'impl::supervisor',
|
||||
filePath: 'apps/bridge/src/impl.erl',
|
||||
language: 'erlang',
|
||||
startLine: 61,
|
||||
endLine: 61,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const behaviourModule: Node = {
|
||||
id: 'namespace:src/my_behaviour.erl:my_behaviour:1',
|
||||
kind: 'namespace',
|
||||
name: 'my_behaviour',
|
||||
qualifiedName: 'my_behaviour',
|
||||
filePath: 'src/my_behaviour.erl',
|
||||
language: 'erlang',
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const nodes = [macroConstant, behaviourModule];
|
||||
const context: ResolutionContext = {
|
||||
getNodesInFile: () => [],
|
||||
getNodesByName: (name) => nodes.filter((n) => n.name === name),
|
||||
getNodesByQualifiedName: () => [],
|
||||
getNodesByKind: () => [],
|
||||
fileExists: () => false,
|
||||
readFile: () => null,
|
||||
getProjectRoot: () => '/test',
|
||||
getAllFiles: () => [],
|
||||
getNodesByLowerName: () => [],
|
||||
getImportMappings: () => [],
|
||||
};
|
||||
const mkRef = (name: string) => ({
|
||||
fromNodeId: 'namespace:src/worker.erl:worker:1',
|
||||
referenceName: name,
|
||||
referenceKind: 'implements' as const,
|
||||
line: 2,
|
||||
column: 0,
|
||||
filePath: 'src/worker.erl',
|
||||
language: 'erlang' as const,
|
||||
});
|
||||
|
||||
// Out-of-repo behaviour whose name collides with a macro constant:
|
||||
// stays unresolved instead of linking the constant.
|
||||
expect(matchReference(mkRef('supervisor'), context)).toBeNull();
|
||||
// In-repo behaviour module resolves to its namespace.
|
||||
const resolved = matchReference(mkRef('my_behaviour'), context);
|
||||
expect(resolved?.targetNodeId).toBe(behaviourModule.id);
|
||||
});
|
||||
|
||||
it('should prefer same-module candidates over cross-module matches', () => {
|
||||
// Simulates a Python monorepo where multiple apps define navigate()
|
||||
const candidateA: Node = {
|
||||
|
||||
Reference in New Issue
Block a user