Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

#### Symbols, tests and the viewer

- Erlang selective imports now resolve a bare call to the exact exported module, function, and arity, while unqualified calls stay within their own module instead of binding to an unrelated same-named project function. Re-index Erlang projects after upgrading. (#1610) (Erlang)

- **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges.

- **Production code under a `samples` or `examples` package path is no longer treated as test code.** A Kotlin or Java project whose package path runs through `com/google/samples/…` (Now in Android, for one) had nearly every file counted as a fixture, so the Map opened on `build-logic`, the entry points hid the app, and dead-code and test badges were wrong. Only the project layout above a `src/` folder decides now; the package path below it never does.
Expand Down
56 changes: 56 additions & 0 deletions __tests__/erlang-arity-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,60 @@ run(L) ->
expect(edges).toContainEqual({ sq: 'user_m::run/1', tq: 'lib_m::bump/1' });
expect(edges.some((e) => e.sq === 'user_m::run/1' && e.tq === 'lib_m::bump/2')).toBe(false);
});

it('resolves a selective import to its named module, not a nearer same-named function', async () => {
fs.mkdirSync(path.join(dir, 'deps'), { recursive: true });
fs.mkdirSync(path.join(dir, 'app'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'deps', 'imported.erl'),
`-module(imported).
-export([pick/1]).

pick(X) -> {imported, X}.
`
);
fs.writeFileSync(
path.join(dir, 'app', 'wrong.erl'),
`-module(wrong).
-export([pick/1]).

pick(X) -> {wrong, X}.
`
);
fs.writeFileSync(
path.join(dir, 'app', 'client.erl'),
`-module(client).
-import(imported, [
pick/1 % imported selectively
]).
-export([run/1]).

run(X) -> pick(X).
`
);
const edges = await callEdges(dir);
expect(edges).toContainEqual({ sq: 'client::run/1', tq: 'imported::pick/1' });
expect(edges).not.toContainEqual({ sq: 'client::run/1', tq: 'wrong::pick/1' });
});

it('does not bind an auto-imported BIF to a same-named project function', async () => {
fs.writeFileSync(
path.join(dir, 'other.erl'),
`-module(other).
-export([length/1]).

length(X) -> X.
`
);
fs.writeFileSync(
path.join(dir, 'client.erl'),
`-module(client).
-export([run/1]).

run(X) -> length(X).
`
);
const edges = await callEdges(dir);
expect(edges).not.toContainEqual({ sq: 'client::run/1', tq: 'other::length/1' });
});
});
89 changes: 89 additions & 0 deletions src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -816,11 +816,75 @@ export function extractImportMappings(
mappings.push(...extractPHPImports(content));
} else if (language === 'c' || language === 'cpp') {
mappings.push(...extractCppImports(content));
} else if (language === 'erlang') {
mappings.push(...extractErlangImports(content));
}

return mappings;
}

/**
* Extract Erlang's selective imports: `-import(module, [f/1, g/2]).`
*
* The arity stays in the local/exported name because it is part of an Erlang
* function's identity. The module name is an atom, not a filesystem path; the
* Erlang branch in resolveViaImport uses it to form the qualified name.
*/
function extractErlangImports(content: string): ImportMapping[] {
const mappings: ImportMapping[] = [];
const atom = String.raw`(?:'(?:\\.|[^'])*'|[a-z][A-Za-z0-9_@]*)`;
const importRe = new RegExp(
String.raw`^\s*-import\s*\(\s*(${atom})\s*,\s*\[([\s\S]*?)\]\s*\)\s*\.`,
'gm',
);
const bindingRe = new RegExp(String.raw`(${atom})\s*\/\s*(\d{1,3})`, 'g');
const unquoteAtom = (value: string): string => value.replace(/^'([\s\S]*)'$/, '$1');

let importMatch: RegExpExecArray | null;
while ((importMatch = importRe.exec(content)) !== null) {
const source = unquoteAtom(importMatch[1]!);
const bindings = stripErlangLineComments(importMatch[2]!);
bindingRe.lastIndex = 0;
let bindingMatch: RegExpExecArray | null;
while ((bindingMatch = bindingRe.exec(bindings)) !== null) {
const name = `${unquoteAtom(bindingMatch[1]!)}/${bindingMatch[2]}`;
mappings.push({
localName: name,
exportedName: name,
source,
isDefault: false,
isNamespace: false,
});
}
}

return mappings;
}

