feat(extraction): index Erlang escripts and OTP app resource files (#635, #648) (#1169)

escripts (.escript) index like any module — the ELP grammar has a
first-class shebang node, so no source transform is needed; main/1 and its
helpers get full function/call extraction.

OTP application resource files (<app>.app.src and compiled <app>.app) join
the graph as Erlang terms the grammar parses natively. They route by full
suffix (their last-dot extension, .src, is far too generic for the
extension map). The application tuple yields structure: {mod, {Mod, _}}
links the app to its callback module — the app's entry point — and
{applications, [...]} / {included_applications, [...]} connect umbrella
sibling apps, resolving through the OTP app-name == module-name convention;
kernel/stdlib and other out-of-repo apps stay unresolved.

App-file refs resolve only ever to MODULES: validation on emqx caught the
ssl OTP-app dependency resolving to a test helper FUNCTION named ssl (the
same defect class as the earlier -behaviour gate), so the matchReference
module-only gate now covers every ref an .app/.app.src file emits.

Validated on emqx: 2 app.src + 6 escripts indexed, entry-module and
umbrella-dependency edges all namespace-targeted post-gate, escript
functions extracted; a stray legacy/module.src stays unknown.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-03 15:32:59 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent a5b8cd8e25
commit a0208feaac
7 changed files with 173 additions and 4 deletions
+20
View File
@@ -140,6 +140,10 @@ export const EXTENSION_MAP: Record<string, Language> = {
// tree-sitter-erlang grammar (the ELP grammar).
'.erl': 'erlang',
'.hrl': 'erlang',
// escripts parse natively — the grammar has a first-class `shebang` node.
// (`.app`/`.app.src` resource files route via isErlangAppFile below: their
// last-dot extension is too generic for this map.)
'.escript': 'erlang',
// Spring config: `application.properties` / `application-*.properties`. Same
// shape as the `.yml` variants — the YAML/properties extractor emits one node
// per leaf key, and the Spring resolver links `@Value("${k}")` references.
@@ -158,6 +162,7 @@ export const EXTENSION_MAP: Record<string, Language> = {
export function isSourceFile(filePath: string, overrides?: Record<string, Language>): boolean {
if (isPlayRoutesFile(filePath)) return true; // Play `conf/routes` is extensionless
if (isShopifyLiquidJson(filePath)) return true; // Shopify OS 2.0 JSON templates / section groups
if (isErlangAppFile(filePath)) return true; // OTP `.app`/`.app.src` resource files
const dot = filePath.lastIndexOf('.');
if (dot < 0) return false;
const ext = filePath.slice(dot).toLowerCase();
@@ -175,6 +180,18 @@ export function isShopifyLiquidJson(filePath: string): boolean {
return /(^|\/)(templates|sections)\/.+\.json$/i.test(filePath);
}
/**
* OTP application resource file: `<app>.app.src` (checked into every rebar3/
* erlang.mk app) or its compiled `<app>.app`. Erlang TERMS, not forms — the
* grammar parses them as top-level expressions, and the Erlang extractor's
* application-tuple handler turns `{mod, {Mod, _}}` and `{applications, […]}`
* into entry-module and dependency edges. Routed by full suffix because the
* last-dot extension (`.src`) is far too generic for EXTENSION_MAP.
*/
export function isErlangAppFile(filePath: string): boolean {
return /\.app(?:\.src)?$/i.test(filePath);
}
/**
* Play Framework routes file: the extensionless `conf/routes` (and included
* `conf/*.routes`). No grammar — route extraction is done by the Play framework
@@ -322,6 +339,9 @@ export function detectLanguage(filePath: string, source?: string, overrides?: Re
// Shopify OS 2.0 JSON templates / section groups → the Liquid extractor (it
// links each section `"type"` to its `sections/<type>.liquid`).
if (isShopifyLiquidJson(filePath)) return 'liquid';
// OTP `.app`/`.app.src` resource files — Erlang terms the grammar parses as
// top-level expressions (last-dot ext `.src` is too generic for the map).
if (isErlangAppFile(filePath)) return 'erlang';
const lang = (overrides && overrides[ext]) || EXTENSION_MAP[ext] || 'unknown';
// .h files could be C, C++, or Objective-C — check source content
+58
View File
@@ -213,6 +213,52 @@ function handleBehaviour(node: SyntaxNode, ctx: ExtractorContext): boolean {
return true;
}
/**
* OTP application resource file (`<app>.app.src` / `<app>.app`): a single
* `{application, Name, Props}.` term the grammar parses as a top-level
* expression. Two properties carry graph structure — `{mod, {Mod, _Args}}`
* names the application-callback module (the app's entry point), and
* `{applications, [...]}` / `{included_applications, [...]}` declare the apps
* this one depends on. In an umbrella repo those resolve to the sibling app's
* module of the same name (the OTP convention); kernel/stdlib and other
* out-of-repo apps stay unresolved.
*/
function handleAppResourceTuple(node: SyntaxNode, ctx: ExtractorContext): boolean {
const parentId = ctx.nodeStack[ctx.nodeStack.length - 1];
const props = node.namedChildren[2];
if (!parentId || props?.type !== 'list') return true;
const ref = (nameNode: SyntaxNode, kind: 'references' | 'imports'): void => {
const name = atomText(nameNode, ctx.source);
if (!name) return;
ctx.addUnresolvedReference({
fromNodeId: parentId,
referenceName: name,
referenceKind: kind,
line: nameNode.startPosition.row + 1,
column: nameNode.startPosition.column,
});
};
for (const prop of props.namedChildren) {
if (prop.type !== 'tuple' || prop.namedChildren.length < 2) continue;
const key = prop.namedChildren[0];
const value = prop.namedChildren[1];
if (!key || key.type !== 'atom' || !value) continue;
const keyName = atomText(key, ctx.source);
if (keyName === 'mod' && value.type === 'tuple') {
const mod = value.namedChildren[0];
if (mod?.type === 'atom') ref(mod, 'references');
} else if (
(keyName === 'applications' || keyName === 'included_applications') &&
value.type === 'list'
) {
for (const app of value.namedChildren) {
if (app.type === 'atom') ref(app, 'imports');
}
}
}
return true; // nothing else in an app term carries graph structure
}
export const erlangExtractor: LanguageExtractor = {
functionTypes: ['fun_decl'], // dispatched via visitNode (name lives on the clause)
classTypes: [],
@@ -282,6 +328,18 @@ export const erlangExtractor: LanguageExtractor = {
case 'spec':
case 'callback':
return true;
// `{application, Name, Props}.` at the top of an .app/.app.src resource
// file (never a valid form in a module, so the gate is file + position).
case 'tuple':
if (
node.parent?.type === 'source_file' &&
/\.app(?:\.src)?$/i.test(ctx.filePath) &&
node.namedChildren[0]?.type === 'atom' &&
atomText(node.namedChildren[0]!, ctx.source) === 'application'
) {
return handleAppResourceTuple(node, ctx);
}
return false;
default:
return false;
}
+8 -2
View File
@@ -1758,8 +1758,14 @@ export function matchReference(
// `-behaviour(supervisor)` resolved to a `-define(supervisor, …)` macro
// constant in an unrelated app. Resolve only to the behaviour module's
// namespace; an out-of-repo behaviour (OTP's gen_server/supervisor) stays
// unresolved rather than guessed.
if (ref.language === 'erlang' && ref.referenceKind === 'implements') {
// unresolved rather than guessed. The same module-only rule applies to every
// ref an `.app`/`.app.src` resource file emits — its `{mod, …}` callback and
// `{applications, …}` dependency names can only mean modules, and on emqx
// the `ssl` OTP app otherwise resolved to a test helper FUNCTION named ssl.
if (
ref.language === 'erlang' &&
(ref.referenceKind === 'implements' || /\.app(?:\.src)?$/i.test(ref.filePath))
) {
const modules = context
.getNodesByName(ref.referenceName)
.filter((n) => n.language === 'erlang' && n.kind === 'namespace');