/** Strip `%` comments without treating a percent inside a quoted atom/string as a comment. */
function stripErlangLineComments(value: string): string {
let result = '';
let quote: "'" | '"' | null = null;
let escaped = false;
for (let i = 0; i < value.length; i++) {
const ch = value[i]!;
if (quote) {
result += ch;
if (escaped) escaped = false;
else if (ch === '\\') escaped = true;
else if (ch === quote) quote = null;
} else if (ch === "'" || ch === '"') {
quote = ch;
result += ch;
} else if (ch === '%') {
while (i + 1 < value.length && value[i + 1] !== '\n') i++;
} else {
result += ch;
}
}
return result;
}

/**
* Extract JS/TS import mappings
*/
Expand Down Expand Up @@ -1461,6 +1525,31 @@ export function resolveViaImport(
return null;
}

// Erlang selective imports name a module rather than a filesystem path, and
// the imported binding includes its arity (`-import(a, [f/1])`). Resolve the
// exact module::function/arity identity before the generic path-based import
// logic. Ambiguous duplicate module definitions are left unresolved.
if (ref.language === 'erlang' && /^.+\/\d{1,3}$/.test(ref.referenceName)) {
const imp = imports.find((candidate) => candidate.localName === ref.referenceName);
if (imp) {
const candidates = context
.getNodesByQualifiedName(`${imp.source}::${imp.exportedName}`)
.filter(
(node) =>
node.language === 'erlang' && node.kind === 'function' && node.isExported,
);
if (candidates.length === 1) {
return {
original: ref,
targetNodeId: candidates[0]!.id,
confidence: 0.95,
resolvedBy: 'import',
};
}
}
return null;
}

// Go cross-package calls: `pkga.FuncX(...)` extracts to referenceName
// `pkga.FuncX` and the import `github.com/example/myproject/pkga`
// maps to a *package directory* containing one or more .go files.
Expand Down
40 changes: 13 additions & 27 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2551,11 +2551,10 @@ export function matchReference(
// Erlang call/fun refs carry the call-site arity (`f/1` — #1610) because
// arity is part of the function's identity and every erlang function's
// qualifiedName carries it (`mod::f/1`). Resolve ONLY to a definition of
// that exact arity: the call site's own file first (a local call targets its
// own module by language semantics; `-import`ed functions ride the
// cross-file branch), and when no definition of that arity exists anywhere,
// resolve to NOTHING rather than a sibling arity — the real target may be
// macro-generated or out of repo, and a wrong-arity edge is worse than none.
// that exact arity in the call site's own module. Explicit `-import`s are
// handled by resolveViaImport before this matcher. A bare call can otherwise
// only be a local function or an auto-imported BIF, so it must never fall
// through to a same-named function in another module.
if (
ref.language === 'erlang' &&
!ref.referenceName.includes('::') &&
Expand All @@ -2565,30 +2564,17 @@ export function matchReference(
if (am) {
// endsWith is length-anchored, so `/1` cannot match `…/11`.
const arityTail = `/${am[2]}`;
const candidates = context
.getNodesByName(am[1]!)
.filter(
const sameFile = context
.getNodesInFile(ref.filePath)
.find(
(n) =>
n.language === 'erlang' && n.kind === 'function' && n.qualifiedName.endsWith(arityTail),
n.language === 'erlang' &&
n.kind === 'function' &&
n.name === am[1] &&
n.qualifiedName.endsWith(arityTail),
);
if (candidates.length > 0) {
const sameFile = candidates.find((n) => n.filePath === ref.filePath);
if (sameFile) {
return { original: ref, targetNodeId: sameFile.id, confidence: 0.95, resolvedBy: 'exact-match' };
}
if (candidates.length === 1) {
return { original: ref, targetNodeId: candidates[0]!.id, confidence: 0.8, resolvedBy: 'exact-match' };
}
const best = findBestMatch(ref, candidates, context);
if (best) {
const proximity = computePathProximity(ref.filePath, best.filePath);
return {
original: ref,
targetNodeId: best.id,
confidence: proximity >= 30 ? 0.7 : 0.4,
resolvedBy: 'exact-match',
};
}
if (sameFile) {
return { original: ref, targetNodeId: sameFile.id, confidence: 0.95, resolvedBy: 'exact-match' };
}
return null;
}
Expand Down