`, `
`) must NOT become component references.
const htmlNodes = cg.getNodesByKind('class').filter((n) => n.name === 'div' || n.name === 'input');
expect(htmlNodes.length, 'no node for HTML elements').toBe(0);
});
it('C# namespaces qualify type names so same-named types are distinct', async () => {
fs.writeFileSync(path.join(tempDir, 'entity.cs'), `namespace App.Entities { public class CatalogBrand { } }`);
fs.writeFileSync(path.join(tempDir, 'dto.cs'), `namespace App.Models { public class CatalogBrand { } }`);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
const brands = cg.getNodesByKind('class').filter((n) => n.name === 'CatalogBrand');
expect(brands.length, 'both CatalogBrand classes indexed').toBe(2);
const qns = brands.map((b) => b.qualifiedName).sort();
expect(qns[0]).not.toBe(qns[1]); // distinct qualified names (namespace-scoped)
expect(qns.some((q) => q.includes('Entities') && q.endsWith('CatalogBrand'))).toBe(true);
expect(qns.some((q) => q.includes('Models') && q.endsWith('CatalogBrand'))).toBe(true);
});
it('disambiguates a Razor type ref via @using (incl. folder _Imports.razor)', async () => {
// `CatalogBrand` exists as both a domain entity and a DTO; the component
// `@using`s the DTO's namespace (here via the folder _Imports.razor), so the
// ref must resolve to the DTO, not the same-named entity.
fs.mkdirSync(path.join(tempDir, 'Models'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'Entities'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'Pages'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'Models/CatalogBrand.cs'), `namespace App.Models { public class CatalogBrand { public int Id { get; set; } } }`);
fs.writeFileSync(path.join(tempDir, 'Entities/CatalogBrand.cs'), `namespace App.Entities { public class CatalogBrand { public int Id { get; set; } } }`);
fs.writeFileSync(path.join(tempDir, 'Pages/_Imports.razor'), `@using App.Models\n`);
fs.writeFileSync(
path.join(tempDir, 'Pages/List.razor'),
`
List
\n@code {\n private CatalogBrand _b = new CatalogBrand();\n}\n`
);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
const dto = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'App.Models::CatalogBrand');
const entity = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'App.Entities::CatalogBrand');
expect(dto && entity, 'both CatalogBrand classes').toBeTruthy();
const dtoDeps = [...cg.getImpactRadius(dto!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
const entityDeps = [...cg.getImpactRadius(entity!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
expect(dtoDeps.some((p) => p.endsWith('List.razor')), 'resolves to the @using\'d DTO').toBe(true);
expect(entityDeps.some((p) => p.endsWith('List.razor')), 'NOT the same-named entity').toBe(false);
});
it('delegates Blazor @code block C# to cover types used in component logic', async () => {
fs.writeFileSync(
path.join(tempDir, 'CatalogService.cs'),
`namespace App { public class CatalogService { public void Load() { } } }`
);
fs.writeFileSync(
path.join(tempDir, 'List.razor'),
`
Catalog
\n\n@code {\n private CatalogService _svc = new CatalogService();\n void Refresh() { _svc.Load(); }\n}\n`
);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
const svc = cg.getNodesByKind('class').find((n) => n.name === 'CatalogService');
expect(svc, 'CatalogService class').toBeDefined();
const deps = [...cg.getImpactRadius(svc!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
expect(deps.some((p) => p.endsWith('List.razor')), '@code usage links the component to the service').toBe(true);
});
});
describe('Default import resolution (renamed default export)', () => {
let tempDir: string;
let cg: CodeGraph;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
if (cg) cg.close();
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
});
it('links a renamed default import to the module file', async () => {
// Express route aggregator: `import articlesController from './controller'`
// where the module does `export default router`. The renamed local can't be
// found as a symbol, so the controller file had no dependent β the dependency
// is on the module file regardless of the default export's name.
fs.mkdirSync(path.join(tempDir, 'app'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'app/controller.ts'), `const router = { get() {} };\nexport default router;\n`);
fs.writeFileSync(path.join(tempDir, 'app/routes.ts'), `import myController from './controller';\nexport const api = myController;\n`);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
const controller = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('app/controller.ts'));
expect(controller, 'controller.ts indexed').toBeDefined();
const deps = [...cg.getImpactRadius(controller!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
expect(deps.some((p) => p.endsWith('routes.ts')), 'importer depends on the default-exporting module').toBe(true);
});
});
describe('Chained method-call resolution (C# extension methods)', () => {
let tempDir: string;
let cg: CodeGraph;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
if (cg) cg.close();
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
});
it('resolves a chained extension-method call (a.b.Method()) to its definition', async () => {
// ASP.NET DI registration: `builder.Services.AddCoreServices(...)` calls a
// static extension method elsewhere. A multi-dot receiver chain matched no
// method-call pattern before, so the extension method had no caller.
fs.mkdirSync(path.join(tempDir, 'cfg'), { recursive: true });
fs.writeFileSync(
path.join(tempDir, 'cfg/Ext.cs'),
`namespace App {\n public static class Ext {\n public static object AddCoreServices(this object services, int x) { return services; }\n }\n}\n`
);
fs.writeFileSync(
path.join(tempDir, 'Program.cs'),
`namespace App {\n public class Program {\n public void Run(object builder) {\n builder.Services.AddCoreServices(1);\n }\n }\n}\n`
);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
const ext = cg
.getNodesByKind('method')
.find((n) => n.name === 'AddCoreServices')
?? cg.getNodesByKind('function').find((n) => n.name === 'AddCoreServices');
expect(ext, 'AddCoreServices defined').toBeDefined();
const callers = [...cg.getImpactRadius(ext!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
expect(callers.some((p) => p.endsWith('Program.cs')), 'chained extension call resolves to its definition').toBe(true);
});
});
describe('Same-directory include + KMP import resolution', () => {
let tempDir: string;
let cg: CodeGraph;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
if (cg) cg.close();
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
});
it('a C/C++ #include resolves to the same-directory header, not a same-named one elsewhere', async () => {
// A multi-platform native module has a header of the same basename per
// platform. `windows/Provider.cpp`'s `#include "Storage.h"` means its OWN
// sibling header β not `apple/Storage.h` (which sorts first and so was
// picked arbitrarily before, leaving the real local header with 0 deps).
fs.mkdirSync(path.join(tempDir, 'apple'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'windows'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'apple', 'Storage.h'), `#pragma once\nstruct Storage { int n; };\n`);
fs.writeFileSync(path.join(tempDir, 'windows', 'Storage.h'), `#pragma once\nstruct Storage { int n; };\n`);
fs.writeFileSync(
path.join(tempDir, 'windows', 'Provider.cpp'),
`#include "Storage.h"\nint use() { Storage s; return s.n; }\n`
);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
const winHeader = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('windows/Storage.h'));
const appleHeader = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('apple/Storage.h'));
expect(winHeader, 'windows/Storage.h indexed').toBeDefined();
expect(appleHeader, 'apple/Storage.h indexed').toBeDefined();
const winDeps = [...cg.getImpactRadius(winHeader!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
const appleDeps = [...cg.getImpactRadius(appleHeader!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
expect(winDeps.some((p) => p.endsWith('Provider.cpp')), 'same-dir header gets the includer').toBe(true);
expect(appleDeps.some((p) => p.endsWith('Provider.cpp')), 'other-platform header does NOT').toBe(false);
});
it('a Kotlin Multiplatform commonMain import resolves to the expect, not a platform actual', async () => {
const common = path.join(tempDir, 'src/commonMain/kotlin/app');
const android = path.join(tempDir, 'src/androidMain/kotlin/app');
fs.mkdirSync(common, { recursive: true });
fs.mkdirSync(android, { recursive: true });
fs.writeFileSync(path.join(common, 'Platform.kt'), `package app\nexpect class PlatformContext\n`);
fs.writeFileSync(path.join(android, 'Platform.android.kt'), `package app\nactual class PlatformContext\n`);
fs.writeFileSync(
path.join(common, 'Db.kt'),
`package app\nimport app.PlatformContext\nclass Db {\n fun open(ctx: PlatformContext) {}\n}\n`
);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
const expectCtx = cg
.getNodesByKind('class')
.find((n) => n.name === 'PlatformContext' && n.filePath.endsWith('commonMain/kotlin/app/Platform.kt'));
expect(expectCtx, 'commonMain expect PlatformContext').toBeDefined();
const deps = [...cg.getImpactRadius(expectCtx!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
expect(deps.some((p) => p.endsWith('Db.kt')), 'commonMain import lands on the expect, not the actual').toBe(true);
});
});
describe('Delphi form code-behind pairing', () => {
let tempDir: string;
let cg: CodeGraph;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
if (cg) cg.close();
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
});
it('links a `.dfm` form to its sibling `.pas` code-behind unit', async () => {
// A Delphi form unit owns its visual form definition via `{$R *.dfm}`, not a
// `uses` clause β so a `.dfm` used only as a form definition looked orphaned.
fs.writeFileSync(path.join(tempDir, 'UFRMAbout.dfm'),
`object FRMAbout: TFRMAbout\n Caption = 'About'\nend\n`);
fs.writeFileSync(path.join(tempDir, 'UFRMAbout.pas'),
`unit UFRMAbout;\ninterface\nuses Forms;\ntype\n TFRMAbout = class(TForm)\n end;\nimplementation\n{$R *.dfm}\nend.\n`);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
const dfm = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('UFRMAbout.dfm'));
expect(dfm, 'UFRMAbout.dfm file node').toBeDefined();
const deps = cg.getFileDependents(dfm!.filePath);
expect(deps.some((p) => p.endsWith('UFRMAbout.pas')), 'the .pas unit links its .dfm form').toBe(true);
});
});
describe('Liquid Shopify JSON template section resolution', () => {
let tempDir: string;
let cg: CodeGraph;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
if (cg) cg.close();
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
});
it('resolves Liquid block snippets and sections without linking commented references', async () => {
for (const dir of ['sections', 'snippets', 'layout']) fs.mkdirSync(path.join(tempDir, dir));
for (const file of ['snippets/card.liquid', 'snippets/legacy.liquid', 'snippets/ghost.liquid', 'sections/footer.liquid']) {
fs.writeFileSync(path.join(tempDir, file), '
content
');
}
fs.writeFileSync(path.join(tempDir, 'layout/theme.liquid'), [
'{% liquid', " assign heading = 'x'", " render 'card'", " include 'legacy'",
" section 'footer'", ' comment', " render 'ghost'", ' endcomment', '%}',
'{% raw %}', "{% render 'ghost' %}", '{% endraw %}',
].join('\n'));
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
for (const name of ['card', 'legacy', 'footer', 'ghost']) {
const file = cg.getNodesByKind('file').find((n) => n.name === `${name}.liquid`)!;
expect(file).toBeDefined();
expect(cg.getFileDependents(file.filePath).some((p) => p.endsWith('layout/theme.liquid')))
.toBe(name !== 'ghost');
}
expect(cg.getNodesByKind('variable').some((n) => n.name === 'heading')).toBe(true);
});
it('indexes capture declarations as variables that search finds, in or out of a Shopify theme', async () => {
// A captured variable could not be found, while an assigned one could (#2498).
fs.mkdirSync(path.join(tempDir, '_includes'), { recursive: true });
fs.writeFileSync(path.join(tempDir, '_includes/card.liquid'), [
"{% assign existing = 'ok' %}",
'{% capture captured %}Hello{% endcapture %}',
'{% liquid', ' capture bare_capture', " echo 'x'", ' endcapture', '%}',
].join('\n'));
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
for (const [name, line] of [['existing', 1], ['captured', 2], ['bare_capture', 4]] as const) {
const hit = cg.searchNodes(name, { kinds: ['variable'] }).find((r) => r.node.name === name);
expect(hit?.node, name).toMatchObject({ filePath: '_includes/card.liquid', language: 'liquid', startLine: line });
expect(cg.getIncomingEdges(hit!.node.id)).toContainEqual(expect.objectContaining({ kind: 'contains' }));
}
});
it('links a Shopify JSON template section `type` to its sections/
.liquid', async () => {
// Shopify OS 2.0 templates are JSON, referencing sections by `type` β not
// a `{% section %}` Liquid tag β so a section used only from a JSON template
// looked unused. The JSON is now indexed and its `type`s linked.
fs.mkdirSync(path.join(tempDir, 'sections'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'templates/customers'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'layout'), { recursive: true });
// Every Shopify theme has layout/theme.liquid beside templates/ and sections/.
fs.writeFileSync(path.join(tempDir, 'layout/theme.liquid'), `{{ content_for_layout }}\n`);
fs.writeFileSync(path.join(tempDir, 'sections/main-product.liquid'), `{{ product.title }}
\n`);
fs.writeFileSync(path.join(tempDir, 'sections/main-login.liquid'), `\n`);
fs.writeFileSync(path.join(tempDir, 'templates/product.json'), JSON.stringify({ sections: { main: { type: 'main-product' } }, order: ['main'] }));
// Nested template dir (templates/customers/login.json) must resolve too.
fs.writeFileSync(path.join(tempDir, 'templates/customers/login.json'), JSON.stringify({ sections: { main: { type: 'main-login' } }, order: ['main'] }));
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
const product = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('sections/main-product.liquid'));
const login = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('sections/main-login.liquid'));
expect(product, 'main-product section').toBeDefined();
expect(login, 'main-login section').toBeDefined();
expect(cg.getFileDependents(product!.filePath).some((p) => p.endsWith('templates/product.json')), 'top-level JSON template links its section').toBe(true);
expect(cg.getFileDependents(login!.filePath).some((p) => p.endsWith('customers/login.json')), 'nested JSON template links its section').toBe(true);
});
const jsonTemplate = (type: string): string => JSON.stringify({ sections: { main: { type } }, order: ['main'] });
const indexedJson = (): string[] =>
cg.getNodesByKind('file').map((n) => n.filePath).filter((p) => p.endsWith('.json')).sort();
it('leaves JSON under templates/ or sections/ out when no Shopify theme holds it', async () => {
// jasontaylordev/CleanArchitecture ships a .NET project template under
// templates/, and a CMS keeps its content under sections/: JSON in folders
// with those names, but nothing beside them makes a Shopify theme, so it is
// not Liquid β even when a Liquid file is named like one of its "types".
fs.mkdirSync(path.join(tempDir, 'templates/ca-use-case/.template.config'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'sections'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'templates/ca-use-case/.template.config/dotnetcli.host.json'), JSON.stringify({ symbolInfo: { UseCase: { longName: 'name' } } }));
fs.writeFileSync(path.join(tempDir, 'templates/ca-use-case/.template.config/template.json'), JSON.stringify({ identity: 'CleanArchitecture.UseCase', sections: { main: { type: 'hero' } } }));
fs.writeFileSync(path.join(tempDir, 'sections/home.json'), jsonTemplate('hero'));
fs.writeFileSync(path.join(tempDir, 'sections/hero.liquid'), `{{ page.title }}
\n`);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
expect(indexedJson()).toEqual([]);
const hero = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('sections/hero.liquid'));
expect(hero, 'the Liquid file itself is still indexed').toBeDefined();
expect(cg.getFileDependents(hero!.filePath)).toEqual([]);
});
it('finds a Shopify theme in a subdirectory by its config/settings_schema.json', async () => {
// A theme under theme/ with no layout/theme.liquid committed: its settings
// schema marks it. The same JSON under tools/templates/ is in no theme.
for (const dir of ['theme/config', 'theme/sections', 'theme/templates', 'tools/templates']) {
fs.mkdirSync(path.join(tempDir, dir), { recursive: true });
}
fs.writeFileSync(path.join(tempDir, 'theme/config/settings_schema.json'), JSON.stringify([{ name: 'theme_info' }]));
fs.writeFileSync(path.join(tempDir, 'theme/sections/main-product.liquid'), `{{ product.title }}
\n`);
fs.writeFileSync(path.join(tempDir, 'theme/templates/product.json'), jsonTemplate('main-product'));
fs.writeFileSync(path.join(tempDir, 'tools/templates/product.json'), jsonTemplate('main-product'));
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
expect(indexedJson()).toEqual(['theme/templates/product.json']);
expect(cg.getFileDependents('theme/sections/main-product.liquid')).toEqual(['theme/templates/product.json']);
// Extracting without storing tells them apart the same way.
const refsOf = (file: string) => cg.extractFromSource(file, jsonTemplate('main-product')).unresolvedReferences.map((r) => r.referenceName);
expect(refsOf('theme/templates/product.json')).toEqual(['sections/main-product.liquid']);
expect(refsOf('tools/templates/product.json')).toEqual([]);
});
it('drops JSON templates once their folder is no longer a theme, and takes them back when it is', async () => {
// The same path an index built before this check takes: JSON it stored as
// Liquid outside any theme leaves on the next sync, without a re-index.
for (const dir of ['layout', 'sections', 'templates']) fs.mkdirSync(path.join(tempDir, dir), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'layout/theme.liquid'), `{{ content_for_layout }}\n`);
fs.writeFileSync(path.join(tempDir, 'sections/main-product.liquid'), `{{ product.title }}
\n`);
fs.writeFileSync(path.join(tempDir, 'templates/page.json'), jsonTemplate('main-product'));
fs.writeFileSync(path.join(tempDir, 'templates/product.json'), jsonTemplate('main-product'));
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
expect(indexedJson()).toEqual(['templates/page.json', 'templates/product.json']);
fs.rmSync(path.join(tempDir, 'layout'), { recursive: true, force: true });
// A sync of only the reported paths (the watcher's) and a full sync agree.
await cg.sync({ paths: ['templates/product.json'] });
expect(indexedJson()).toEqual(['templates/page.json']);
await cg.sync();
expect(indexedJson()).toEqual([]);
fs.mkdirSync(path.join(tempDir, 'config'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'config/settings_schema.json'), '[]');
await cg.sync();
expect(indexedJson()).toEqual(['templates/page.json', 'templates/product.json']);
});
it('reads JSON under templates/ or sections/ as Liquid only inside a theme under the project root', () => {
fs.mkdirSync(path.join(tempDir, 'shop/layout'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'shop/layout/theme.liquid'), '');
for (const file of ['shop/templates/product.json', 'shop/templates/customers/login.json', 'shop/sections/header-group.json']) {
expect(isSourceFile(file, undefined, tempDir), file).toBe(true);
expect(detectLanguage(file, undefined, undefined, tempDir), file).toBe('liquid');
// Without the project root there is no theme to look for.
expect(isSourceFile(file), file).toBe(false);
expect(detectLanguage(file), file).toBe('unknown');
}
// Beside the theme, not in it; and the theme's other JSON was never a template.
for (const file of ['templates/product.json', 'other/sections/header-group.json', 'shop/config/settings_data.json']) {
expect(isSourceFile(file, undefined, tempDir), file).toBe(false);
expect(detectLanguage(file, undefined, undefined, tempDir), file).toBe('unknown');
}
// Outside a theme, JSON gets whatever the project maps `.json` to, as any other JSON does.
expect(detectLanguage('templates/product.json', undefined, { '.json': 'yaml' }, tempDir)).toBe('yaml');
expect(detectLanguage('shop/templates/product.json', undefined, { '.json': 'yaml' }, tempDir)).toBe('liquid');
});
const writeFiles = (files: Record, root = tempDir): void => {
for (const [file, text] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
fs.writeFileSync(path.join(root, file), text);
}
};
// A theme at the project root, a theme in a folder marked only by its settings
// schema, and one in a folder with no 404 section and no price snippet of its
// own β the shape of panoply/syncify's example themes.
const severalThemes = {
'layout/theme.liquid': `{% section 'header' %}{{ content_for_layout }}\n`,
'sections/header.liquid': `{% render 'price' %}\n`,
'sections/main.liquid': `\n`,
'snippets/price.liquid': `{{ product.price | money }}\n`,
'straps/dusk/config/settings_schema.json': '[]',
'straps/dusk/sections/404.liquid': `{{ 'templates.404.title' | t }}
\n`,
'straps/dusk/templates/404.json': jsonTemplate('404'),
'examples/tailwind/layout/theme.liquid': `{% section 'header' %}{{ content_for_layout }}\n`,
'examples/tailwind/sections/header.liquid': `{% liquid\n render 'price'\n render 'icon'\n%}\n`,
'examples/tailwind/sections/main.liquid': `\n`,
'examples/tailwind/snippets/icon.liquid': `\n`,
'examples/tailwind/templates/index.json': jsonTemplate('main'),
'examples/tailwind/templates/404.json': jsonTemplate('404'),
// Liquid in no theme: an app's theme extension.
'extensions/reviews/blocks/stars.liquid': `{% render 'star' %}\n`,
'extensions/reviews/snippets/star.liquid': `\n`,
};
it("links a theme's sections and snippets only inside that theme", async () => {
// Shopify looks a `{% render %}`, a `{% section %}` or a JSON template's
// section `type` up in the theme the file belongs to, never in another one.
// Matched by path tail, a theme without the 404 section linked to another
// theme's, and a theme in a folder even linked the project root's
// sections/header.liquid over its own.
writeFiles(severalThemes);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
const linksOf = (file: string): string[] => cg.getFileDependencies(file).sort();
expect(linksOf('layout/theme.liquid')).toEqual(['sections/header.liquid']);
expect(linksOf('sections/header.liquid')).toEqual(['snippets/price.liquid']);
expect(linksOf('straps/dusk/templates/404.json')).toEqual(['straps/dusk/sections/404.liquid']);
expect(linksOf('examples/tailwind/layout/theme.liquid')).toEqual(['examples/tailwind/sections/header.liquid']);
expect(linksOf('examples/tailwind/templates/index.json')).toEqual(['examples/tailwind/sections/main.liquid']);
// What the theme lacks stays unlinked: no other theme's 404 section or price snippet.
expect(linksOf('examples/tailwind/templates/404.json')).toEqual([]);
expect(linksOf('examples/tailwind/sections/header.liquid')).toEqual(['examples/tailwind/snippets/icon.liquid']);
// Outside any theme, a path still resolves as before.
expect(linksOf('extensions/reviews/blocks/stars.liquid')).toEqual(['extensions/reviews/snippets/star.liquid']);
});
it("keeps a theme's references inside it when its own section is deleted", async () => {
// Sync resolves again what named a deleted file; another theme's 404
// section is no stand-in for the theme's own.
writeFiles({ ...severalThemes, 'examples/tailwind/sections/404.liquid': `404
\n` });
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
expect(cg.getFileDependencies('examples/tailwind/templates/404.json')).toEqual(['examples/tailwind/sections/404.liquid']);
fs.rmSync(path.join(tempDir, 'examples/tailwind/sections/404.liquid'));
await cg.sync();
expect(cg.getFileDependencies('examples/tailwind/templates/404.json')).toEqual([]);
});
it('links a section or snippet that appears after the files naming it', async () => {
// Sync retries a reference it could not resolve once a file it may name
// appears. A section or snippet reference was parked under its extension,
// `liquid`, which no file is named, so it stayed unlinked until the file
// naming it changed or the project was indexed again.
const later: Record = {
'sections/404.liquid': `404
\n`,
'snippets/price.liquid': severalThemes['snippets/price.liquid'],
// Only straps/dusk had a 404 section, which is no stand-in for this theme's own.
'examples/tailwind/sections/404.liquid': `404
\n`,
'extensions/reviews/snippets/star.liquid': severalThemes['extensions/reviews/snippets/star.liquid'],
};
const initial = Object.fromEntries(Object.entries(severalThemes).filter(([file]) => !(file in later)));
writeFiles({ ...initial, 'templates/404.json': jsonTemplate('404') });
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
const naming = ['templates/404.json', 'sections/header.liquid', 'examples/tailwind/templates/404.json',
'examples/tailwind/sections/header.liquid', 'extensions/reviews/blocks/stars.liquid'];
const links = (graph = cg) => Object.fromEntries(naming.map((file) => [file, graph.getFileDependencies(file).sort()]));
expect(links()).toEqual({
'templates/404.json': [],
'sections/header.liquid': [],
'examples/tailwind/templates/404.json': [],
'examples/tailwind/sections/header.liquid': ['examples/tailwind/snippets/icon.liquid'],
'extensions/reviews/blocks/stars.liquid': [],
});
writeFiles(later);
expect((await cg.sync()).filesAdded).toBe(4);
const synced = links();
expect(synced).toEqual({
'templates/404.json': ['sections/404.liquid'],
'sections/header.liquid': ['snippets/price.liquid'],
'examples/tailwind/templates/404.json': ['examples/tailwind/sections/404.liquid'],
// The root theme's new price snippet is not this theme's.
'examples/tailwind/sections/header.liquid': ['examples/tailwind/snippets/icon.liquid'],
'extensions/reviews/blocks/stars.liquid': ['extensions/reviews/snippets/star.liquid'],
});
expect(cg.getPendingReferenceCount()).toBe(0);
// A fresh index of the same files links the same. (Indexing again over
// this index would skip the unchanged files that name the new ones.)
const freshDir = createTempDir();
writeFiles({ ...severalThemes, 'templates/404.json': jsonTemplate('404'), ...later }, freshDir);
const fresh = CodeGraph.initSync(freshDir);
try {
await fresh.indexAll();
expect(links(fresh)).toEqual(synced);
} finally {
fresh.close();
fs.rmSync(freshDir, { recursive: true, force: true });
}
// A deleted snippet's references wait for it the same way.
fs.rmSync(path.join(tempDir, 'snippets/price.liquid'));
await cg.sync();
expect(cg.getFileDependencies('sections/header.liquid')).toEqual([]);
writeFiles({ 'snippets/price.liquid': later['snippets/price.liquid']! });
await cg.sync();
expect(cg.getFileDependencies('sections/header.liquid')).toEqual(['snippets/price.liquid']);
}, 60_000);
// The notice Shopify's theme editor writes at the top of every JSON template
// and section group it saves.
const editorNotice = [
'/*',
' * ------------------------------------------------------------',
' * IMPORTANT: The contents of this file are auto-generated.',
' * ------------------------------------------------------------',
' */',
];
const referencesFrom = (file: string): Array<[string | undefined, number | undefined, number | undefined]> => {
const fileNode = cg.getNodesInFile(file).find((n) => n.kind === 'file')!;
return cg.getOutgoingEdges(fileNode.id)
.filter((e) => e.kind === 'references')
.map((e): [string | undefined, number | undefined, number | undefined] => [cg.getNode(e.target)?.filePath, e.line, e.column])
.sort((a, b) => a[1]! - b[1]!);
};
it.each(['\n', '\r\n'])('links the sections an editor-saved template and section group list, where each `type` is (%j)', async (newline) => {
// JSON.parse rejects the editor's comment, so such a file linked none of
// its sections, and a file without one linked them at line 1 (#2494).
writeFiles({
'layout/theme.liquid': `{% sections 'header-group' %}{{ content_for_layout }}\n`,
'sections/header-group.json': [
...editorNotice, '{', ' "type": "header",', ' "name": "Header",', ' "sections": {',
' "bar": { "type": "announcement-bar" },', ' "header": {', ' "type": "header"', ' }',
' },', ' "order": ["bar", "header"]', '}',
].join(newline),
'sections/announcement-bar.liquid': `\n`,
'sections/header.liquid': `\n`,
'sections/main-product.liquid': `{{ product.title }}
\n`,
'templates/product.json': [
...editorNotice, '{', ' "sections": {', ' "main": {', ' "type": "main-product",',
' "settings": { "note": "https://example.com/*not-a-comment*/ // nor this" }', ' },',
' "again": { "type": "main-product" }', ' },', ' "order": ["main", "again"]', '}',
].join(newline),
'templates/index.json': `{\n "sections": {\n "main": { "type": "header" }\n },\n "order": ["main"]\n}\n`,
});
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
expect(referencesFrom('templates/product.json')).toEqual([['sections/main-product.liquid', 9, 14]]);
expect(referencesFrom('sections/header-group.json')).toEqual([
['sections/announcement-bar.liquid', 10, 21],
['sections/header.liquid', 12, 14],
]);
expect(referencesFrom('templates/index.json')).toEqual([['sections/header.liquid', 3, 22]]);
expect(referencesFrom('layout/theme.liquid')).toEqual([['sections/header-group.json', 1, 0]]);
});
it('reads a JSON template with trailing commas, which Shopify accepts in theme JSON', () => {
const text = '// a note\n{"sections": {"main": {"type": "main-product",}, "hero": {"type": "hero"},}, "order": ["main", "hero",],}';
expect(extractFromSource('templates/product.json', text, 'liquid').unresolvedReferences.map((r) => r.referenceName))
.toEqual(['sections/main-product.liquid', 'sections/hero.liquid']);
});
it.each([
['a missing brace', '{"sections": {"main": {"type": "main-product"}}'],
['an unclosed comment', '/* saved by the editor {"sections": {"main": {"type": "main-product"}}}'],
['a missing comma', '{"sections": {"main": {"type": "main-product"} "hero": {"type": "hero"}}}'],
])('links nothing from a JSON template with %s', (_case, text) => {
expect(extractFromSource('templates/product.json', text, 'liquid').unresolvedReferences).toEqual([]);
});
it("links a layout's section groups inside its own theme, and on to the sections they list", async () => {
// `{% sections 'header-group' %}` renders the theme's
// sections/header-group.json, whose sections render snippets (#2494).
writeFiles({
...severalThemes,
'layout/theme.liquid': `{% sections 'header-group' %}{{ content_for_layout }}\n`,
'sections/header-group.json': [...editorNotice, jsonTemplate('header')].join('\n'),
// This theme has no header group of its own, and the root theme's is not its.
'examples/tailwind/layout/theme.liquid': `{% liquid\n sections 'header-group'\n%}{{ content_for_layout }}\n`,
});
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
const linksOf = (file: string): string[] => cg.getFileDependencies(file).sort();
expect(linksOf('layout/theme.liquid')).toEqual(['sections/header-group.json']);
expect(linksOf('sections/header-group.json')).toEqual(['sections/header.liquid']);
expect(linksOf('sections/header.liquid')).toEqual(['snippets/price.liquid']);
expect(linksOf('examples/tailwind/layout/theme.liquid')).toEqual([]);
// Once the theme has a header group of its own, sync links the layout to it.
writeFiles({ 'examples/tailwind/sections/header-group.json': [...editorNotice, jsonTemplate('header')].join('\n') });
await cg.sync();
expect(linksOf('examples/tailwind/layout/theme.liquid')).toEqual(['examples/tailwind/sections/header-group.json']);
expect(linksOf('examples/tailwind/sections/header-group.json')).toEqual(['examples/tailwind/sections/header.liquid']);
}, 60_000);
it('finds the theme a file is in from the theme folder it sits in', () => {
const markers = new Set(['layout/theme.liquid', 'examples/tailwind/config/settings_schema.json']);
const exists = (relativePath: string): boolean => markers.has(relativePath);
expect(shopifyThemeRoot('sections/header.liquid', exists)).toBe('');
expect(shopifyThemeRoot('templates/customers/login.json', exists)).toBe('');
expect(shopifyThemeRoot('examples/tailwind/snippets/icon.liquid', exists)).toBe('examples/tailwind');
// A copy kept elsewhere under the root theme is not part of it, nor is a file beside its folders.
expect(shopifyThemeRoot('vendor/legacy/snippets/icon.liquid', exists)).toBeUndefined();
expect(shopifyThemeRoot('theme.liquid', exists)).toBeUndefined();
});
});
describe('Lua/Luau require resolution', () => {
let tempDir: string;
let cg: CodeGraph;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
if (cg) cg.close();
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
});
it('resolves a dotted Lua require and an instance-path Luau require to their module files', async () => {
// The require is the ONLY link (no method call), so coverage here proves the
// require resolver specifically, not method-call name-matching.
// Lua dotted module path: require("myapp.config") β lua/myapp/config.lua.
fs.mkdirSync(path.join(tempDir, 'lua/myapp'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'lua/myapp/config.lua'), `local M = {}\nfunction M.setup() end\nreturn M\n`);
fs.writeFileSync(path.join(tempDir, 'lua/myapp/init.lua'), `local config = require("myapp.config")\nreturn config\n`);
// Luau Roblox instance-path require (only the leaf survives extraction):
// require(script.Util.helper) β src/Util/helper.luau.
fs.mkdirSync(path.join(tempDir, 'src/Util'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'src/Util/helper.luau'), `local H = {}\nfunction H.go() end\nreturn H\n`);
fs.writeFileSync(path.join(tempDir, 'src/init.luau'), `local helper = require(script.Util.helper)\nreturn helper\n`);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
const config = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('myapp/config.lua'));
const helper = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('Util/helper.luau'));
expect(config, 'config.lua file node').toBeDefined();
expect(helper, 'helper.luau file node').toBeDefined();
const cfgDeps = cg.getFileDependents(config!.filePath);
const helpDeps = cg.getFileDependents(helper!.filePath);
expect(cfgDeps.some((p) => p.endsWith('myapp/init.lua')), 'dotted Lua require resolves to the module').toBe(true);
expect(helpDeps.some((p) => p.endsWith('src/init.luau')), 'instance-path Luau require resolves to the module').toBe(true);
});
});
describe('Rust module-path call resolution', () => {
let tempDir: string;
let cg: CodeGraph;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
if (cg) cg.close();
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
});
it('a bare submodule call (`users::router()`) resolves self-relative to the submodule fn', async () => {
// The canonical Axum router-assembly pattern: a parent module calls each
// submodule's `router()`. `users::` / `profiles::` are SELF-relative
// submodule prefixes (2018 edition) β `mod users;` makes `users` a child of
// the CURRENT module, NOT `crate::users`. Before the fix the bare prefix was
// resolved crate-relative only (looking for `src/users.rs`), so it found
// nothing and the handler modules looked dependent-less.
const http = path.join(tempDir, 'src/http');
fs.mkdirSync(http, { recursive: true });
fs.writeFileSync(path.join(tempDir, 'src/lib.rs'), `pub mod http;\n`);
fs.writeFileSync(
path.join(http, 'mod.rs'),
`mod users;\nmod profiles;\npub fn api_router() {\n users::router();\n profiles::router();\n}\n`
);
fs.writeFileSync(path.join(http, 'users.rs'), `pub fn router() -> i32 { 1 }\n`);
fs.writeFileSync(path.join(http, 'profiles.rs'), `pub fn router() -> i32 { 2 }\n`);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
// Each submodule's same-named `router` fn must get mod.rs as a dependent β
// proving the bare prefix resolved self-relative AND disambiguated the
// colliding `router` name to the correct file (not an arbitrary one).
const routers = cg.getNodesByKind('function').filter((n) => n.name === 'router');
const usersRouter = routers.find((n) => n.filePath.endsWith('http/users.rs'));
const profilesRouter = routers.find((n) => n.filePath.endsWith('http/profiles.rs'));
expect(usersRouter, 'users.rs router fn').toBeDefined();
expect(profilesRouter, 'profiles.rs router fn').toBeDefined();
const usersDeps = [...cg.getImpactRadius(usersRouter!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
const profilesDeps = [...cg.getImpactRadius(profilesRouter!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
expect(usersDeps.some((p) => p.endsWith('http/mod.rs')), 'users::router() lands on users.rs').toBe(true);
expect(profilesDeps.some((p) => p.endsWith('http/mod.rs')), 'profiles::router() lands on profiles.rs').toBe(true);
});
it('a 3-segment module-path call (`database::profiles::find()`) resolves to the leaf fn', async () => {
// A 2-level module path β the common `db.run(move |c| database::profiles::find(c))`
// / `crate::a::b::func()` shape. The reference-resolver pre-filter used to drop any
// `a::b::c` whose leaf it never checked (it tested only the first segment and the
// `b::c` remainder, neither of which names a symbol), so the call never reached the
// Rust path resolver and the leaf module looked dependent-less.
const routes = path.join(tempDir, 'src/routes');
const database = path.join(tempDir, 'src/database');
fs.mkdirSync(routes, { recursive: true });
fs.mkdirSync(database, { recursive: true });
fs.writeFileSync(path.join(tempDir, 'src/lib.rs'), `pub mod routes;\npub mod database;\n`);
fs.writeFileSync(path.join(database, 'mod.rs'), `pub mod profiles;\n`);
fs.writeFileSync(path.join(database, 'profiles.rs'), `pub fn find(id: i32) -> i32 { id }\n`);
fs.writeFileSync(
path.join(routes, 'mod.rs'),
`use crate::database;\npub fn get_profile(id: i32) -> i32 {\n database::profiles::find(id)\n}\n`
);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
const find = cg
.getNodesByKind('function')
.find((n) => n.name === 'find' && n.filePath.endsWith('database/profiles.rs'));
expect(find, 'database/profiles.rs find fn').toBeDefined();
const deps = [...cg.getImpactRadius(find!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
expect(deps.some((p) => p.endsWith('routes/mod.rs')), 'database::profiles::find() resolves to the leaf fn').toBe(true);
});
it('Rocket `routes![β¦]` / `catchers![β¦]` macros link the mount to the handler fns', async () => {
// Tree-sitter leaves the macro body as a raw token tree, so the handler
// paths inside `routes![a::b::handler, β¦]` are invisible to the call walker
// and the handlers β mounted by Rocket at runtime, not called in-repo β look
// like they have no caller. The route-macro extractor reconstructs each path
// and emits a reference, which the Rust path resolver links to the handler.
const routes = path.join(tempDir, 'src/routes');
fs.mkdirSync(routes, { recursive: true });
fs.writeFileSync(path.join(tempDir, 'src/lib.rs'),
`mod routes;\nfn not_found() {}\npub fn rocket() {\n` +
` rocket::build()\n` +
` .mount("/api", routes![routes::users::post_users, routes::users::get_user])\n` +
` .register("/", catchers![not_found]);\n}\n`);
fs.writeFileSync(path.join(routes, 'mod.rs'), `pub mod users;\n`);
fs.writeFileSync(path.join(routes, 'users.rs'), `pub fn post_users() {}\npub fn get_user() {}\n`);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
const handlers = cg.getNodesByKind('function').filter((n) => n.filePath.endsWith('routes/users.rs'));
expect(handlers.length, 'both handler fns indexed').toBe(2);
for (const h of handlers) {
const deps = [...cg.getImpactRadius(h.id, 2).nodes.values()].map((n) => n.filePath ?? '');
expect(deps.some((p) => p.endsWith('lib.rs')), `routes![] links ${h.name} to its mount in lib.rs`).toBe(true);
}
});
});
describe('SvelteKit load β page synthesizer', () => {
let tempDir: string;
let cg: CodeGraph;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
if (cg) cg.close();
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
});
it('links a +page.svelte to its OWN directory\'s +page.server.js load, not another route\'s', async () => {
// SvelteKit wires +page.server.js's `load` to +page.svelte's `data` BY FILE
// PATH β there is no static import β so editing a loader showed no impact on
// the page it feeds. The synthesizer links each page component to the `load`
// in its OWN directory (path-deterministic, so it never crosses routes).
const login = path.join(tempDir, 'src/routes/login');
const register = path.join(tempDir, 'src/routes/register');
fs.mkdirSync(login, { recursive: true });
fs.mkdirSync(register, { recursive: true });
fs.writeFileSync(path.join(login, '+page.svelte'), `\nLogin {data.x}
\n`);
fs.writeFileSync(path.join(login, '+page.server.js'), `export function load() { return { x: 1 }; }\n`);
fs.writeFileSync(path.join(register, '+page.svelte'), `\nRegister
\n`);
fs.writeFileSync(path.join(register, '+page.server.js'), `export function load() { return { y: 2 }; }\n`);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
const loginLoad = cg
.getNodesByKind('function')
.find((n) => n.name === 'load' && n.filePath.endsWith('login/+page.server.js'));
expect(loginLoad, 'login load fn').toBeDefined();
const impacted = [...cg.getImpactRadius(loginLoad!.id, 3).nodes.values()].map((n) => n.filePath ?? '');
// editing login's load surfaces login's page (the framework-wired data flow)β¦
expect(impacted.some((p) => p.endsWith('login/+page.svelte')), 'load links to its own page').toBe(true);
// β¦but never register's page (same-directory only).
expect(impacted.some((p) => p.endsWith('register/+page.svelte')), 'does not cross routes').toBe(false);
});
});
describe('Nuxt nested auto-imported component resolution', () => {
let tempDir: string;
let cg: CodeGraph;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
if (cg) cg.close();
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
});
it('links a `` usage to components/media/Card.vue (Nuxt dir-prefixed auto-import)', async () => {
// Nuxt auto-imports a nested component by a DIRECTORY-PREFIXED name β
// components/media/Card.vue is used as , not β but the
// component node is named by basename (`Card`), so the PascalCase usage
// didn't resolve and the nested component looked unused.
const media = path.join(tempDir, 'components/media');
fs.mkdirSync(media, { recursive: true });
fs.writeFileSync(path.join(media, 'Card.vue'), `card
\n\n`);
fs.writeFileSync(
path.join(tempDir, 'components/Grid.vue'),
`\n
\n\n\n`
);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
const card = cg.getNodesByKind('component').find((n) => n.filePath.endsWith('media/Card.vue'));
expect(card, 'media/Card.vue component').toBeDefined();
const deps = [...cg.getImpactRadius(card!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
expect(deps.some((p) => p.endsWith('components/Grid.vue')), ' links Grid to media/Card.vue').toBe(true);
});
});
describe('Swift property-wrapper attribute type references', () => {
let tempDir: string;
let cg: CodeGraph;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
if (cg) cg.close();
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
});
it('a Fluent `@Siblings(through: Pivot.self)` links the model to the pivot type', async () => {
// A many-to-many pivot/join model is referenced ONLY through the relationship
// property wrapper's metatype argument (`Pivot.self`), never by a controller
// query. The wrapper type was captured but the argument expression wasn't
// walked, so the pivot model looked like nothing depended on it.
fs.writeFileSync(path.join(tempDir, 'Pivot.swift'),
`import Fluent\nfinal class AcronymCategoryPivot: Model {\n static let schema = "acronym-category"\n}\n`);
fs.writeFileSync(path.join(tempDir, 'Acronym.swift'),
`import Fluent\nfinal class Acronym: Model {\n` +
` @Siblings(through: AcronymCategoryPivot.self, from: \\.$acronym, to: \\.$category)\n` +
` var categories: [Category]\n}\n`);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
const pivot = cg.getNodesByKind('class').find((n) => n.name === 'AcronymCategoryPivot');
expect(pivot, 'pivot model class').toBeDefined();
const deps = [...cg.getImpactRadius(pivot!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
expect(deps.some((p) => p.endsWith('Acronym.swift')), '@Siblings metatype arg links Acronym to the pivot').toBe(true);
});
});
describe('Objective-C messages, class receivers, and #import', () => {
let tempDir: string;
let cg: CodeGraph;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
if (cg) cg.close();
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
});
it('resolves single-arg selectors, class-message receivers, and #import headers', async () => {
fs.writeFileSync(
path.join(tempDir, 'SDImageCache.h'),
`#import
@interface SDImageCache : NSObject
+ (instancetype)sharedCache;
+ (void)storeImage:(NSString *)key;
@end
`
);
fs.writeFileSync(
path.join(tempDir, 'SDImageCache.m'),
`#import "SDImageCache.h"
@implementation SDImageCache
+ (instancetype)sharedCache { return nil; }
+ (void)storeImage:(NSString *)key { }
@end
`
);
fs.writeFileSync(
path.join(tempDir, 'SDManager.m'),
`#import "SDImageCache.h"
@interface SDManager : NSObject
@end
@implementation SDManager
- (void)run {
[SDImageCache sharedCache];
[SDImageCache storeImage:@"k"];
}
@end
`
);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
// 1. The single-argument selector `[SDImageCache storeImage:@"k"]` resolves
// to the `storeImage:` method β named WITH its colon both at the call site
// and the definition (before the fix the call site dropped the colon).
const storeImage = cg.getNodesByKind('method').find((n) => n.name === 'storeImage:');
expect(storeImage, 'storeImage: method').toBeDefined();
const storeCallers = [...cg.getImpactRadius(storeImage!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
expect(storeCallers.some((p) => p.endsWith('SDManager.m'))).toBe(true);
// 2. The class-message receiver `[SDImageCache sharedCache]` references the
// SDImageCache class (whose @interface lives in the header).
const cache = cg.getNodesByKind('class').find((n) => n.name === 'SDImageCache');
expect(cache, 'SDImageCache class').toBeDefined();
const classDeps = [...cg.getImpactRadius(cache!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
expect(classDeps.some((p) => p.endsWith('SDManager.m'))).toBe(true);
// 3. `#import "SDImageCache.h"` resolves to the header FILE β editing it
// surfaces both importers.
const header = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('SDImageCache.h'));
expect(header, 'SDImageCache.h indexed').toBeDefined();
const importers = [...cg.getImpactRadius(header!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
expect(importers.some((p) => p.endsWith('SDManager.m'))).toBe(true);
});
});
describe('Full Indexing', () => {
let tempDir: string;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
cleanupTempDir(tempDir);
});
it('should index a TypeScript file', async () => {
// Create test file
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(srcDir);
fs.writeFileSync(
path.join(srcDir, 'utils.ts'),
`
export function add(a: number, b: number): number {
return a + b;
}
export function multiply(a: number, b: number): number {
return a * b;
}
`
);
// Initialize and index
const cg = CodeGraph.initSync(tempDir);
const result = await cg.indexAll();
expect(result.success).toBe(true);
expect(result.filesIndexed).toBe(1);
expect(result.nodesCreated).toBeGreaterThanOrEqual(2);
// Check nodes were stored
const nodes = cg.getNodesInFile('src/utils.ts');
expect(nodes.length).toBeGreaterThanOrEqual(2);
const addFunc = nodes.find((n) => n.name === 'add');
expect(addFunc).toBeDefined();
expect(addFunc?.kind).toBe('function');
cg.close();
});
it('should index multiple files', async () => {
// Create test files
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(srcDir);
fs.writeFileSync(
path.join(srcDir, 'math.ts'),
`export function add(a: number, b: number) { return a + b; }`
);
fs.writeFileSync(
path.join(srcDir, 'string.ts'),
`export function capitalize(s: string) { return s.toUpperCase(); }`
);
// Initialize and index
const cg = CodeGraph.initSync(tempDir);
const result = await cg.indexAll();
expect(result.success).toBe(true);
expect(result.filesIndexed).toBe(2);
const files = cg.getFiles();
expect(files.length).toBe(2);
cg.close();
});
it('should track file hashes for incremental updates', async () => {
// Create initial file
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(srcDir);
fs.writeFileSync(path.join(srcDir, 'main.ts'), `export const x = 1;`);
// Initialize and index
const cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
// Check file is tracked
const file = cg.getFile('src/main.ts');
expect(file).toBeDefined();
expect(file?.contentHash).toBeDefined();
// Modify file
fs.writeFileSync(path.join(srcDir, 'main.ts'), `export const x = 2;`);
// Check for changes
const changes = cg.getChangedFiles();
expect(changes.modified).toContain('src/main.ts');
cg.close();
});
it('should sync and detect changes', async () => {
// Create initial file
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(srcDir);
fs.writeFileSync(
path.join(srcDir, 'main.ts'),
`export function original() { return 1; }`
);
// Initialize and index
const cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
const initialNodes = cg.getNodesInFile('src/main.ts');
expect(initialNodes.some((n) => n.name === 'original')).toBe(true);
// Modify file
fs.writeFileSync(
path.join(srcDir, 'main.ts'),
`export function updated() { return 2; }`
);
// Sync
const syncResult = await cg.sync();
expect(syncResult.filesModified).toBe(1);
// Check nodes were updated
const updatedNodes = cg.getNodesInFile('src/main.ts');
expect(updatedNodes.some((n) => n.name === 'updated')).toBe(true);
expect(updatedNodes.some((n) => n.name === 'original')).toBe(false);
cg.close();
});
it('should count file-level tracked YAML files as indexed', async () => {
fs.writeFileSync(path.join(tempDir, 'app.yaml'), 'name: test\n');
fs.writeFileSync(path.join(tempDir, 'routes.yml'), 'route: value\n');
const cg = CodeGraph.initSync(tempDir);
const result = await cg.indexAll();
expect(result.success).toBe(true);
expect(result.filesIndexed).toBe(2);
expect(result.filesSkipped).toBe(0);
expect(cg.getFiles().map((f) => f.path).sort()).toEqual(['app.yaml', 'routes.yml']);
cg.close();
});
it('should count file-level tracked YAML/Twig files as indexed in indexFiles()', async () => {
fs.writeFileSync(path.join(tempDir, 'app.yaml'), 'name: test\n');
fs.writeFileSync(path.join(tempDir, 'view.twig'), '{{ title }}\n');
const cg = CodeGraph.initSync(tempDir);
const result = await cg.indexFiles(['app.yaml', 'view.twig']);
expect(result.success).toBe(true);
expect(result.filesIndexed).toBe(2);
expect(result.filesSkipped).toBe(0);
const tracked = cg.getFiles().map((f) => `${f.path}:${f.language}`).sort();
expect(tracked).toEqual(['app.yaml:yaml', 'view.twig:twig']);
cg.close();
});
it('should count file-level tracked .properties files as indexed', async () => {
fs.writeFileSync(path.join(tempDir, 'application.properties'), 'server.port=8080\n');
fs.writeFileSync(path.join(tempDir, 'log.properties'), 'log.level=INFO\n');
const cg = CodeGraph.initSync(tempDir);
const result = await cg.indexAll();
expect(result.success).toBe(true);
expect(result.filesIndexed).toBe(2);
expect(result.filesSkipped).toBe(0);
cg.close();
});
it('should resolve an ES import from a .xsjs file to a .xsjslib file (#556)', async () => {
// Exercises the JS import-path resolution list: `./helpers` must resolve to
// `helpers.xsjslib`. `decoy.js` exports the same symbol name and is never
// imported β without .xsjs/.xsjslib in the list the import resolves to
// nothing and the call falls back to same-name matching, which binds the
// edge to the decoy. The decoy is what makes this test fail on a regression:
// with a lone helpers.xsjslib the fallback happens to pick the right file.
fs.writeFileSync(
path.join(tempDir, 'helpers.xsjslib'),
'export function buildQuery(table) {\n return "SELECT * FROM " + table;\n}\n'
);
fs.writeFileSync(
path.join(tempDir, 'decoy.js'),
'export function buildQuery(table) {\n return "DECOY " + table;\n}\n'
);
fs.writeFileSync(
path.join(tempDir, 'service.xsjs'),
'import { buildQuery } from "./helpers";\n\nfunction run() {\n return buildQuery("users");\n}\n'
);
const cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
const run = cg.getNodesInFile('service.xsjs').find((n) => n.name === 'run');
const buildQuery = cg.getNodesInFile('helpers.xsjslib').find((n) => n.name === 'buildQuery');
const decoy = cg.getNodesInFile('decoy.js').find((n) => n.name === 'buildQuery');
expect(run).toBeDefined();
expect(buildQuery).toBeDefined();
expect(decoy).toBeDefined();
expect(
cg.getFileDependencies('service.xsjs'),
"'./helpers' should resolve to helpers.xsjslib, not the same-named decoy"
).toEqual(['helpers.xsjslib']);
const outgoing = cg.getOutgoingEdges(run!.id);
expect(
outgoing.find((e) => e.target === buildQuery!.id),
'run() should resolve buildQuery across the .xsjs -> .xsjslib import'
).toBeDefined();
expect(
outgoing.find((e) => e.target === decoy!.id),
'run() must not bind to the unrelated same-named export in decoy.js'
).toBeUndefined();
cg.close();
});
it('should count the full file-level tracked class (yaml/twig/properties) in indexFiles()', async () => {
fs.writeFileSync(path.join(tempDir, 'app.yaml'), 'name: test\n');
fs.writeFileSync(path.join(tempDir, 'view.twig'), '{{ title }}\n');
fs.writeFileSync(path.join(tempDir, 'application.properties'), 'server.port=8080\n');
const cg = CodeGraph.initSync(tempDir);
const result = await cg.indexFiles(['app.yaml', 'view.twig', 'application.properties']);
expect(result.success).toBe(true);
expect(result.filesIndexed).toBe(3);
expect(result.filesSkipped).toBe(0);
const tracked = cg.getFiles().map((f) => `${f.path}:${f.language}`).sort();
expect(tracked).toEqual(['app.yaml:yaml', 'application.properties:properties', 'view.twig:twig']);
cg.close();
});
});
describe('Path Normalization', () => {
it('should convert backslashes to forward slashes', () => {
expect(normalizePath('gui\\node_modules\\foo')).toBe('gui/node_modules/foo');
expect(normalizePath('src\\components\\Button.tsx')).toBe('src/components/Button.tsx');
});
it('should leave forward-slash paths unchanged', () => {
expect(normalizePath('src/components/Button.tsx')).toBe('src/components/Button.tsx');
});
it('should handle empty string', () => {
expect(normalizePath('')).toBe('');
});
});
describe('Directory Exclusion', () => {
let tempDir: string;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
cleanupTempDir(tempDir);
});
it('should exclude directories listed in .gitignore', () => {
// Create structure: src/index.ts + node_modules/pkg/index.js, gitignore node_modules
const srcDir = path.join(tempDir, 'src');
const nmDir = path.join(tempDir, 'node_modules', 'pkg');
fs.mkdirSync(srcDir, { recursive: true });
fs.mkdirSync(nmDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;');
fs.writeFileSync(path.join(nmDir, 'index.js'), 'module.exports = {};');
fs.writeFileSync(path.join(tempDir, '.gitignore'), 'node_modules/\n');
const files = scanDirectory(tempDir);
expect(files).toContain('src/index.ts');
expect(files.every((f) => !f.includes('node_modules'))).toBe(true);
});
it('should exclude nested node_modules via a root .gitignore', () => {
// A trailing-slash pattern with no leading slash matches at any depth.
const srcDir = path.join(tempDir, 'packages', 'app', 'src');
const nmDir = path.join(tempDir, 'packages', 'app', 'node_modules', 'pkg');
fs.mkdirSync(srcDir, { recursive: true });
fs.mkdirSync(nmDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;');
fs.writeFileSync(path.join(nmDir, 'index.js'), 'module.exports = {};');
fs.writeFileSync(path.join(tempDir, '.gitignore'), 'node_modules/\n');
const files = scanDirectory(tempDir);
expect(files).toContain('packages/app/src/index.ts');
expect(files.every((f) => !f.includes('node_modules'))).toBe(true);
});
it('should apply a nested .gitignore only to its own subtree', () => {
const appSrc = path.join(tempDir, 'app', 'src');
fs.mkdirSync(appSrc, { recursive: true });
fs.writeFileSync(path.join(appSrc, 'keep.ts'), 'export const a = 1;');
fs.writeFileSync(path.join(appSrc, 'skip.ts'), 'export const b = 2;');
fs.writeFileSync(path.join(tempDir, 'app', '.gitignore'), 'src/skip.ts\n');
// A sibling with the same name outside app/ must NOT be ignored.
const otherDir = path.join(tempDir, 'other', 'src');
fs.mkdirSync(otherDir, { recursive: true });
fs.writeFileSync(path.join(otherDir, 'skip.ts'), 'export const c = 3;');
const files = scanDirectory(tempDir);
expect(files).toContain('app/src/keep.ts');
expect(files).not.toContain('app/src/skip.ts');
expect(files).toContain('other/src/skip.ts');
});
it('should always skip .git directories', () => {
const srcDir = path.join(tempDir, 'src');
const gitDir = path.join(tempDir, '.git', 'objects');
fs.mkdirSync(srcDir, { recursive: true });
fs.mkdirSync(gitDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;');
fs.writeFileSync(path.join(gitDir, 'pack.ts'), 'export const y = 2;');
const files = scanDirectory(tempDir);
expect(files).toContain('src/index.ts');
expect(files.every((f) => !f.includes('.git'))).toBe(true);
});
it('should return forward-slash paths on all platforms', () => {
const srcDir = path.join(tempDir, 'src', 'components');
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, 'Button.tsx'), 'export function Button() {}');
const files = scanDirectory(tempDir);
expect(files.length).toBe(1);
expect(files[0]).toBe('src/components/Button.tsx');
expect(files[0]).not.toContain('\\');
});
});
describe('Nested .gitignore node_modules exclusion (#1567)', () => {
let tempDir: string;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
cleanupTempDir(tempDir);
});
function plantNodeModules(subproject: string, packages = 80): void {
const base = path.join(tempDir, subproject, 'node_modules');
for (let i = 0; i < packages; i++) {
const pkg = path.join(base, `pkg${i}`);
fs.mkdirSync(pkg, { recursive: true });
fs.writeFileSync(path.join(pkg, 'index.js'), `module.exports = ${i};`);
fs.writeFileSync(path.join(pkg, 'index.d.ts'), 'export const n: number;');
if (i % 4 === 0) fs.writeFileSync(path.join(pkg, '.gitignore'), '*.map\n');
const nested = path.join(pkg, 'node_modules', `nested${i}`);
fs.mkdirSync(nested, { recursive: true });
fs.writeFileSync(path.join(nested, 'lib.ts'), 'export const x = 1;');
}
}
function initGitRepo(): void {
const { execFileSync } = require('child_process') as typeof import('child_process');
execFileSync('git', ['init'], { cwd: tempDir, stdio: 'ignore' });
execFileSync('git', ['add', '-A'], { cwd: tempDir, stdio: 'ignore' });
execFileSync(
'git',
['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'init'],
{ cwd: tempDir, stdio: 'ignore' },
);
}
it('excludes node_modules ignored only by a nested .gitignore (git path)', () => {
fs.mkdirSync(path.join(tempDir, 'frontend', 'src'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'extension', 'src'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'frontend', 'src', 'app.ts'), 'export const a = 1;');
fs.writeFileSync(path.join(tempDir, 'extension', 'src', 'ext.ts'), 'export const b = 1;');
fs.writeFileSync(path.join(tempDir, 'root.ts'), 'export const r = 1;');
fs.writeFileSync(path.join(tempDir, '.gitignore'), '*.log\n');
fs.writeFileSync(path.join(tempDir, 'frontend', '.gitignore'), '/node_modules\n');
fs.writeFileSync(path.join(tempDir, 'extension', '.gitignore'), 'node_modules/\n');
plantNodeModules('frontend');
plantNodeModules('extension');
initGitRepo();
const files = scanDirectory(tempDir);
expect(files.sort()).toEqual(['extension/src/ext.ts', 'frontend/src/app.ts', 'root.ts']);
expect(files.every((f) => !f.includes('node_modules'))).toBe(true);
});
it('excludes nested-gitignore node_modules on the filesystem-walk fallback too', () => {
fs.mkdirSync(path.join(tempDir, 'frontend', 'src'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'extension', 'src'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'frontend', 'src', 'app.ts'), 'export const a = 1;');
fs.writeFileSync(path.join(tempDir, 'extension', 'src', 'ext.ts'), 'export const b = 1;');
fs.writeFileSync(path.join(tempDir, 'root.ts'), 'export const r = 1;');
fs.writeFileSync(path.join(tempDir, '.gitignore'), '*.log\n');
fs.writeFileSync(path.join(tempDir, 'frontend', '.gitignore'), '/node_modules\n');
fs.writeFileSync(path.join(tempDir, 'extension', '.gitignore'), 'node_modules/\n');
plantNodeModules('frontend', 60);
plantNodeModules('extension', 60);
const files = scanDirectory(tempDir);
expect(files.sort()).toEqual(['extension/src/ext.ts', 'frontend/src/app.ts', 'root.ts']);
expect(files.every((f) => !f.includes('node_modules'))).toBe(true);
});
it('still excludes when root only lists one subproject node_modules (Boba-like)', () => {
fs.mkdirSync(path.join(tempDir, 'frontend', 'src'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'extension', 'src'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'frontend', 'src', 'app.ts'), 'export const a = 1;');
fs.writeFileSync(path.join(tempDir, 'extension', 'src', 'ext.ts'), 'export const b = 1;');
fs.writeFileSync(path.join(tempDir, 'root.ts'), 'export const r = 1;');
fs.writeFileSync(
path.join(tempDir, '.gitignore'),
['*.log', 'frontend/node_modules/', 'frontend/.angular/', ''].join('\n'),
);
fs.writeFileSync(path.join(tempDir, 'frontend', '.gitignore'), '/node_modules\n');
fs.writeFileSync(path.join(tempDir, 'extension', '.gitignore'), 'node_modules/\n');
plantNodeModules('frontend', 40);
plantNodeModules('extension', 40);
const fsFiles = scanDirectory(tempDir);
expect(fsFiles.every((f) => !f.includes('node_modules'))).toBe(true);
expect(fsFiles.sort()).toEqual(['extension/src/ext.ts', 'frontend/src/app.ts', 'root.ts']);
initGitRepo();
const gitFiles = scanDirectory(tempDir);
expect(gitFiles.every((f) => !f.includes('node_modules'))).toBe(true);
expect(gitFiles.sort()).toEqual(['extension/src/ext.ts', 'frontend/src/app.ts', 'root.ts']);
});
});
describe('Git Submodules', () => {
let tempDir: string;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
cleanupTempDir(tempDir);
});
it('should index files inside git submodules (issue #147)', async () => {
const { execFileSync } = await import('child_process');
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, stdio: 'pipe' });
// Build a separate "library" repo to use as a submodule source.
const libDir = path.join(tempDir, '_lib');
fs.mkdirSync(libDir, { recursive: true });
git(libDir, 'init', '-q');
git(libDir, 'config', 'user.email', 'test@test.com');
git(libDir, 'config', 'user.name', 'Test');
fs.writeFileSync(path.join(libDir, 'lib.ts'), 'export const fromSubmodule = 1;');
git(libDir, 'add', '-A');
git(libDir, 'commit', '-q', '-m', 'lib init');
// Build the main repo and add the lib repo as a submodule.
const mainDir = path.join(tempDir, 'main');
fs.mkdirSync(mainDir, { recursive: true });
git(mainDir, 'init', '-q');
git(mainDir, 'config', 'user.email', 'test@test.com');
git(mainDir, 'config', 'user.name', 'Test');
fs.writeFileSync(path.join(mainDir, 'app.ts'), 'export const app = 1;');
git(mainDir, 'add', '-A');
git(mainDir, 'commit', '-q', '-m', 'app init');
// protocol.file.allow=always is required to add a local-path submodule on
// recent git versions (CVE-2022-39253 mitigation).
execFileSync(
'git',
['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', libDir, 'libs/lib'],
{ cwd: mainDir, stdio: 'pipe' }
);
git(mainDir, 'commit', '-q', '-m', 'add submodule');
const files = scanDirectory(mainDir);
expect(files).toContain('app.ts');
expect(files).toContain('libs/lib/lib.ts');
});
});
describe('Nested gitlink repos (#1031, #1033)', () => {
let tempDir: string;
// Helper: make a self-contained git repo at `dir` with one committed TS file.
const makeRepo = async (dir: string, base: string) => {
const { execFileSync } = await import('child_process');
const git = (...args: string[]) => execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
fs.mkdirSync(dir, { recursive: true });
git('init', '-q');
git('config', 'user.email', 'test@test.com');
git('config', 'user.name', 'Test');
fs.writeFileSync(path.join(dir, `${base}.ts`), `export const ${base} = 1;`);
git('add', '-A');
git('commit', '-q', '-m', `${base} init`);
};
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
cleanupTempDir(tempDir);
});
// The #1031 case: a nested repo `git add`ed inside the super-repo becomes a
// gitlink (mode 160000) with NO `.gitmodules`. It is tracked (so it never shows
// in the untracked `-o` listing) yet not an active submodule (so
// `--recurse-submodules` won't expand it) β it used to fall through both passes
// and only the super-repo's own files got indexed.
it('indexes a bare gitlink (git add\'ed embedded repo, no .gitmodules), recursively', async () => {
const { execFileSync } = await import('child_process');
const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe' });
const root = path.join(tempDir, 'root');
await makeRepo(root, 'app');
// An embedded clone, itself holding a further nested clone (untracked inside it).
await makeRepo(path.join(root, 'embedded'), 'inner');
await makeRepo(path.join(root, 'embedded', 'deep'), 'deep');
// `git add embedded` records it as a 160000 gitlink (no fetch, no .gitmodules).
git(root, 'add', 'embedded');
git(root, 'commit', '-q', '-m', 'add embedded as gitlink');
expect(fs.existsSync(path.join(root, '.gitmodules'))).toBe(false);
const files = scanDirectory(root);
expect(files).toContain('app.ts');
expect(files).toContain('embedded/inner.ts'); // the gitlink's own source
expect(files).toContain('embedded/deep/deep.ts'); // recursion continues into its nested repo
});
// The -c β -s switch must not regress active submodules (#147): a repo can hold
// BOTH an active submodule (expanded by --recurse-submodules) and a bare gitlink
// (handled by the new pass), and the mixed 160000/100644 modes must parse right.
it('indexes a gitlink alongside an active submodule', async () => {
const { execFileSync } = await import('child_process');
const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe' });
const lib = path.join(tempDir, '_lib');
await makeRepo(lib, 'lib');
const root = path.join(tempDir, 'root');
await makeRepo(root, 'app');
// A proper, active submodule.
execFileSync('git', ['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', lib, 'libs/lib'], { cwd: root, stdio: 'pipe' });
git(root, 'commit', '-q', '-m', 'add submodule');
// A bare gitlink in the same repo (under a non-ignored dir name).
await makeRepo(path.join(root, 'external', 'tool'), 'tool');
git(root, 'add', 'external/tool');
git(root, 'commit', '-q', '-m', 'add gitlink');
const files = scanDirectory(root);
expect(files).toContain('app.ts');
expect(files).toContain('libs/lib/lib.ts'); // active submodule still expands (#147)
expect(files).toContain('external/tool/tool.ts'); // bare gitlink now indexed
});
// A gitlink under a built-in default-ignored directory (vendor/, node_modules/,
// β¦) stays excluded β a committed dependency doesn't become project code just
// because it's a nested repo. Mirrors how the untracked-embedded path treats
// the same dirs (#407), so the two passes agree.
it('does not index a gitlink under a default-ignored directory (e.g. vendor/)', async () => {
const { execFileSync } = await import('child_process');
const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe' });
const root = path.join(tempDir, 'root');
await makeRepo(root, 'app');
await makeRepo(path.join(root, 'vendor', 'pkg'), 'dep');
git(root, 'add', 'vendor/pkg');
git(root, 'commit', '-q', '-m', 'add vendored gitlink');
const files = scanDirectory(root);
expect(files).toContain('app.ts');
expect(files).not.toContain('vendor/pkg/dep.ts');
});
// A gitlink with NO working tree on disk (the common "cloned without
// --recurse-submodules" state) has nothing to index β we must leave it alone,
// not fabricate entries, and must not break the rest of the scan.
it('leaves an uninitialized submodule (no checkout on disk) alone', async () => {
const { execFileSync } = await import('child_process');
const lib = path.join(tempDir, '_lib');
await makeRepo(lib, 'lib');
const sup = path.join(tempDir, 'super');
await makeRepo(sup, 'app');
execFileSync('git', ['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', lib, 'libs/lib'], { cwd: sup, stdio: 'pipe' });
execFileSync('git', ['commit', '-q', '-m', 'add submodule'], { cwd: sup, stdio: 'pipe' });
// Clone the super-repo WITHOUT --recurse-submodules β libs/lib is an empty
// gitlink dir (mode 160000, no `.git` inside, no files).
const clone = path.join(tempDir, 'clone');
execFileSync('git', ['clone', '-q', sup, clone], { stdio: 'pipe' });
expect(fs.readdirSync(path.join(clone, 'libs', 'lib'))).toHaveLength(0);
const files = scanDirectory(clone);
expect(files).toContain('app.ts');
expect(files).not.toContain('libs/lib/lib.ts'); // not on disk β correctly absent
});
// #1065: a gitlink under a path the super-repo's OWN `.gitignore` covers is the
// tracked-gitlink twin of the untracked-ignored embedded repo (#514, #970). The
// gitlink-discovery pass must honor that `.gitignore` the same way β otherwise a
// gitignored reference/benchmark corpus full of `git add`ed clones gets pulled
// into the index (the 138k-file blow-up the reporter hit). Respect it by default;
// re-include only via `codegraph.json` `includeIgnored`.
it('does not index a gitlink under a gitignored directory by default (#1065)', async () => {
const { execFileSync } = await import('child_process');
const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe' });
const root = path.join(tempDir, 'root');
await makeRepo(root, 'app');
// An embedded clone under a path the super-repo gitignores (a benchmark corpus).
await makeRepo(path.join(root, 'benchmark', 'repos', 'ref'), 'ref');
git(root, 'add', 'benchmark/repos/ref'); // tracked as a 160000 gitlink
fs.writeFileSync(path.join(root, '.gitignore'), 'benchmark/repos/\n');
git(root, 'add', '.gitignore');
git(root, 'commit', '-q', '-m', 'add gitignored gitlink + ignore rule');
const files = scanDirectory(root);
expect(files).toContain('app.ts');
expect(files).not.toContain('benchmark/repos/ref/ref.ts'); // gitignored β excluded
// The watcher path agrees: the ignored root is never discovered, and the dir is
// pruned (the reporter's exact clue β `ignores('benchmark/repos/')` was false).
expect(discoverEmbeddedRepoRoots(root)).toEqual([]);
expect(buildScopeIgnore(root).ignores('benchmark/repos/')).toBe(true);
expect(buildScopeIgnore(root).ignores('benchmark/repos/ref/ref.ts')).toBe(true);
});
it('re-includes a gitignored gitlink when codegraph.json includeIgnored opts in (#1065)', async () => {
const { execFileSync } = await import('child_process');
const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe' });
const root = path.join(tempDir, 'root');
await makeRepo(root, 'app');
await makeRepo(path.join(root, 'benchmark', 'repos', 'ref'), 'ref');
git(root, 'add', 'benchmark/repos/ref');
fs.writeFileSync(path.join(root, '.gitignore'), 'benchmark/repos/\n');
fs.writeFileSync(path.join(root, 'codegraph.json'), JSON.stringify({ includeIgnored: ['benchmark/repos/'] }));
git(root, 'add', '.gitignore', 'codegraph.json');
git(root, 'commit', '-q', '-m', 'opt the gitignored gitlink back in');
const files = scanDirectory(root);
expect(files).toContain('app.ts');
expect(files).toContain('benchmark/repos/ref/ref.ts'); // opted in β indexed
expect(discoverEmbeddedRepoRoots(root)).toContain('benchmark/repos/ref/');
});
});
describe('Nested non-submodule git repos', () => {
let tempDir: string;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
cleanupTempDir(tempDir);
});
it('should index files in embedded git repos run from a git super-repo (issue #193)', async () => {
const { execFileSync } = await import('child_process');
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, stdio: 'pipe' });
// Top-level workspace is itself a git repo, holding no source directly β
// the CMake "super-repo" layout from the issue.
const root = path.join(tempDir, 'root');
fs.mkdirSync(path.join(root, 'coding'), { recursive: true });
git(root, 'init', '-q');
git(root, 'config', 'user.email', 'test@test.com');
git(root, 'config', 'user.name', 'Test');
fs.writeFileSync(path.join(root, 'CMakeLists.txt'), 'cmake_minimum_required(VERSION 3.10)\n');
// Two independent clones living inside the workspace (NOT submodules):
// one with committed source, one with only untracked source.
const sub1 = path.join(root, 'sub_repo1', 'src');
fs.mkdirSync(sub1, { recursive: true });
git(path.join(root, 'sub_repo1'), 'init', '-q');
git(path.join(root, 'sub_repo1'), 'config', 'user.email', 'test@test.com');
git(path.join(root, 'sub_repo1'), 'config', 'user.name', 'Test');
fs.writeFileSync(path.join(sub1, 'one.ts'), 'export const one = 1;');
git(path.join(root, 'sub_repo1'), 'add', '-A');
git(path.join(root, 'sub_repo1'), 'commit', '-q', '-m', 'sub1 init');
const sub2 = path.join(root, 'sub_repo2', 'src');
fs.mkdirSync(sub2, { recursive: true });
git(path.join(root, 'sub_repo2'), 'init', '-q');
fs.writeFileSync(path.join(sub2, 'two.ts'), 'export const two = 2;');
const files = scanDirectory(root);
// Both committed and untracked source from the nested repos must be found.
expect(files).toContain('sub_repo1/src/one.ts');
expect(files).toContain('sub_repo2/src/two.ts');
});
it('should respect each embedded repo\'s own .gitignore', async () => {
const { execFileSync } = await import('child_process');
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, stdio: 'pipe' });
const root = path.join(tempDir, 'root');
fs.mkdirSync(root, { recursive: true });
git(root, 'init', '-q');
const sub = path.join(root, 'sub_repo', 'src');
fs.mkdirSync(sub, { recursive: true });
git(path.join(root, 'sub_repo'), 'init', '-q');
fs.writeFileSync(path.join(root, 'sub_repo', '.gitignore'), 'src/generated.ts\n');
fs.writeFileSync(path.join(sub, 'real.ts'), 'export const real = 1;');
fs.writeFileSync(path.join(sub, 'generated.ts'), 'export const generated = 1;');
const files = scanDirectory(root);
expect(files).toContain('sub_repo/src/real.ts');
expect(files).not.toContain('sub_repo/src/generated.ts');
});
// A .gitignore the `ignore` library can't compile to a regex must not abort
// the whole scan β the bad pattern is dropped, valid ones still apply (#682).
it('does not crash on a .gitignore with an uncompilable pattern (#682)', () => {
fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'build'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'src', 'real.ts'), 'export const x = 1;');
fs.writeFileSync(path.join(tempDir, 'build', 'out.ts'), 'export const y = 2;');
// `\\[` makes the matcher build an unterminated character class β the throw
// is lazy (at match time), which is what escaped and killed sync.
fs.writeFileSync(path.join(tempDir, '.gitignore'), 'build/\n\\\\[\n');
let files: string[] = [];
expect(() => {
files = scanDirectory(tempDir);
}).not.toThrow();
expect(files).toContain('src/real.ts');
// The still-valid `build/` rule is honored; only the bad line was dropped.
expect(files.some((f) => f.startsWith('build/'))).toBe(false);
});
// A .gitignore that isn't valid UTF-8 β e.g. encrypted in place by corporate
// DLP / endpoint software (UTF-16 header + ciphertext) β is skipped whole,
// not fed to the matcher as garbage patterns (#682).
it('does not crash on a non-UTF-8 (DLP-encrypted) .gitignore (#682)', () => {
fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'src', 'real.ts'), 'export const x = 1;');
const header = Buffer.concat([
Buffer.from([0x00, 0x00]),
Buffer.from('[notice][user]', 'utf16le'),
]);
const junk = Buffer.from([0x5b, 0x99, 0xc3, 0x28, 0x5c, 0x5b, 0xff, 0xfd]);
fs.writeFileSync(path.join(tempDir, '.gitignore'), Buffer.concat([header, junk]));
let files: string[] = [];
expect(() => {
files = scanDirectory(tempDir);
}).not.toThrow();
expect(files).toContain('src/real.ts');
});
it('buildDefaultIgnore survives a bad .gitignore and still applies valid rules (#682)', () => {
fs.writeFileSync(path.join(tempDir, '.gitignore'), 'dist/\n\\\\[\n');
const ig = buildDefaultIgnore(tempDir);
expect(() => ig.ignores('src/app.ts')).not.toThrow();
expect(ig.ignores('dist/')).toBe(true); // valid rule survives
expect(ig.ignores('src/app.ts')).toBe(false);
});
it('buildDefaultIgnore honors .git/info/exclude (#1728)', async () => {
const { execFileSync } = await import('child_process');
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, stdio: 'pipe' });
const root = path.join(tempDir, 'exclude-root');
fs.mkdirSync(root, { recursive: true });
git(root, 'init', '-q');
fs.writeFileSync(path.join(root, 'src.ts'), 'export const x = 1;\n');
fs.mkdirSync(path.join(root, '.claude', 'worktrees', 'agent-1'), { recursive: true });
fs.writeFileSync(
path.join(root, '.claude', 'worktrees', 'agent-1', 'src.ts'),
'export const w = 1;\n',
);
// Not in .gitignore β only in info/exclude (the reporter's exact shape).
fs.writeFileSync(
path.join(root, '.git', 'info', 'exclude'),
'**/.claude/worktrees/\n',
);
const ig = buildDefaultIgnore(root);
expect(ig.ignores('src.ts')).toBe(false);
expect(ig.ignores('.claude/worktrees/agent-1/src.ts')).toBe(true);
expect(ig.ignores('.claude/worktrees/')).toBe(true);
// ScopeIgnore (watcher path) agrees, including via git ignored-dir seeding.
const scope = buildScopeIgnore(root);
expect(scope.ignores('src.ts')).toBe(false);
expect(scope.ignores('.claude/worktrees/agent-1/')).toBe(true);
expect(scope.ignores('.claude/worktrees/agent-1/src.ts')).toBe(true);
});
it('buildDefaultIgnore honors core.excludesFile (#1728)', async () => {
const { execFileSync } = await import('child_process');
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, stdio: 'pipe' });
const root = path.join(tempDir, 'excludesfile-root');
fs.mkdirSync(root, { recursive: true });
git(root, 'init', '-q');
const globalExcludes = path.join(tempDir, 'global-excludes');
fs.writeFileSync(globalExcludes, 'scratch/\n');
git(root, 'config', 'core.excludesFile', globalExcludes);
fs.mkdirSync(path.join(root, 'scratch'), { recursive: true });
fs.writeFileSync(path.join(root, 'scratch', 'tmp.ts'), 'export const t = 1;\n');
fs.writeFileSync(path.join(root, 'app.ts'), 'export const a = 1;\n');
const ig = buildDefaultIgnore(root);
expect(ig.ignores('app.ts')).toBe(false);
expect(ig.ignores('scratch/')).toBe(true);
expect(ig.ignores('scratch/tmp.ts')).toBe(true);
});
it('filesystem fallback retains git info/exclude and core.excludesFile when ls-files fails (#1959)', async () => {
const { execFileSync } = await import('child_process');
const root = path.join(tempDir, 'fallback-excludes-root');
fs.mkdirSync(root, { recursive: true });
execFileSync('git', ['init', '-q'], { cwd: root, stdio: 'pipe' });
const globalExcludes = path.join(tempDir, 'fallback-global-excludes');
fs.writeFileSync(globalExcludes, 'scratch/\n');
execFileSync('git', ['config', 'core.excludesFile', globalExcludes], { cwd: root, stdio: 'pipe' });
fs.writeFileSync(path.join(root, '.git', 'info', 'exclude'), 'worktrees/\n');
fs.mkdirSync(path.join(root, 'scratch'));
fs.mkdirSync(path.join(root, 'worktrees'));
fs.writeFileSync(path.join(root, 'app.ts'), 'export const app = 1;\n');
fs.writeFileSync(path.join(root, 'scratch', 'hidden.ts'), 'export const hidden = 1;\n');
fs.writeFileSync(path.join(root, 'worktrees', 'hidden.ts'), 'export const hidden = 2;\n');
// rev-parse/config still work, but both ls-files and status fail as they
// would under a Git timeout. This exercises the real filesystem walk.
fs.writeFileSync(path.join(root, '.git', 'index'), 'not a git index');
expect(() => execFileSync('git', ['ls-files'], { cwd: root, stdio: 'pipe' })).toThrow();
expect(scanDirectory(root)).toEqual(['app.ts']);
expect(await scanDirectoryAsync(root)).toEqual(['app.ts']);
});
it('buildScopeIgnore prunes dirs ignored only by a nested .gitignore (#1728)', async () => {
const { execFileSync } = await import('child_process');
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, stdio: 'pipe' });
const root = path.join(tempDir, 'nested-gi-root');
fs.mkdirSync(path.join(root, 'pkg', 'build'), { recursive: true });
git(root, 'init', '-q');
git(root, 'config', 'user.email', 'test@test.com');
git(root, 'config', 'user.name', 'Test');
fs.writeFileSync(path.join(root, 'pkg', 'app.ts'), 'export const a = 1;\n');
fs.writeFileSync(path.join(root, 'pkg', 'build', 'out.ts'), 'export const o = 1;\n');
fs.writeFileSync(path.join(root, 'pkg', '.gitignore'), 'build/\n');
// Commit only the non-ignored file so git still reports build/ as ignored-other.
git(root, 'add', 'pkg/app.ts', 'pkg/.gitignore');
git(root, 'commit', '-q', '-m', 'init');
const scope = buildScopeIgnore(root);
expect(scope.ignores('pkg/app.ts')).toBe(false);
expect(scope.ignores('pkg/build/')).toBe(true);
expect(scope.ignores('pkg/build/out.ts')).toBe(true);
});
it.each(['filesystem', 'untracked', 'tracked'])(
'keeps Java packages named build while excluding build output (%s, #1642)',
async (mode) => {
const sources = ['', 'module/'].flatMap((prefix) => ['main', 'test'].flatMap((sourceSet) => [
`${prefix}src/${sourceSet}/java/com/acme/build/RealtimePlusService.java`,
`${prefix}src/${sourceSet}/java/build/nested/build/Example.java`,
]).concat([
`${prefix}src/androidTest/java/com/acme/build/DeviceProbe.java`,
`${prefix}src/main/kotlin/com/acme/build/KotlinProbe.kt`,
`${prefix}src/test/scala/com/acme/build/ScalaProbe.scala`,
]));
const ignored = [
'build/generated/Generated.java',
'module/build/generated/Generated.java',
'build/src/main/java/com/build/Generated.java',
'module/build/src/test/java/build/Generated.java',
'src/main/resources/build/Generated.java',
'node_modules/pkg/src/main/java/com/build/Generated.java',
'target/src/test/java/com/build/Generated.java',
...['src/main/java/com/build/', 'module/src/test/java/build/'].flatMap((prefix) => [
`${prefix}node_modules/pkg/a.js`,
`${prefix}target/A.java`,
`${prefix}target/build/A.java`,
`${prefix}dist/A.java`,
`${prefix}vendor/A.java`,
`${prefix}cmake-build-debug/A.java`,
`${prefix}res/layout/A.xml`,
]),
];
for (const rel of [...sources, ...ignored]) {
const abs = path.join(tempDir, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, 'class Example {}\n');
}
if (mode !== 'filesystem') {
execFileSync('git', ['init', '-q'], { cwd: tempDir });
if (mode === 'tracked') execFileSync('git', ['add', '-f', '.'], { cwd: tempDir });
}
const defaults = buildDefaultIgnore(tempDir);
const scope = buildScopeIgnore(tempDir);
const files = scanDirectory(tempDir);
expect(await scanDirectoryAsync(tempDir)).toEqual(files);
for (const rel of sources) {
expect(defaults.ignores(rel), rel).toBe(false);
expect(scope.ignores(rel), rel).toBe(false);
// The watcher and filesystem walker must be able to reach each file.
const parts = rel.split('/');
for (let i = 1; i < parts.length; i++) {
expect(scope.ignores(parts.slice(0, i).join('/') + '/'), rel).toBe(false);
}
expect(files).toContain(rel);
}
for (const rel of ignored) {
expect(defaults.ignores(rel), rel).toBe(true);
expect(scope.ignores(rel), rel).toBe(true);
expect(files).not.toContain(rel);
}
},
);
it.each(['.gitignore', 'codegraph.json', 'src/main/java/.gitignore'])(
'lets explicit %s rules exclude a Java package named build (#1642)',
(ignoreFile) => {
const sourceFile = 'src/main/java/com/acme/build/Hidden.java';
const abs = path.join(tempDir, sourceFile);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, 'class Hidden {}\n');
execFileSync('git', ['init', '-q'], { cwd: tempDir });
fs.writeFileSync(path.join(tempDir, ignoreFile), ignoreFile === 'codegraph.json'
? JSON.stringify({ exclude: ['src/main/java/**/build/'] })
: 'build/\n');
expect(buildScopeIgnore(tempDir).ignores(sourceFile)).toBe(true);
expect(scanDirectory(tempDir)).not.toContain(sourceFile);
},
);
it('retrieves and syncs indexed Java packages named build (#1642)', async () => {
const { ToolHandler } = await import('../src/mcp/tools');
const sourceFile = 'src/main/java/com/ctrip/panda/es/build/RealtimePlusService.java';
const ignoredFile = 'src/main/java/com/ctrip/panda/es/build/target/Generated.java';
for (const rel of [sourceFile, ignoredFile]) {
fs.mkdirSync(path.dirname(path.join(tempDir, rel)), { recursive: true });
}
const source = 'package com.ctrip.panda.es.build;\npublic class RealtimePlusService { public int run() { return 1; } }\n';
fs.writeFileSync(path.join(tempDir, sourceFile), source);
fs.writeFileSync(path.join(tempDir, ignoredFile), 'public class Generated {}\n');
const cg = CodeGraph.initSync(tempDir);
try {
expect((await cg.indexAll()).filesIndexed).toBe(1);
expect(cg.getNodesInFile(sourceFile).some((node) => node.name === 'RealtimePlusService')).toBe(true);
const result = await new ToolHandler(cg).execute('codegraph_explore', { query: 'RealtimePlusService' });
expect(result.isError).toBeUndefined();
expect(result.content[0]!.text).toContain('public class RealtimePlusService');
fs.writeFileSync(path.join(tempDir, sourceFile), source.replace('run()', 'updatedRun()'));
await cg.sync({ paths: [sourceFile, ignoredFile] });
expect(cg.getNodesInFile(sourceFile).some((node) => node.name === 'updatedRun')).toBe(true);
expect(cg.getNodesInFile(ignoredFile)).toEqual([]);
const addedFile = 'src/test/java/com/build/ServiceTest.java';
fs.mkdirSync(path.dirname(path.join(tempDir, addedFile)), { recursive: true });
fs.writeFileSync(path.join(tempDir, addedFile), 'package com.build; public class ServiceTest {}\n');
expect((await cg.sync()).filesAdded).toBe(1);
expect(cg.getNodesInFile(addedFile).some((node) => node.name === 'ServiceTest')).toBe(true);
} finally {
cg.close();
}
});
});
// =============================================================================
// Scala
// =============================================================================
describe('Scala Extraction', () => {
describe('Language detection', () => {
it('should detect Scala files', () => {
expect(detectLanguage('Main.scala')).toBe('scala');
expect(detectLanguage('script.sc')).toBe('scala');
expect(detectLanguage('src/UserService.scala')).toBe('scala');
});
it('should report Scala as supported', () => {
expect(isLanguageSupported('scala')).toBe(true);
expect(getSupportedLanguages()).toContain('scala');
});
});
describe('Class extraction', () => {
it('should extract class definitions', () => {
const code = `
class UserService(private val repo: UserRepository) {
def findUser(id: String): Option[String] = Some(id)
}
`;
const result = extractFromSource('UserService.scala', code);
const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'UserService');
expect(cls).toBeDefined();
expect(cls?.language).toBe('scala');
});
it.each(['\n', '\r\n'])('extracts objects as modules with method ownership (%j)', (eol) => {
const code = [
'object DatabaseConfig {',
' val url = "jdbc:postgresql://localhost/mydb"',
' def connect(): String = url',
'}',
'def scope(): Int = {',
' object Local { def value(): Int = 1 }',
' Local.value()',
'}',
'case object Empty',
].join(eol);
const result = extractFromSource('Config.scala', code);
for (const [name, method] of [['DatabaseConfig', 'connect'], ['Local', 'value']]) {
const obj = result.nodes.find((n) => n.kind === 'module' && n.name === name)!;
const member = result.nodes.find((n) => n.kind === 'method' && n.name === method)!;
expect(obj).toBeDefined();
expect(member).toBeDefined();
expect(result.edges.some((e) => e.kind === 'contains' && e.source === obj.id && e.target === member.id)).toBe(true);
}
expect(result.nodes.some((n) => n.kind === 'module' && n.name === 'Empty')).toBe(true);
});
it('should extract trait definitions as trait kind', () => {
const code = `
trait Repository[A] {
def findById(id: String): Option[A]
def save(entity: A): Unit
}
`;
const result = extractFromSource('Repository.scala', code);
const trait_ = result.nodes.find((n) => n.kind === 'trait' && n.name === 'Repository');
expect(trait_).toBeDefined();
});
});
describe('Method and function extraction', () => {
it('should extract method definitions inside a class', () => {
const code = `
class Calculator {
def add(a: Int, b: Int): Int = a + b
def divide(a: Double, b: Double): Double = a / b
}
`;
const result = extractFromSource('Calculator.scala', code);
const methods = result.nodes.filter((n) => n.kind === 'method');
expect(methods.find((m) => m.name === 'add')).toBeDefined();
expect(methods.find((m) => m.name === 'divide')).toBeDefined();
});
it('should extract method signatures', () => {
const code = `
class Greeter {
def greet(name: String): String = s"Hello, \${name}!"
}
`;
const result = extractFromSource('Greeter.scala', code);
const method = result.nodes.find((n) => n.name === 'greet');
expect(method?.signature).toContain('name: String');
expect(method?.signature).toContain('String');
});
it('should extract top-level function definitions as functions', () => {
const code = `
def factorial(n: Int): Int = if (n <= 1) 1 else n * factorial(n - 1)
def greet(name: String): String = s"Hello, \${name}!"
`;
const result = extractFromSource('utils.scala', code);
const fns = result.nodes.filter((n) => n.kind === 'function');
expect(fns.find((f) => f.name === 'factorial')).toBeDefined();
expect(fns.find((f) => f.name === 'greet')).toBeDefined();
});
});
describe('Val and var extraction', () => {
it('should extract val inside a class as field', () => {
const code = `
class Config {
val timeout: Int = 30
val host: String = "localhost"
}
`;
const result = extractFromSource('Config.scala', code);
const fields = result.nodes.filter((n) => n.kind === 'field');
expect(fields.find((f) => f.name === 'timeout')).toBeDefined();
expect(fields.find((f) => f.name === 'host')).toBeDefined();
});
it('should extract var inside a class as field', () => {
const code = `
class Counter {
var count: Int = 0
}
`;
const result = extractFromSource('Counter.scala', code);
const field = result.nodes.find((n) => n.kind === 'field' && n.name === 'count');
expect(field).toBeDefined();
});
it('should extract top-level val as constant', () => {
const code = `
val MaxConnections: Int = 100
val DefaultTimeout = 30
`;
const result = extractFromSource('constants.scala', code);
const consts = result.nodes.filter((n) => n.kind === 'constant');
expect(consts.find((c) => c.name === 'MaxConnections')).toBeDefined();
});
it('should extract top-level var as variable', () => {
const code = `
var retries: Int = 3
`;
const result = extractFromSource('state.scala', code);
const v = result.nodes.find((n) => n.kind === 'variable' && n.name === 'retries');
expect(v).toBeDefined();
});
it('should include type in val/var signature', () => {
const code = `
class Service {
val timeout: Int = 30
}
`;
const result = extractFromSource('Service.scala', code);
const field = result.nodes.find((n) => n.name === 'timeout');
expect(field?.signature).toContain('timeout');
expect(field?.signature).toContain('Int');
});
});
describe('Enum extraction', () => {
it('should extract enum definitions', () => {
const code = `
enum Color:
case Red
case Green
case Blue
`;
const result = extractFromSource('Color.scala', code);
const enumNode = result.nodes.find((n) => n.kind === 'enum' && n.name === 'Color');
expect(enumNode).toBeDefined();
});
it('should extract enum cases as enum_member', () => {
const code = `
enum Direction:
case North
case South
case East
case West
`;
const result = extractFromSource('Direction.scala', code);
const members = result.nodes.filter((n) => n.kind === 'enum_member');
expect(members.find((m) => m.name === 'North')).toBeDefined();
expect(members.find((m) => m.name === 'South')).toBeDefined();
expect(members.length).toBeGreaterThanOrEqual(4);
});
});
describe('Type alias extraction', () => {
it('should extract type aliases', () => {
const code = `
type UserId = String
type UserMap = Map[String, String]
`;
const result = extractFromSource('types.scala', code);
const aliases = result.nodes.filter((n) => n.kind === 'type_alias');
expect(aliases.find((a) => a.name === 'UserId')).toBeDefined();
expect(aliases.find((a) => a.name === 'UserMap')).toBeDefined();
});
});
describe('Import extraction', () => {
it('should extract import declarations', () => {
const code = `
import scala.collection.mutable.ListBuffer
import scala.concurrent.Future
`;
const result = extractFromSource('imports.scala', code);
const imports = result.nodes.filter((n) => n.kind === 'import');
expect(imports.length).toBeGreaterThanOrEqual(2);
});
});
describe('Visibility modifiers', () => {
it('should extract private visibility', () => {
const code = `
class Service {
private val secret: String = "abc"
private def helper(): Unit = {}
}
`;
const result = extractFromSource('Service.scala', code);
const secretField = result.nodes.find((n) => n.name === 'secret');
expect(secretField?.visibility).toBe('private');
const helperMethod = result.nodes.find((n) => n.name === 'helper');
expect(helperMethod?.visibility).toBe('private');
});
it('should extract protected visibility', () => {
const code = `
class Base {
protected def helperMethod(): Unit = {}
}
`;
const result = extractFromSource('Base.scala', code);
const method = result.nodes.find((n) => n.name === 'helperMethod');
expect(method?.visibility).toBe('protected');
});
it('should default to public visibility', () => {
const code = `
class Greeter {
def hello(): Unit = {}
}
`;
const result = extractFromSource('Greeter.scala', code);
const method = result.nodes.find((n) => n.name === 'hello');
expect(method?.visibility).toBe('public');
});
});
describe('Inheritance', () => {
it('should extract extends relationships', () => {
const code = `
class AdminUser extends User {
def adminAction(): Unit = {}
}
`;
const result = extractFromSource('AdminUser.scala', code);
const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends');
expect(extendsRefs.find((r) => r.referenceName === 'User')).toBeDefined();
});
});
describe('Call extraction', () => {
it('should extract function call expressions', () => {
const code = `
def processData(): Unit = {
val result = computeResult()
println(result)
}
`;
const result = extractFromSource('processor.scala', code);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
expect(calls.length).toBeGreaterThan(0);
});
it('walks a val/var initializer scoped to the declared symbol (#693 for Scala)', () => {
// The val/var hook minted the node and returned true, so the dispatcher
// only scanned the subtree for function-as-value candidates β every call
// in an initializer was dropped, which on a `val`-heavy codebase
// (SpinalHDL, Akka wiring) is most of the wiring.
const code = `
class C {
val fieldLambda: () => Unit = () => target()
val direct = target()
lazy val lazily = target()
private def target(): Unit = {}
}
object O {
val topLambda = () => hit()
def hit(): Unit = {}
}
`;
const result = extractFromSource('C.scala', code);
const byId = new Map(result.nodes.map((n) => [n.id, n]));
const callersOf = (name: string) =>
result.unresolvedReferences
.filter((u) => u.referenceKind === 'calls' && u.referenceName === name)
.map((u) => byId.get(u.fromNodeId)?.name)
.sort();
expect(callersOf('target')).toEqual(['direct', 'fieldLambda', 'lazily']);
expect(callersOf('hit')).toEqual(['topLambda']);
});
});
});
describe('Vue Extraction', () => {
it('should detect Vue files', () => {
expect(detectLanguage('App.vue')).toBe('vue');
expect(detectLanguage('components/Button.vue')).toBe('vue');
expect(isLanguageSupported('vue')).toBe(true);
});
it('should extract component node from a Vue SFC', () => {
const code = `
{{ message }}
`;
const result = extractFromSource('HelloWorld.vue', code);
const componentNode = result.nodes.find((n) => n.kind === 'component');
expect(componentNode).toBeDefined();
expect(componentNode?.name).toBe('HelloWorld');
expect(componentNode?.language).toBe('vue');
expect(componentNode?.isExported).toBe(true);
});
it('should extract functions from
`;
const result = extractFromSource('Button.vue', code);
const componentNode = result.nodes.find((n) => n.kind === 'component');
expect(componentNode).toBeDefined();
expect(componentNode?.name).toBe('Button');
const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'handleClick');
expect(funcNode).toBeDefined();
expect(funcNode?.language).toBe('vue');
});
it('should extract from
`;
const result = extractFromSource('Counter.vue', code);
const componentNode = result.nodes.find((n) => n.kind === 'component');
expect(componentNode).toBeDefined();
expect(componentNode?.name).toBe('Counter');
const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'increment');
expect(funcNode).toBeDefined();
expect(funcNode?.language).toBe('vue');
// All nodes should be marked as vue language
for (const node of result.nodes) {
expect(node.language).toBe('vue');
}
});
it('should extract calls from top-level
`;
const result = extractFromSource('Issue425Setup.vue', code);
const call = result.unresolvedReferences.find(
(ref) => ref.referenceKind === 'calls' && ref.referenceName === 'getTokenMp'
);
expect(call).toBeDefined();
});
it.each(['LF', 'CRLF'])('should attribute calls in
`;
const code = ending === 'CRLF' ? lf.replace(/\n/g, '\r\n') : lf;
const result = extractFromSource('Card.vue', code);
const component = result.nodes.find((n) => n.kind === 'component')!;
const calls = result.unresolvedReferences
.filter((r) => r.referenceKind === 'calls')
.map((r) => `${r.fromNodeId === component.id ? 'component' : r.fromNodeId}:${r.referenceName}@${r.line}`)
.sort();
expect(calls).toEqual([
'component:label@3',
'component:useBar@2',
'component:useBar@2',
'component:useFoo@7',
'component:useFoo@8',
]);
});
it('should extract calls from Vue Options API object methods', () => {
const code = `
`;
const result = extractFromSource('Issue425Options.vue', code);
const calls = result.unresolvedReferences.filter(
(ref) => ref.referenceKind === 'calls' && ref.referenceName === 'getTokenMp'
);
expect(calls).toHaveLength(2);
});
it('should extract component usages from the Vue template (PascalCase + kebab, skipping built-ins) (#629)', () => {
const code = `
Click
x
`;
const result = extractFromSource('Host.vue', code);
const refs = result.unresolvedReferences
.filter((r) => r.referenceKind === 'references')
.map((r) => r.referenceName);
expect(refs).toContain('UserCard'); // PascalCase tag
expect(refs).toContain('MyButton'); // kebab β MyButton
expect(refs).not.toContain('Transition'); // Vue built-in skipped
expect(refs).not.toContain('Div'); // native HTML element skipped
expect(refs).not.toContain('Span');
});
it('should extract from both
`;
const result = extractFromSource('DualScript.vue', code);
const componentNode = result.nodes.find((n) => n.kind === 'component');
expect(componentNode).toBeDefined();
const greetFunc = result.nodes.find((n) => n.kind === 'function' && n.name === 'greet');
expect(greetFunc).toBeDefined();
});
it('should create component node for template-only Vue file', () => {
const code = `
Static content
`;
const result = extractFromSource('Static.vue', code);
const componentNode = result.nodes.find((n) => n.kind === 'component');
expect(componentNode).toBeDefined();
expect(componentNode?.name).toBe('Static');
expect(componentNode?.language).toBe('vue');
// The file and the component it is β no script nodes. (A file with no
// script used to have no file node at all.)
expect(result.nodes.map((n) => n.kind).sort()).toEqual(['component', 'file']);
expect(result.edges).toContainEqual(expect.objectContaining({ source: 'file:Static.vue', target: componentNode!.id, kind: 'contains' }));
});
it('should create containment edges from component to script nodes', () => {
const code = `
{{ value }}
`;
const result = extractFromSource('Contained.vue', code);
const componentNode = result.nodes.find((n) => n.kind === 'component');
expect(componentNode).toBeDefined();
// Should have containment edges from component to child nodes
const containEdges = result.edges.filter(
(e) => e.source === componentNode!.id && e.kind === 'contains'
);
expect(containEdges.length).toBeGreaterThan(0);
});
});
describe('Astro Extraction', () => {
it('should detect Astro files', () => {
expect(detectLanguage('src/pages/index.astro')).toBe('astro');
expect(detectLanguage('Layout.astro')).toBe('astro');
expect(isLanguageSupported('astro')).toBe(true);
});
it('should extract component node from an .astro file', () => {
const code = `---
const title = 'Hello';
---
{title}
`;
const result = extractFromSource('Card.astro', code);
const componentNode = result.nodes.find((n) => n.kind === 'component');
expect(componentNode).toBeDefined();
expect(componentNode?.name).toBe('Card');
expect(componentNode?.language).toBe('astro');
expect(componentNode?.isExported).toBe(true);
});
it('should extract frontmatter symbols with correct line numbers (#768)', () => {
const code = `---
import { formatDate } from '../utils/format';
function getIconNode(name: string): string {
return name;
}
const { title } = Astro.props;
---
{title}
`;
const result = extractFromSource('navs.astro', code);
// The #768 repro: a function defined in frontmatter must be found
const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'getIconNode');
expect(fn).toBeDefined();
expect(fn?.language).toBe('astro');
expect(fn?.startLine).toBe(4);
const imp = result.nodes.find((n) => n.kind === 'import');
expect(imp).toBeDefined();
expect(imp?.startLine).toBe(2);
});
it('should extract exported getStaticPaths from frontmatter', () => {
const code = `---
export async function getStaticPaths() {
return [];
}
const { slug } = Astro.params;
---
{slug}
`;
const result = extractFromSource('[slug].astro', code);
const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'getStaticPaths');
expect(fn).toBeDefined();
expect(fn?.isExported).toBe(true);
});
it('should extract calls from template expressions', () => {
const code = `---
import { formatDate } from '../utils/format';
const date = new Date();
---
`;
const result = extractFromSource('Stamp.astro', code);
const call = result.unresolvedReferences.find(
(ref) => ref.referenceKind === 'calls' && ref.referenceName === 'formatDate' && ref.line === 5
);
expect(call).toBeDefined();
});
it('should extract calls from a multiline expression opening line', () => {
const code = `---
const posts = [];
---
{posts.map((post) => (
- {render(post)}
))}
`;
const result = extractFromSource('List.astro', code);
const mapCall = result.unresolvedReferences.find(
(ref) => ref.referenceKind === 'calls' && ref.referenceName === 'posts.map'
);
expect(mapCall).toBeDefined();
const innerCall = result.unresolvedReferences.find(
(ref) => ref.referenceKind === 'calls' && ref.referenceName === 'render'
);
expect(innerCall).toBeDefined();
});
it('should extract PascalCase component usages from the template', () => {
const code = `---
import Layout from '../layouts/Layout.astro';
import PostCard from '../components/PostCard.astro';
---
`;
const result = extractFromSource('index.astro', code);
const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references');
const names = refs.map((r) => r.referenceName);
expect(names).toContain('Layout');
expect(names).toContain('PostCard');
// Astro built-ins and lowercase HTML are not component references
expect(names).not.toContain('Fragment');
expect(names).not.toContain('div');
});
it('should not extract template patterns from frontmatter, script, or style content', () => {
const code = `---
// inside frontmatter comment
const x = { y: maybeCall(1) };
---
real
`;
const result = extractFromSource('Guard.astro', code);
const templateRefs = result.unresolvedReferences.filter(
(r) => r.referenceKind === 'references' && r.referenceName === 'FakeComponent'
);
expect(templateRefs).toHaveLength(0);
// maybeCall/scriptCall come from the delegated TS extraction (once),
// not double-counted by the template scanner
const maybeCalls = result.unresolvedReferences.filter(
(r) => r.referenceName === 'maybeCall' && r.referenceKind === 'calls'
);
expect(maybeCalls.length).toBeLessThanOrEqual(1);
});
it('should extract
`;
const result = extractFromSource('Tracker.astro', code);
const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'trackView');
expect(fn).toBeDefined();
expect(fn?.startLine).toBe(6);
expect(fn?.language).toBe('astro');
});
it('should create component node for a frontmatter-less template-only file', () => {
const code = `Static content
`;
const result = extractFromSource('Static.astro', code);
const componentNode = result.nodes.find((n) => n.kind === 'component');
expect(componentNode).toBeDefined();
expect(componentNode?.name).toBe('Static');
expect(componentNode?.language).toBe('astro');
});
it('should treat an unclosed frontmatter fence as no frontmatter', () => {
const code = `---
const broken = true;
never closed
`;
const result = extractFromSource('Broken.astro', code);
// No TS delegation happened (the fence never closes), but the component
// node still exists and nothing throws.
const componentNode = result.nodes.find((n) => n.kind === 'component');
expect(componentNode).toBeDefined();
expect(result.nodes.find((n) => n.name === 'broken')).toBeUndefined();
});
it('should create containment edges from component to frontmatter nodes', () => {
const code = `---
const value = 42;
---
{value}
`;
const result = extractFromSource('Contained.astro', code);
const componentNode = result.nodes.find((n) => n.kind === 'component');
expect(componentNode).toBeDefined();
const containEdges = result.edges.filter(
(e) => e.source === componentNode!.id && e.kind === 'contains'
);
expect(containEdges.length).toBeGreaterThan(0);
});
});
describe('Instantiates + Decorates edge extraction', () => {
it('emits an instantiates ref for `new Foo()`', () => {
const code = `
class Foo {}
function bootstrap() { return new Foo(); }
`;
const result = extractFromSource('app.ts', code);
const ref = result.unresolvedReferences.find(
(r) => r.referenceKind === 'instantiates' && r.referenceName === 'Foo'
);
expect(ref).toBeDefined();
});
it('strips type-argument suffix from generic constructors', () => {
const code = `
class Container { constructor(_: T) {} }
function go() { return new Container('x'); }
`;
const result = extractFromSource('app.ts', code);
const ref = result.unresolvedReferences.find(
(r) => r.referenceKind === 'instantiates'
);
expect(ref).toBeDefined();
// Container must be normalised to "Container" β otherwise
// resolution can never match the class node.
expect(ref!.referenceName).toBe('Container');
});
it('keeps trailing identifier from qualified `new ns.Foo()`', () => {
const code = `
const ns = { Foo: class {} };
function go() { return new ns.Foo(); }
`;
const result = extractFromSource('app.ts', code);
const ref = result.unresolvedReferences.find(
(r) => r.referenceKind === 'instantiates'
);
// We can't always resolve which Foo, but the name should be the
// simple identifier so name-matching has a chance.
expect(ref?.referenceName).toBe('Foo');
});
it('emits a decorates ref for `@Foo class X {}`', () => {
const code = `
function Foo(_arg: string) { return (cls: any) => cls; }
@Foo('x')
class X {}
`;
const result = extractFromSource('app.ts', code);
const decorClass = result.unresolvedReferences.find(
(r) => r.referenceKind === 'decorates' && r.referenceName === 'Foo'
);
expect(decorClass).toBeDefined();
});
it('does NOT attribute a prior class\'s decorator to the next class', () => {
// Regression: the sibling-walk must stop at the first non-
// decorator separator. `@A class Foo {} @B class Bar {}` must
// produce `decorates(Foo, A)` and `decorates(Bar, B)` β never
// `decorates(Bar, A)`.
const code = `
function A(cls: any) { return cls; }
function B(cls: any) { return cls; }
@A
class Foo {}
@B
class Bar {}
`;
const result = extractFromSource('app.ts', code);
const decoratesEdges = result.unresolvedReferences.filter(
(r) => r.referenceKind === 'decorates'
);
// Exactly one decorates ref per decorated class, no cross-attribution.
const fromBar = decoratesEdges.filter((r) =>
result.nodes.find((n) => n.id === r.fromNodeId && n.name === 'Bar')
);
expect(fromBar.length).toBe(1);
expect(fromBar[0]!.referenceName).toBe('B');
});
it('emits a decorates ref for `@Foo method() {}`', () => {
const code = `
function Get(p: string) { return (t: any, k: string) => t; }
class Svc {
@Get('/x') method() { return 1; }
}
`;
const result = extractFromSource('app.ts', code);
const decorMethod = result.unresolvedReferences.find(
(r) => r.referenceKind === 'decorates' && r.referenceName === 'Get'
);
expect(decorMethod).toBeDefined();
// The decorated symbol must be `method`, not the constructor or class.
const decoratedNode = result.nodes.find((n) => n.id === decorMethod!.fromNodeId);
expect(decoratedNode?.name).toBe('method');
});
});
// =============================================================================
// Lua
// =============================================================================
describe('Lua Extraction', () => {
describe('Language detection', () => {
it('should detect Lua files', () => {
expect(detectLanguage('init.lua')).toBe('lua');
expect(detectLanguage('src/util.lua')).toBe('lua');
});
it('should report Lua as supported', () => {
expect(isLanguageSupported('lua')).toBe(true);
expect(getSupportedLanguages()).toContain('lua');
});
});
describe('Function extraction', () => {
it('should extract global and local functions', () => {
const code = `
function configure(opts) return opts end
local function helper(x) return x * 2 end
`;
const result = extractFromSource('init.lua', code);
const funcs = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
expect(funcs).toContain('configure');
expect(funcs).toContain('helper');
const configure = result.nodes.find((n) => n.name === 'configure');
expect(configure?.language).toBe('lua');
expect(configure?.signature).toBe('(opts)');
});
it('should split table/method functions into a receiver and method name', () => {
const code = `
function M.connect(host, port) return host end
function M:send(data) return self end
`;
const result = extractFromSource('init.lua', code);
const methods = result.nodes.filter((n) => n.kind === 'method');
const connect = methods.find((m) => m.name === 'connect');
expect(connect?.qualifiedName).toBe('M::connect');
const send = methods.find((m) => m.name === 'send');
expect(send?.qualifiedName).toBe('M::send');
});
it('should name function expressions from local, member, and table-field bindings', () => {
const code = `
local function helper() return 1 end
local localFn = function() return helper() end
local M = {
callbacks = {
onStart = function() return helper() end,
["onStop"] = function() return helper() end,
[DYNAMIC] = function() return helper() end,
},
}
M.assignedFn = function() return helper() end
M["bracketFn"] = function() return helper() end
localFn()
`;
const result = extractFromSource('handlers.lua', code);
const localFn = result.nodes.find((n) => n.kind === 'function' && n.name === 'localFn');
const assignedFn = result.nodes.find(
(n) => n.kind === 'method' && n.qualifiedName === 'M::assignedFn'
);
const onStart = result.nodes.find(
(n) => n.kind === 'method' && n.qualifiedName === 'M.callbacks::onStart'
);
const onStop = result.nodes.find(
(n) => n.kind === 'method' && n.qualifiedName === 'M.callbacks::onStop'
);
const bracketFn = result.nodes.find(
(n) => n.kind === 'method' && n.qualifiedName === 'M::bracketFn'
);
expect(localFn).toBeDefined();
expect(assignedFn).toBeDefined();
expect(onStart).toBeDefined();
expect(onStop).toBeDefined();
expect(bracketFn).toBeDefined();
expect(result.nodes.some((n) => n.name === 'DYNAMIC')).toBe(false);
expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'localFn')).toBe(false);
for (const callable of [localFn, assignedFn, onStart, onStop, bracketFn]) {
expect(
result.unresolvedReferences.some(
(r) => r.fromNodeId === callable!.id && r.referenceKind === 'calls' && r.referenceName === 'helper'
)
).toBe(true);
}
expect(
result.unresolvedReferences.some(
(r) => r.referenceKind === 'calls' && r.referenceName === 'localFn'
)
).toBe(true);
});
});
describe('Variable extraction', () => {
it('should extract local variable declarations', () => {
const code = `
local M = {}
local count = 0
`;
const result = extractFromSource('mod.lua', code);
const vars = result.nodes.filter((n) => n.kind === 'variable').map((n) => n.name);
expect(vars).toContain('M');
expect(vars).toContain('count');
});
});
describe('Import extraction (require)', () => {
it('should extract require() in local declarations and bare calls', () => {
const code = `
local socket = require("socket")
local http = require "resty.http"
require("side.effect")
`;
const result = extractFromSource('net.lua', code);
const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
expect(imports).toContain('socket');
expect(imports).toContain('resty.http');
expect(imports).toContain('side.effect');
const ref = result.unresolvedReferences.find(
(r) => r.referenceKind === 'imports' && r.referenceName === 'socket'
);
expect(ref).toBeDefined();
});
// Regression: the tree-sitter-wasms Lua grammar (ABI 13) corrupts the shared
// WASM heap under web-tree-sitter 0.25, dropping nested calls/imports on every
// parse after the first. We vendor the ABI-15 grammar instead β this guards it
// by extracting several sources in sequence and asserting the LAST still works.
it('should keep extracting require across many sequential parses', () => {
let last;
for (let i = 0; i < 8; i++) {
last = extractFromSource(`f${i}.lua`, `local m = require("module.${i}")\nreturn m\n`);
}
const imports = last!.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
expect(imports).toContain('module.7');
});
});
describe('Call extraction', () => {
it('should record intra-file calls as resolvable references', () => {
const code = `
local function helper(x) return x end
local function run(y) return helper(y) end
`;
const result = extractFromSource('calls.lua', code);
const call = result.unresolvedReferences.find(
(r) => r.referenceKind === 'calls' && r.referenceName === 'helper'
);
expect(call).toBeDefined();
});
});
});
// =============================================================================
// Luau (typed superset of Lua β https://luau.org)
// =============================================================================
describe('Luau Extraction', () => {
describe('Language detection', () => {
it('should detect Luau files', () => {
expect(detectLanguage('init.luau')).toBe('luau');
expect(detectLanguage('src/Client.luau')).toBe('luau');
});
it('should report Luau as supported', () => {
expect(isLanguageSupported('luau')).toBe(true);
expect(getSupportedLanguages()).toContain('luau');
});
});
describe('Type aliases', () => {
it('should extract `type` and `export type` definitions', () => {
const code = `
export type Vector = { x: number, y: number }
type Handler = (msg: string) -> boolean
`;
const result = extractFromSource('types.luau', code);
const aliases = result.nodes.filter((n) => n.kind === 'type_alias');
const vector = aliases.find((a) => a.name === 'Vector');
expect(vector).toBeDefined();
expect(vector?.isExported).toBe(true);
const handler = aliases.find((a) => a.name === 'Handler');
expect(handler).toBeDefined();
expect(handler?.isExported).toBe(false);
});
});
describe('Typed functions and methods', () => {
it('should capture typed signatures and split methods by receiver', () => {
const code = `
function configure(opts: { debug: boolean }): boolean
return opts.debug
end
function Client:fetch(path: string): Response
return path
end
`;
const result = extractFromSource('client.luau', code);
const configure = result.nodes.find((n) => n.kind === 'function' && n.name === 'configure');
expect(configure?.language).toBe('luau');
expect(configure?.signature).toBe('(opts: { debug: boolean }): boolean');
const fetch = result.nodes.find((n) => n.kind === 'method' && n.name === 'fetch');
expect(fetch?.qualifiedName).toBe('Client::fetch');
});
});
describe('Imports and variables', () => {
it('should extract string and Roblox instance-path require imports', () => {
const code = `
local http = require("http")
local Signal = require(script.Parent.Signal)
local count = 0
`;
const result = extractFromSource('mod.luau', code);
const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
expect(imports).toContain('http'); // string require
expect(imports).toContain('Signal'); // Roblox instance-path require
const vars = result.nodes.filter((n) => n.kind === 'variable').map((n) => n.name);
expect(vars).toContain('count');
});
});
});
// =============================================================================
// Objective-C
// =============================================================================
describe('Objective-C Extraction', () => {
const sample = `
#import
#import "MyClass.h"
@interface MyClass : NSObject
@property (nonatomic, copy) NSString *name;
- (void)greet;
- (void)doThing:(id)x with:(id)y;
+ (instancetype)shared;
@end
@implementation MyClass
- (void)greet {
NSLog(@"Hello");
[self doWork];
}
- (void)doThing:(id)x with:(id)y {
[self notify:x];
}
+ (instancetype)shared {
return [[MyClass alloc] init];
}
@end
void helperFunction(int count) {
MyClass *obj = [MyClass shared];
[obj greet];
}
`;
it('should extract classes, methods, functions, and imports', () => {
const result = extractFromSource('App.m', sample);
const classes = result.nodes.filter((n) => n.kind === 'class');
expect(classes.filter((c) => c.name === 'MyClass')).toHaveLength(1);
const methods = result.nodes.filter((n) => n.kind === 'method');
expect(methods.map((m) => m.name).sort()).toEqual(['doThing:with:', 'greet', 'shared']);
const shared = methods.find((m) => m.name === 'shared');
expect(shared?.isStatic).toBe(true);
const properties = result.nodes.filter((n) => n.kind === 'property');
expect(properties.some((p) => p.name === 'name')).toBe(true);
const functions = result.nodes.filter((n) => n.kind === 'function');
expect(functions.some((f) => f.name === 'helperFunction')).toBe(true);
const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
expect(imports).toContain('Foundation/Foundation.h');
expect(imports).toContain('MyClass.h');
});
it('extracts union declarations as first-class union nodes', () => {
const code = `
typedef union {
unsigned int raw;
float value;
} NumberBits;
union opaque_bits;
`;
const result = extractFromSource('NumberBits.m', code);
const numberBits = result.nodes.find((n) => n.name === 'NumberBits');
expect(numberBits?.kind).toBe('union');
expect(result.nodes.some((n) => n.name === 'opaque_bits')).toBe(false);
});
it('should record inheritance and protocol conformance', () => {
const result = extractFromSource('App.m', sample);
const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends');
const implementsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'implements');
expect(extendsRefs.map((r) => r.referenceName)).toContain('NSObject');
expect(implementsRefs.map((r) => r.referenceName)).toContain('NSCopying');
});
it('should record message sends and C calls', () => {
const result = extractFromSource('App.m', sample);
const calls = result.unresolvedReferences
.filter((r) => r.referenceKind === 'calls')
.map((r) => r.referenceName);
expect(calls).toEqual(expect.arrayContaining(['NSLog', 'doWork', 'MyClass.shared', 'obj.greet']));
});
it('should reconstruct multi-keyword selectors at the call site so they resolve to the method definition', () => {
// Regression for the gap discovered post-#165: message_expression's
// multi-keyword form `[obj a:1 b:2]` was only emitting the first keyword,
// so calls never resolved to multi-part method definitions like
// `GET:parameters:headers:progress:success:failure:`. The call-site name
// must match the method-definition name with full keywords + trailing colons.
const code = `
@implementation Caller
- (void)demo {
NSMutableDictionary *d = [NSMutableDictionary new];
[d setObject:@"v" forKey:@"k"];
[d setObject:@"v2" forKey:@"k2" withRetry:@YES];
[self touchesBegan:nil withEvent:nil];
}
@end
`;
const result = extractFromSource('Caller.m', code);
const calls = result.unresolvedReferences
.filter((r) => r.referenceKind === 'calls')
.map((r) => r.referenceName);
expect(calls).toEqual(
expect.arrayContaining([
'd.setObject:forKey:',
'd.setObject:forKey:withRetry:',
'touchesBegan:withEvent:',
])
);
});
it('should not classify pure C headers with @end in comments as objc', () => {
const cHeader = '/* @end of file */\n#ifndef STDIO_H\nvoid printf(const char *);\n#endif\n';
expect(detectLanguage('stdio.h', cHeader)).toBe('c');
});
it('should extract protocol declarations', () => {
const code = `
@protocol DataSource
- (NSInteger)numberOfItems;
@end
`;
const result = extractFromSource('DataSource.h', code);
const protocol = result.nodes.find((n) => n.kind === 'protocol' && n.name === 'DataSource');
expect(protocol).toBeDefined();
});
it('should report Objective-C as supported', () => {
expect(isLanguageSupported('objc')).toBe(true);
expect(getSupportedLanguages()).toContain('objc');
});
});
describe('Solidity Extraction', () => {
const code = `// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IVault {
function deposit(uint256 amount) external returns (bool);
event Deposited(address indexed user, uint256 amount);
}
library SafeMath {
function add(uint256 a, uint256 b) internal pure returns (uint256) {
return a + b;
}
}
contract Vault is IVault {
using SafeMath for uint256;
enum Status { Active, Frozen, Closed }
struct UserInfo {
uint256 balance;
uint256 lastDeposit;
}
IERC20 public immutable token;
mapping(address => UserInfo) public users;
address public owner;
event Withdrawn(address indexed user, uint256 amount);
error NotOwner();
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
constructor(address _token) {
token = IERC20(_token);
owner = msg.sender;
}
function deposit(uint256 amount) external override returns (bool) {
users[msg.sender].balance = users[msg.sender].balance.add(amount);
emit Deposited(msg.sender, amount);
return true;
}
function withdraw(uint256 amount) external onlyOwner {
emit Withdrawn(msg.sender, amount);
}
}
`;
describe('Language detection', () => {
it('should detect Solidity files', () => {
expect(detectLanguage('contracts/Vault.sol')).toBe('solidity');
});
it('should report Solidity as supported', () => {
expect(isLanguageSupported('solidity')).toBe(true);
expect(getSupportedLanguages()).toContain('solidity');
});
});
describe('Container extraction', () => {
it('should extract contract / interface / library as class-likes', () => {
const result = extractFromSource('Vault.sol', code);
// interface_declaration β interface
const iface = result.nodes.find((n) => n.kind === 'interface' && n.name === 'IVault');
expect(iface).toBeDefined();
expect(iface?.language).toBe('solidity');
// contract and library both map to 'class' (library has no special semantics
// a class node doesn't already cover β they share methodTypes/inheritance).
expect(result.nodes.find((n) => n.kind === 'class' && n.name === 'Vault')).toBeDefined();
expect(result.nodes.find((n) => n.kind === 'class' && n.name === 'SafeMath')).toBeDefined();
});
it('should emit extends references for `is X, Y` inheritance', () => {
// `Vault is IVault` β Solidity uses one keyword (`is`) for both class
// extension and interface implementation, so the extractor emits `extends`
// and the resolver's interface-impl synthesizer reclassifies to
// `implements` based on the target node kind.
const result = extractFromSource('Vault.sol', code);
const extendsRefs = result.unresolvedReferences.filter(
(r) => r.referenceKind === 'extends' && r.referenceName === 'IVault'
);
expect(extendsRefs).toHaveLength(1);
const vaultNode = result.nodes.find((n) => n.kind === 'class' && n.name === 'Vault');
expect(extendsRefs[0]?.fromNodeId).toBe(vaultNode?.id);
});
});
describe('Method extraction', () => {
it('should extract methods, modifiers, and constructor with signatures', () => {
const result = extractFromSource('Vault.sol', code);
const methods = result.nodes.filter((n) => n.kind === 'method');
const names = methods.map((n) => n.name);
expect(names).toContain('deposit');
expect(names).toContain('withdraw');
expect(names).toContain('add');
expect(names).toContain('onlyOwner'); // modifier_definition
expect(names).toContain('constructor'); // constructor_definition (synthetic name)
// Signature should capture parameters + visibility + state mutability + return type.
const add = methods.find((m) => m.name === 'add');
expect(add?.signature).toContain('uint256 a');
expect(add?.signature).toContain('internal');
expect(add?.signature).toContain('pure');
expect(add?.signature).toContain('returns (uint256)');
// `external` visibility should map to 'public' (callable from outside the contract).
const deposit = methods.find((m) => m.name === 'deposit');
expect(deposit?.visibility).toBe('public');
});
});
describe('Struct, enum, and field extraction', () => {
it('should extract struct, enum, and enum members', () => {
const result = extractFromSource('Vault.sol', code);
expect(result.nodes.find((n) => n.kind === 'struct' && n.name === 'UserInfo')).toBeDefined();
expect(result.nodes.find((n) => n.kind === 'enum' && n.name === 'Status')).toBeDefined();
const enumMembers = result.nodes.filter((n) => n.kind === 'enum_member').map((n) => n.name);
expect(enumMembers).toEqual(expect.arrayContaining(['Active', 'Frozen', 'Closed']));
});
it('should extract state variables, struct members, events, errors as fields', () => {
const result = extractFromSource('Vault.sol', code);
const fieldNames = result.nodes.filter((n) => n.kind === 'field').map((n) => n.name);
// state variables
expect(fieldNames).toEqual(expect.arrayContaining(['token', 'users', 'owner']));
// struct members
expect(fieldNames).toEqual(expect.arrayContaining(['balance', 'lastDeposit']));
// event + error
expect(fieldNames).toEqual(expect.arrayContaining(['Deposited', 'Withdrawn', 'NotOwner']));
});
it('should treat `constant_variable_declaration` as a constant, not a variable', () => {
const constCode = `// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
uint256 constant FILE_CONST = 42;
`;
const result = extractFromSource('consts.sol', constCode);
const node = result.nodes.find((n) => n.name === 'FILE_CONST');
expect(node?.kind).toBe('constant');
});
});
describe('Import and call extraction', () => {
it('should extract import directives with the source path as the module name', () => {
const result = extractFromSource('Vault.sol', code);
const imp = result.nodes.find((n) => n.kind === 'import');
expect(imp).toBeDefined();
expect(imp?.name).toBe('@openzeppelin/contracts/token/ERC20/IERC20.sol');
});
it('should produce calls refs for emit, revert, and library/method calls', () => {
const result = extractFromSource('Vault.sol', code);
const calls = result.unresolvedReferences
.filter((r) => r.referenceKind === 'calls')
.map((r) => r.referenceName);
// emit Deposited(...)
expect(calls).toContain('Deposited');
// revert NotOwner()
expect(calls).toContain('NotOwner');
// library call: balance.add(amount) β receiver-qualified
expect(calls.some((c) => c === 'add' || c === 'balance.add')).toBe(true);
});
it('should produce calls refs for modifier invocations and base-constructor invocations', () => {
// `withdraw(...) external onlyOwner` β the modifier sits in the function
// header, outside the body: field the call walker descends, so it goes
// through the decorator-position walk. It must emit `calls` (not
// `decorates`) so flow traversal rides the withdraw β onlyOwner β
// NotOwner audit path.
const result = extractFromSource('Vault.sol', code);
const withdrawNode = result.nodes.find((n) => n.kind === 'method' && n.name === 'withdraw');
const modifierCall = result.unresolvedReferences.find(
(r) => r.referenceKind === 'calls' && r.referenceName === 'onlyOwner'
);
expect(modifierCall).toBeDefined();
expect(modifierCall?.fromNodeId).toBe(withdrawNode?.id);
// Base-constructor invocation parses as the same modifier_invocation
// node: `constructor(address o) ERC20("T", "TOK") Ownable(o)` β the
// constructor-chain hop.
const ctorCode = `pragma solidity ^0.8.20;
contract MyToken is ERC20, Ownable {
constructor(address o) ERC20("Tok", "TOK") Ownable(o) {}
function grab(bytes32 r) external onlyRole(ADMIN_ROLE) returns (uint256) { return 1; }
}
`;
const ctorResult = extractFromSource('MyToken.sol', ctorCode);
const ctorCalls = ctorResult.unresolvedReferences
.filter((r) => r.referenceKind === 'calls')
.map((r) => r.referenceName);
expect(ctorCalls).toEqual(expect.arrayContaining(['ERC20', 'Ownable']));
// modifier WITH arguments still resolves to the bare modifier name
expect(ctorCalls).toContain('onlyRole');
});
});
});
describe('Regression: issue-specific extraction fixes', () => {
it('indexes inner functions of an anonymous AMD/CommonJS module wrapper (#528)', () => {
const code = `
define(['dep'], function (dep) {
function innerHelper(x) { return x + 1; }
function compute(y) { return innerHelper(y); }
return { compute: compute };
});
`;
const result = extractFromSource('amd-module.js', code);
const fns = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
expect(fns).toContain('innerHelper');
expect(fns).toContain('compute');
});
it('attaches Go methods on generic receivers to their type (#583)', () => {
const code = `
package main
type Stack[T any] struct { items []T }
func (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }
func (s Stack[T]) Len() int { return len(s.items) }
`;
const result = extractFromSource('stack.go', code);
const methods = result.nodes.filter((n) => n.kind === 'method');
expect(methods.find((m) => m.name === 'Push')?.qualifiedName).toBe('Stack::Push');
expect(methods.find((m) => m.name === 'Len')?.qualifiedName).toBe('Stack::Len');
});
it('indexes new module extensions: .mts/.cts (TS) and .xsjs/.xsjslib (JS) (#366, #556)', () => {
expect(isSourceFile('mod.mts')).toBe(true);
expect(isSourceFile('mod.cts')).toBe(true);
expect(isSourceFile('service.xsjs')).toBe(true);
expect(isSourceFile('lib.xsjslib')).toBe(true);
expect(detectLanguage('mod.mts')).toBe('typescript');
expect(detectLanguage('service.xsjs')).toBe('javascript');
// End-to-end: a .mts file is parsed as TS, a .xsjs file as JS.
const ts = extractFromSource('mod.mts', 'export function hello(): number { return 1; }');
expect(ts.nodes.find((n) => n.name === 'hello' && n.kind === 'function')).toBeDefined();
const js = extractFromSource('service.xsjs', 'function handleRequest() { return 1; }');
expect(js.nodes.find((n) => n.name === 'handleRequest' && n.kind === 'function')).toBeDefined();
});
});
describe('Import / re-export dependency linking (blast-radius recall)', () => {
// An import IS a dependency, but extraction only emits references for calls,
// instantiations, type annotations, and inheritance β so a symbol imported and
// then merely re-exported, placed in a registry array, passed as an argument,
// or used in JSX produced no cross-file edge, leaving the providing file with a
// false "0 dependents". These tests pin the import/re-export binding linking.
it('emits an imports reference per named, aliased, and default import binding', () => {
const code = `
import { widget, helper as h } from './foo';
import Thing from './thing';
import * as NS from './ns';
export const registry = [widget];
`;
const result = extractFromSource('bar.ts', code);
const names = result.unresolvedReferences
.filter((r) => r.referenceKind === 'imports')
.map((r) => r.referenceName);
expect(names).toContain('widget'); // named import β local name
expect(names).toContain('h'); // aliased import β local alias
expect(names).toContain('Thing'); // default import
expect(names).toContain('NS'); // namespace import β linked to the module file as a dependency
});
it('emits an imports reference per re-exported binding', () => {
const result = extractFromSource('barrel.ts', `export { alpha, beta as b } from './source';`);
const names = result.unresolvedReferences
.filter((r) => r.referenceKind === 'imports')
.map((r) => r.referenceName);
// Re-export links the SOURCE-side name, not the local alias.
expect(names).toContain('alpha');
expect(names).toContain('beta');
});
it('a re-export imports its source module, as an import statement does', () => {
const result = extractFromSource(
'barrel.ts',
`export * from './star';\nexport { alpha } from './named';\nexport * as ns from './space';\n`
);
const modules = ['./star', './named', './space'];
expect(result.nodes.filter((n) => n.kind === 'import').map((n) => n.name)).toEqual(modules);
const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'imports').map((r) => r.referenceName);
for (const module of modules) expect(refs).toContain(module);
expect(refs).toContain('alpha');
});
it('a barrel that only re-exports everything is a dependent of the module', async () => {
const dir = createTempDir();
try {
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(path.join(dir, 'src', 'foo.ts'), `export function helper(): void {}\n`);
fs.writeFileSync(path.join(dir, 'src', 'index.ts'), `export * from './foo';\n`);
const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.ts'], exclude: [] } });
await cg.indexAll();
cg.resolveReferences();
expect(cg.getFileDependents('src/foo.ts')).toContain('src/index.ts');
cg.destroy();
} finally {
cleanupTempDir(dir);
}
});
it('a value imported/re-exported but never called still makes the importer a dependent', async () => {
const dir = createTempDir();
try {
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'foo.ts'),
`export const widget = { n: 1 };\nexport function helper(): void {}\n`
);
// bar uses widget ONLY in an array and re-exports helper β neither is
// called/typed, so before import-linking bar had no edge to foo at all.
fs.writeFileSync(
path.join(dir, 'src', 'bar.ts'),
`import { widget } from './foo';\nexport { helper } from './foo';\nexport const registry = [widget];\n`
);
const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.ts'], exclude: [] } });
await cg.indexAll();
cg.resolveReferences();
expect(cg.getFileDependents('src/foo.ts')).toContain('src/bar.ts');
cg.destroy();
} finally {
cleanupTempDir(dir);
}
});
it('a namespace import touched only via a value-member read still links the module file', async () => {
const dir = createTempDir();
try {
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(path.join(dir, 'src', 'foo.ts'), `export const SOME_CONST = 42;\n`);
// `foo` is imported as a namespace and used ONLY via a value-member read
// (no call, no type) β `foo.helper()` would link on its own, but a bare
// `foo.SOME_CONST` would not, so the module-import backstop must link it.
fs.writeFileSync(path.join(dir, 'src', 'bar.ts'), `import * as foo from './foo';\nexport const x = foo.SOME_CONST;\n`);
const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.ts'], exclude: [] } });
await cg.indexAll();
cg.resolveReferences();
expect(cg.getFileDependents('src/foo.ts')).toContain('src/bar.ts');
cg.destroy();
} finally {
cleanupTempDir(dir);
}
});
});
describe('Python import dependency linking (blast-radius recall)', () => {
// Same recall gap as TS: Python only linked called/instantiated imports, so a
// name brought in with `from module import X` and then merely stored, used as
// a decorator/argument, or re-exported through an `__init__.py` produced no
// cross-file edge β the providing module showed a false "0 dependents".
it('emits an imports reference per name in a `from module import ...` (incl. value/aliased)', () => {
const code = [
'from foo import helper, widget',
'from foo import Thing as T',
'from . import sibling',
'from bar import *',
].join('\n');
const names = extractFromSource('mod.py', code)
.unresolvedReferences.filter((r) => r.referenceKind === 'imports')
.map((r) => r.referenceName);
expect(names).toContain('helper');
expect(names).toContain('widget'); // value import
expect(names).toContain('T'); // aliased import β local name
expect(names).toContain('sibling'); // `from . import `
expect(names).not.toContain('*'); // wildcard import has no names
});
it('a Python value imported but never called still makes the importer a dependent', async () => {
const dir = createTempDir();
try {
fs.mkdirSync(path.join(dir, 'pkg'), { recursive: true });
fs.writeFileSync(path.join(dir, 'pkg', 'foo.py'), `widget = {"n": 1}\ndef helper():\n return 1\n`);
// bar imports widget+helper but only stores widget in a list β nothing is
// called, so before import-linking bar had no edge to foo.
fs.writeFileSync(path.join(dir, 'pkg', 'bar.py'), `from foo import widget, helper\nregistry = [widget]\n`);
const cg = CodeGraph.initSync(dir, { config: { include: ['pkg/**/*.py'], exclude: [] } });
await cg.indexAll();
cg.resolveReferences();
expect(cg.getFileDependents('pkg/foo.py')).toContain('pkg/bar.py');
cg.destroy();
} finally {
cleanupTempDir(dir);
}
});
it('resolves `from . import submodule` + `submodule.func()` to the submodule', async () => {
const dir = createTempDir();
try {
fs.mkdirSync(path.join(dir, 'pkg'), { recursive: true });
fs.writeFileSync(path.join(dir, 'pkg', '__init__.py'), '');
fs.writeFileSync(path.join(dir, 'pkg', 'certs.py'), `def where():\n return "/ca.pem"\n`);
// certs is an imported MODULE (a file), and certs.where() is a qualified
// call through it β the receiver isn't a symbol, so plain name-matching
// can't link it. Also exercises the Python relative-dot path fix (`.certs`).
fs.writeFileSync(path.join(dir, 'pkg', 'utils.py'), `from . import certs\ndef go():\n return certs.where()\n`);
const cg = CodeGraph.initSync(dir, { config: { include: ['pkg/**/*.py'], exclude: [] } });
await cg.indexAll();
cg.resolveReferences();
expect(cg.getFileDependents('pkg/certs.py')).toContain('pkg/utils.py');
cg.destroy();
} finally {
cleanupTempDir(dir);
}
});
it('a module import is a dependency even when the used member is re-exported elsewhere', async () => {
const dir = createTempDir();
try {
fs.mkdirSync(path.join(dir, 'pkg'), { recursive: true });
fs.writeFileSync(path.join(dir, 'pkg', '__init__.py'), '');
// `where` is NOT defined in certs.py (re-exported from a 3rd-party pkg), so
// member resolution can't find it β the module-import backstop must still
// record utils -> certs. (Mirrors requests' real `certs.where`.)
fs.writeFileSync(path.join(dir, 'pkg', 'certs.py'), `from external_ca import where\n`);
fs.writeFileSync(path.join(dir, 'pkg', 'utils.py'), `from . import certs\nCA = certs.where()\n`);
const cg = CodeGraph.initSync(dir, { config: { include: ['pkg/**/*.py'], exclude: [] } });
await cg.indexAll();
cg.resolveReferences();
expect(cg.getFileDependents('pkg/certs.py')).toContain('pkg/utils.py');
cg.destroy();
} finally {
cleanupTempDir(dir);
}
});
});
describe('Go cross-package composite literals (blast-radius recall)', () => {
// Go function calls and type references across packages already resolved, but
// struct composite literals β `render.XML{...}` / `pkga.Widget{...}` β were not
// extracted at all, so a package whose types are only INSTANTIATED elsewhere
// (gin's render/binding implementations) showed 0 dependents.
it('links a cross-package struct composite literal to the defining package', async () => {
const dir = createTempDir();
try {
fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
fs.mkdirSync(path.join(dir, 'render'), { recursive: true });
fs.writeFileSync(path.join(dir, 'render', 'xml.go'), `package render\n\ntype XML struct { Data any }\n`);
fs.writeFileSync(path.join(dir, 'app.go'), `package main\n\nimport "example.com/proj/render"\n\nfunc handle() any { return render.XML{} }\n`);
const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
await cg.indexAll();
cg.resolveReferences();
expect(cg.getFileDependents('render/xml.go')).toContain('app.go');
cg.destroy();
} finally {
cleanupTempDir(dir);
}
});
it('links a composite literal in a package-level var registry', async () => {
const dir = createTempDir();
try {
fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
fs.mkdirSync(path.join(dir, 'render'), { recursive: true });
fs.writeFileSync(path.join(dir, 'render', 'xml.go'), `package render\n\ntype XML struct {}\nfunc (XML) Render() {}\n`);
// The implementation is registered only in a top-level `var registry = {...}`
// map literal β the body walker doesn't cover top-level declarations, so this
// exercises the var-initializer walking added for Go.
fs.writeFileSync(path.join(dir, 'reg.go'), `package main\n\nimport "example.com/proj/render"\n\ntype R interface { Render() }\n\nvar registry = map[string]R{ "xml": render.XML{} }\n`);
const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
await cg.indexAll();
cg.resolveReferences();
expect(cg.getFileDependents('render/xml.go')).toContain('reg.go');
cg.destroy();
} finally {
cleanupTempDir(dir);
}
});
it('attributes a call inside a top-level closure (cobra RunE) to the var, not the file (#693)', async () => {
const dir = createTempDir();
try {
fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
// Wire is called ONLY from the anonymous RunE closure inside a top-level
// `var rootCmd = &Cmd{...}` β previously the call leaked to the file node,
// so `callers(Wire)` surfaced a file (or read as "no caller"). It must now
// attribute to the enclosing var.
fs.writeFileSync(path.join(dir, 'factory.go'), `package main\n\nfunc Wire() error { return nil }\n`);
fs.writeFileSync(
path.join(dir, 'root.go'),
`package main\n\ntype Cmd struct{ RunE func() error }\n\nvar rootCmd = &Cmd{\n\tRunE: func() error { return Wire() },\n}\n`
);
const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
await cg.indexAll();
cg.resolveReferences();
const wire = cg.getNodesByName('Wire').find((n) => n.kind === 'function');
expect(wire).toBeDefined();
const callers = cg.getCallers(wire!.id).map((c) => c.node);
expect(callers.some((n) => n.kind === 'variable' && n.name === 'rootCmd')).toBe(true);
expect(callers.some((n) => n.kind === 'file')).toBe(false);
cg.destroy();
} finally {
cleanupTempDir(dir);
}
});
it('links a parenthesized pointer type conversion `(*T)(x)` to the type', async () => {
const dir = createTempDir();
try {
fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
fs.writeFileSync(path.join(dir, 'types.go'), `package main\n\ntype Wrapped struct { N int }\n`);
// `(*Wrapped)(x)` parses as a call whose callee is the parenthesized type
// `(*Wrapped)` β without normalization it dropped on the floor.
fs.writeFileSync(path.join(dir, 'use.go'), `package main\n\nfunc run(x *int) { _ = (*Wrapped)(x) }\n`);
const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
await cg.indexAll();
cg.resolveReferences();
expect(cg.getFileDependents('types.go')).toContain('use.go');
cg.destroy();
} finally {
cleanupTempDir(dir);
}
});
it('links an implementation reached only through a Go interface (implicit satisfaction, #584)', async () => {
const dir = createTempDir();
try {
fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
fs.mkdirSync(path.join(dir, 'codec'), { recursive: true });
fs.writeFileSync(path.join(dir, 'codec', 'api.go'), `package codec\n\ntype Core interface {\n\tMarshal(v any) ([]byte, error)\n}\n\nvar API Core\n`);
// jsonApi satisfies Core structurally (no `implements` keyword) and is
// reached ONLY through the interface (API.Marshal). Without implicit
// interface satisfaction + dispatch, json.go shows 0 dependents.
fs.writeFileSync(path.join(dir, 'codec', 'json.go'), `package codec\n\ntype jsonApi struct{}\n\nfunc (j jsonApi) Marshal(v any) ([]byte, error) { return nil, nil }\n\nfunc init() { API = jsonApi{} }\n`);
const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
await cg.indexAll();
cg.resolveReferences();
expect(cg.getFileDependents('codec/json.go')).toContain('codec/api.go');
cg.destroy();
} finally {
cleanupTempDir(dir);
}
});
});
describe('C# records (blast-radius recall)', () => {
// Records are ubiquitous in modern C# (DTOs, value objects, CQRS messages),
// but `record` / `record struct` declarations weren't extracted as types β so
// every reference, generic-type-argument, and `new` of a record dropped on the
// floor and the defining file showed 0 dependents. (#237)
it('extracts a record as a graph node (record class + record struct)', () => {
const r = extractFromSource('r.cs', `namespace P;\npublic record Box(int N);\npublic record struct Pt(int X);\n`);
expect(r.nodes.find((n) => n.name === 'Box' && (n.kind === 'class' || n.kind === 'struct'))).toBeDefined();
expect(r.nodes.find((n) => n.name === 'Pt' && (n.kind === 'class' || n.kind === 'struct'))).toBeDefined();
});
it('resolves references / instantiations of a record across files', async () => {
const dir = createTempDir();
try {
fs.writeFileSync(path.join(dir, 'types.cs'), `namespace P;\npublic record Box(int N);\n`);
// Box is used as a generic type argument and instantiated β both require
// Box to be a node to resolve.
fs.writeFileSync(
path.join(dir, 'use.cs'),
`using System.Collections.Generic;\nnamespace P;\npublic class User {\n public IEnumerable Boxes { get; }\n public Box Make() => new Box(1);\n}\n`
);
const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.cs'], exclude: [] } });
await cg.indexAll();
cg.resolveReferences();
expect(cg.getFileDependents('types.cs')).toContain('use.cs');
cg.destroy();
} finally {
cleanupTempDir(dir);
}
});
});
describe('Rust cross-module recall', () => {
function rustProject(files: Record): string {
const dir = createTempDir();
fs.writeFileSync(path.join(dir, 'Cargo.toml'), '[package]\nname = "proj"\nversion = "0.1.0"\nedition = "2021"\n');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
for (const [rel, content] of Object.entries(files)) {
const full = path.join(dir, 'src', rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
return dir;
}
it('extracts a struct literal `Foo { .. }` as an instantiation across modules', async () => {
const dir = rustProject({
'lib.rs': 'pub mod types;\npub mod consumer;\n',
'types.rs': 'pub struct Widget { pub n: i32 }\n',
'consumer.rs': 'use crate::types::Widget;\npub fn build() -> Widget { Widget { n: 1 } }\n',
});
try {
const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } });
await cg.indexAll();
cg.resolveReferences();
expect(cg.getFileDependents('src/types.rs')).toContain('src/consumer.rs');
cg.destroy();
} finally { cleanupTempDir(dir); }
});
it('extracts trait method declarations and bridges trait dispatch to the impl', async () => {
const dir = rustProject({
'lib.rs': 'pub mod types;\npub mod consumer;\n',
'types.rs': 'pub trait Render { fn render(&self) -> i32; }\n',
// Mine implements Render structurally; reached via &dyn Render dispatch.
'consumer.rs': 'use crate::types::Render;\npub struct Mine { pub x: i32 }\nimpl Render for Mine { fn render(&self) -> i32 { self.x } }\n',
});
try {
const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } });
await cg.indexAll();
cg.resolveReferences();
// implements edge (Mine -> Render) makes types.rs a dependent of consumer.rs's struct.
expect(cg.getFileDependents('src/types.rs')).toContain('src/consumer.rs');
cg.destroy();
} finally { cleanupTempDir(dir); }
});
it('links `pub use` re-export hubs to the modules they re-export', async () => {
const dir = rustProject({
'lib.rs': 'pub mod api;\n',
'api/mod.rs': 'mod widget;\npub use self::widget::Widget;\n',
'api/widget.rs': 'pub struct Widget { pub n: i32 }\n',
});
try {
const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } });
await cg.indexAll();
cg.resolveReferences();
// The re-export hub depends on the module it re-exports from.
expect(cg.getFileDependents('src/api/widget.rs')).toContain('src/api/mod.rs');
cg.destroy();
} finally { cleanupTempDir(dir); }
});
it('resolves a qualified path to the correct module when the leaf name collides', async () => {
const dir = rustProject({
'lib.rs': 'pub mod fast;\npub mod slow;\npub mod hub;\n',
'fast.rs': 'pub fn read() -> i32 { 1 }\n',
'slow.rs': 'pub fn read() -> i32 { 2 }\n',
// `read` exists in BOTH fast.rs and slow.rs β module-path resolution must
// send this re-export to fast.rs specifically, not name-match either.
'hub.rs': 'pub use crate::fast::read;\n',
});
try {
const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } });
await cg.indexAll();
cg.resolveReferences();
expect(cg.getFileDependents('src/fast.rs')).toContain('src/hub.rs');
expect(cg.getFileDependents('src/slow.rs')).not.toContain('src/hub.rs');
cg.destroy();
} finally { cleanupTempDir(dir); }
});
});
describe('Java annotations (blast-radius recall)', () => {
it('indexes @interface definitions and links @Annotation usages to them', async () => {
const dir = createTempDir();
try {
fs.mkdirSync(path.join(dir, 'p'), { recursive: true });
// The annotation DEFINITION must be a node, and the @MyAnno usages (which
// live inside a `modifiers` node on the class/field/method) must extract.
fs.writeFileSync(path.join(dir, 'p', 'MyAnno.java'), `package p;\npublic @interface MyAnno { String value() default ""; }\n`);
fs.writeFileSync(
path.join(dir, 'p', 'User.java'),
`package p;\n@MyAnno("c")\npublic class User {\n @MyAnno("f") int field;\n @MyAnno("m") void go() {}\n}\n`
);
const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.java'], exclude: [] } });
await cg.indexAll();
cg.resolveReferences();
expect(cg.getFileDependents('p/MyAnno.java')).toContain('p/User.java');
cg.destroy();
} finally { cleanupTempDir(dir); }
});
});
describe('Swift property wrappers / attributes (blast-radius recall)', () => {
it('links a @propertyWrapper usage to the wrapper type', async () => {
const dir = createTempDir();
try {
fs.mkdirSync(path.join(dir, 'Sources', 'M'), { recursive: true });
fs.writeFileSync(path.join(dir, 'Sources', 'M', 'Wrap.swift'), `@propertyWrapper\npublic struct Argument { public var wrappedValue: T }\n`);
// `@Argument` is a Swift attribute on a stored property β it lives in the
// property's `modifiers` and Swift doesn't extract instance properties as
// their own nodes, so without the fix the wrapper type has no users.
fs.writeFileSync(path.join(dir, 'Sources', 'M', 'Cmd.swift'), `public struct MyCommand {\n @Argument var name: String\n @Argument var count: Int\n}\n`);
const cg = CodeGraph.initSync(dir, { config: { include: ['Sources/**/*.swift'], exclude: [] } });
await cg.indexAll();
cg.resolveReferences();
expect(cg.getFileDependents('Sources/M/Wrap.swift')).toContain('Sources/M/Cmd.swift');
cg.destroy();
} finally { cleanupTempDir(dir); }
});
});
describe('R Extraction', () => {
describe('Language detection', () => {
it('should detect R files (both extension cases)', () => {
expect(detectLanguage('analysis.R')).toBe('r');
expect(detectLanguage('scripts/clean.r')).toBe('r');
});
it('should report R as supported', () => {
expect(isLanguageSupported('r')).toBe(true);
expect(getSupportedLanguages()).toContain('r');
});
});
describe('Function extraction', () => {
it('extracts every assignment form, lambdas, and nested functions', () => {
const code = `
clean_data <- function(df, threshold = 0.5) {
helper <- function(d) scale(d)
helper(df)
}
normalize = function(v) (v - mean(v)) / sd(v)
double_it <- \\(x) x * 2
`;
const result = extractFromSource('analysis.R', code);
const funcs = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
expect(funcs).toContain('clean_data');
expect(funcs).toContain('normalize');
expect(funcs).toContain('double_it');
expect(funcs).toContain('helper'); // nested, inside clean_data's scope
const cleanData = result.nodes.find((n) => n.name === 'clean_data');
expect(cleanData?.language).toBe('r');
expect(cleanData?.signature).toBe('(df, threshold = 0.5)');
});
it('attributes body calls to the enclosing function', () => {
const code = `
prep <- function(d) scale(d)
fit_model <- function(data) {
lm(y ~ x, data = prep(data))
}
`;
const result = extractFromSource('models.R', code);
const prepCall = result.unresolvedReferences.find(
(r) => r.referenceName === 'prep' && r.referenceKind === 'calls'
);
expect(prepCall).toBeDefined();
const fitModel = result.nodes.find((n) => n.name === 'fit_model');
expect(prepCall?.fromNodeId).toBe(fitModel?.id);
});
});
describe('Imports', () => {
it('extracts library/require/source as imports, not calls', () => {
const code = `
library(dplyr)
require(stats)
requireNamespace("jsonlite")
source("helpers.R")
`;
const result = extractFromSource('main.R', code);
const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
expect(imports).toContain('dplyr');
expect(imports).toContain('stats');
expect(imports).toContain('jsonlite');
expect(imports).toContain('helpers.R');
// Claimed by the hook β no call references to the import machinery.
const libCalls = result.unresolvedReferences.filter(
(r) => r.referenceKind === 'calls' && (r.referenceName === 'library' || r.referenceName === 'source')
);
expect(libCalls).toHaveLength(0);
});
});
describe('Variables and constants', () => {
it('extracts top-level assignments; ALL_CAPS as constants; right-assign too', () => {
const code = `
ALPHA <- 0.05
max_iter = 100
compute_stats(df) -> stats_result
inner <- function() {
local_var <- 1
}
`;
const result = extractFromSource('config.R', code);
const constant = result.nodes.find((n) => n.name === 'ALPHA');
expect(constant?.kind).toBe('constant');
const variable = result.nodes.find((n) => n.name === 'max_iter');
expect(variable?.kind).toBe('variable');
const rightAssigned = result.nodes.find((n) => n.name === 'stats_result');
expect(rightAssigned?.kind).toBe('variable');
// Locals inside functions are deliberately NOT extracted.
expect(result.nodes.find((n) => n.name === 'local_var')).toBeUndefined();
});
});
describe('Classes', () => {
it('extracts S4/R5/R6 class calls as classes with their list methods', () => {
const code = `
setClass("Patient", representation(id = "character"))
Account <- setRefClass("Account",
fields = list(balance = "numeric"),
methods = list(deposit = function(x) { balance <<- balance + x })
)
Stack <- R6Class("Stack",
public = list(push = function(v) invisible(v))
)
setGeneric("describe", function(obj) standardGeneric("describe"))
setMethod("describe", "Patient", function(obj) paste(obj@id))
`;
const result = extractFromSource('classes.R', code);
const classes = result.nodes.filter((n) => n.kind === 'class').map((n) => n.name);
expect(classes).toContain('Patient');
expect(classes).toContain('Account');
expect(classes).toContain('Stack');
const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
expect(methods).toContain('deposit');
expect(methods).toContain('push');
// setGeneric/setMethod produce functions named by their string argument.
const describes = result.nodes.filter((n) => n.name === 'describe' && n.kind === 'function');
expect(describes.length).toBeGreaterThanOrEqual(2);
// The class-assignment idiom must not ALSO produce a variable node.
expect(result.nodes.find((n) => n.name === 'Account' && n.kind === 'variable')).toBeUndefined();
});
it('extracts ggproto classes with direct-arg methods and the parent as extends', () => {
// ggplot2's OO system β every Geom/Stat/Scale in the ecosystem uses it.
const code = `
GeomPoint <- ggproto("GeomPoint", Geom,
required_aes = c("x", "y"),
draw_panel = function(data, panel_params, coord) {
coords <- coord$transform(data, panel_params)
grid::pointsGrob(coords$x, coords$y)
},
draw_key = draw_key_point
)
`;
const result = extractFromSource('geom-point.R', code);
const cls = result.nodes.find((n) => n.name === 'GeomPoint' && n.kind === 'class');
expect(cls).toBeDefined();
const method = result.nodes.find((n) => n.name === 'draw_panel' && n.kind === 'method');
expect(method).toBeDefined();
const ext = result.unresolvedReferences.find(
(r) => r.referenceKind === 'extends' && r.referenceName === 'Geom'
);
expect(ext?.fromNodeId).toBe(cls?.id);
// No twin variable for the assignment.
expect(result.nodes.find((n) => n.name === 'GeomPoint' && n.kind === 'variable')).toBeUndefined();
});
});
});
// =============================================================================
// CFML (ColdFusion Markup Language β .cfc/.cfm tag-based and bare-script, .cfs)
// =============================================================================
describe('CFML Extraction', () => {
describe('Language detection', () => {
it('should detect .cfc/.cfm as cfml and .cfs as cfscript', () => {
expect(detectLanguage('Service.cfc')).toBe('cfml');
expect(detectLanguage('index.cfm')).toBe('cfml');
expect(detectLanguage('Helper.cfs')).toBe('cfscript');
});
it('should report cfml and cfscript as supported', () => {
expect(isLanguageSupported('cfml')).toBe(true);
expect(isLanguageSupported('cfscript')).toBe(true);
expect(getSupportedLanguages()).toContain('cfml');
expect(getSupportedLanguages()).toContain('cfscript');
});
});
describe('Bare-script .cfc (component { ... })', () => {
const code = `
component extends="BaseService" implements="IService" {
property name="name" type="string";
function init(required string name) {
variables.name = arguments.name;
return this;
}
public string function getName() {
return variables.name;
}
private void function logSomething(required string msg) {
writeLog(text=msg);
}
}
`;
it('should name the component from the file name (the grammar has no name field)', () => {
const result = extractFromSource('SampleService.cfc', code);
const cls = result.nodes.find((n) => n.kind === 'class');
expect(cls).toBeDefined();
expect(cls?.name).toBe('SampleService');
expect(cls?.language).toBe('cfml');
});
it('should extract methods with visibility and contains edges to the class', () => {
const result = extractFromSource('SampleService.cfc', code);
const cls = result.nodes.find((n) => n.kind === 'class');
const methods = result.nodes.filter((n) => n.kind === 'method');
expect(methods.map((m) => m.name)).toEqual(
expect.arrayContaining(['init', 'getName', 'logSomething'])
);
const logSomething = methods.find((m) => m.name === 'logSomething');
expect(logSomething?.visibility).toBe('private');
const containsLog = result.edges.find(
(e) => e.source === cls?.id && e.target === logSomething?.id && e.kind === 'contains'
);
expect(containsLog).toBeDefined();
});
it('should extract extends/implements as unresolved references from the class', () => {
const result = extractFromSource('SampleService.cfc', code);
const cls = result.nodes.find((n) => n.kind === 'class');
const extendsRef = result.unresolvedReferences.find((r) => r.referenceKind === 'extends');
expect(extendsRef?.referenceName).toBe('BaseService');
expect(extendsRef?.fromNodeId).toBe(cls?.id);
const implRef = result.unresolvedReferences.find((r) => r.referenceKind === 'implements');
expect(implRef?.referenceName).toBe('IService');
expect(implRef?.fromNodeId).toBe(cls?.id);
});
});
describe('Standalone .cfs (pure CFScript)', () => {
it('should also name an anonymous component from the file name', () => {
const code = `
component {
function ping() {
return "pong";
}
}
`;
const result = extractFromSource('Sample.cfs', code);
const cls = result.nodes.find((n) => n.kind === 'class');
expect(cls).toBeDefined();
expect(cls?.name).toBe('Sample');
expect(cls?.language).toBe('cfscript');
});
it('should extract top-level imports with no enclosing component', () => {
const code = `
import com.foo.Bar;
import foo.cfm;
`;
const result = extractFromSource('Includes.cfs', code);
const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
expect(imports).toContain('com.foo.Bar');
expect(imports).toContain('foo.cfm');
});
});
describe('Tag-based .cfc (/)', () => {
const code = `
\t
\t\t
\t
\t
\t\t
\t\t\tvar x = helper();
\t\t\tanotherCall(x);
\t\t
\t
`;
it('should name the component from the file name when the tag has no name attribute', () => {
const result = extractFromSource('TagStyle.cfc', code);
const cls = result.nodes.find((n) => n.kind === 'class');
expect(cls?.name).toBe('TagStyle');
expect(cls?.language).toBe('cfml');
});
it('should prefer an explicit name attribute on the cfcomponent tag', () => {
const named = `\n\n`;
const result = extractFromSource('File.cfc', named);
expect(result.nodes.find((n) => n.kind === 'class')?.name).toBe('ExplicitName');
});
it('should extract cffunction tags as methods with access-derived visibility', () => {
const result = extractFromSource('TagStyle.cfc', code);
const methods = result.nodes.filter((n) => n.kind === 'method');
expect(methods.map((m) => m.name)).toEqual(expect.arrayContaining(['getName', 'doWork']));
const getName = methods.find((m) => m.name === 'getName');
expect(getName?.visibility).toBe('public');
expect(getName?.returnType).toBe('string');
const doWork = methods.find((m) => m.name === 'doWork');
expect(doWork?.visibility).toBe('private');
});
it('should not double-extract symbols from the component body (implicit-end-tag walk)', () => {
const result = extractFromSource('TagStyle.cfc', code);
const methods = result.nodes.filter((n) => n.kind === 'method' && n.name === 'getName');
expect(methods).toHaveLength(1);
const doWorkMethods = result.nodes.filter((n) => n.kind === 'method' && n.name === 'doWork');
expect(doWorkMethods).toHaveLength(1);
});
it('should delegate tag bodies to the cfscript grammar and attribute calls to the enclosing method', () => {
const result = extractFromSource('TagStyle.cfc', code);
const doWork = result.nodes.find((n) => n.kind === 'method' && n.name === 'doWork');
const helperCall = result.unresolvedReferences.find(
(r) => r.referenceKind === 'calls' && r.referenceName === 'helper'
);
expect(helperCall?.fromNodeId).toBe(doWork?.id);
});
it('should produce exactly one correctly-ranged file node, not a leaked snippet-scoped one', () => {
const result = extractFromSource('TagStyle.cfc', code);
const fileNodes = result.nodes.filter((n) => n.kind === 'file');
expect(fileNodes).toHaveLength(1);
expect(fileNodes[0].startLine).toBe(1);
const cls = result.nodes.find((n) => n.kind === 'class');
const containsClass = result.edges.find(
(e) => e.source === fileNodes[0].id && e.target === cls?.id && e.kind === 'contains'
);
expect(containsClass).toBeDefined();
});
});
describe('Top-level cffunction with no enclosing cfcomponent (.cfm template)', () => {
it('should extract as a top-level function contained by the file', () => {
const code = `
\t
`;
const result = extractFromSource('helper.cfm', code);
const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'helper');
expect(fn).toBeDefined();
const fileNode = result.nodes.find((n) => n.kind === 'file');
const containsFn = result.edges.find(
(e) => e.source === fileNode?.id && e.target === fn?.id && e.kind === 'contains'
);
expect(containsFn).toBeDefined();
});
});
describe(' nested inside control-flow tags (//)', () => {
it('should delegate a body nested inside within a ', () => {
const code = `
helper();
`;
const result = extractFromSource('Nested.cfc', code);
const doStuff = result.nodes.find((n) => n.kind === 'method' && n.name === 'doStuff');
expect(doStuff).toBeDefined();
const helperCall = result.unresolvedReferences.find(
(r) => r.referenceKind === 'calls' && r.referenceName === 'helper'
);
expect(helperCall?.fromNodeId).toBe(doStuff?.id);
});
it('should delegate a body nested inside at top-level component scope', () => {
const code = `
topLevelHelper();
`;
const result = extractFromSource('Nested2.cfc', code);
const cls = result.nodes.find((n) => n.kind === 'class');
expect(cls).toBeDefined();
const helperCall = result.unresolvedReferences.find(
(r) => r.referenceKind === 'calls' && r.referenceName === 'topLevelHelper'
);
expect(helperCall?.fromNodeId).toBe(cls?.id);
});
});
describe(' SQL bodies (cfquery grammar)', () => {
it('should extract a call expression embedded in a #hash# inside the SQL body', () => {
const code = `
SELECT id, name FROM users WHERE owner = #getCurrentUser().getId()#
`;
const result = extractFromSource('Query.cfc', code);
const getUsers = result.nodes.find((n) => n.kind === 'method' && n.name === 'getUsers');
expect(getUsers).toBeDefined();
const getCurrentUserCall = result.unresolvedReferences.find(
(r) => r.referenceKind === 'calls' && r.referenceName === 'getCurrentUser'
);
expect(getCurrentUserCall?.fromNodeId).toBe(getUsers?.id);
const getIdCall = result.unresolvedReferences.find(
(r) => r.referenceKind === 'calls' && r.referenceName === 'getId'
);
expect(getIdCall?.fromNodeId).toBe(getUsers?.id);
});
});
describe('UTF-8 BOM handling (common in CFML saved by Windows editors)', () => {
it('should route a BOM-prefixed tag-based .cfc to the tag grammar, not the script grammar', () => {
const code = `\uFEFF\n\n\n\n\n`;
const result = extractFromSource('ModuleConfig.cfc', code);
const cls = result.nodes.find((n) => n.kind === 'class');
expect(cls?.name).toBe('ModuleConfig');
const configure = result.nodes.find((n) => n.kind === 'method' && n.name === 'configure');
expect(configure).toBeDefined();
});
it('should still treat a BOM-prefixed bare-script .cfc as script', () => {
const code = `\uFEFFcomponent {\n function ping() { return "pong"; }\n}\n`;
const result = extractFromSource('Ping.cfc', code);
expect(result.nodes.find((n) => n.kind === 'class')?.name).toBe('Ping');
expect(result.nodes.find((n) => n.kind === 'method')?.name).toBe('ping');
});
});
describe('Unquoted tag attribute values (legal in older CFML)', () => {
it('should extract functions and inheritance from unquoted attributes', () => {
const code = `\n\n\n\n\n`;
const result = extractFromSource('Unquoted.cfc', code);
const doThing = result.nodes.find((n) => n.kind === 'method' && n.name === 'doThing');
expect(doThing).toBeDefined();
expect(doThing?.visibility).toBe('private');
const extendsRef = result.unresolvedReferences.find((r) => r.referenceKind === 'extends');
expect(extendsRef?.referenceName).toBe('Base');
});
});
describe('Functions in a component-level block', () => {
it('should classify them as methods of the component (ColdBox ModuleConfig shape)', () => {
const code = `\n\nfunction configure() {\n return settings();\n}\nfunction onLoad() {\n return 1;\n}\n\n\n`;
const result = extractFromSource('ModuleConfig.cfc', code);
const cls = result.nodes.find((n) => n.kind === 'class');
const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
expect(methods).toEqual(expect.arrayContaining(['configure', 'onLoad']));
expect(result.nodes.filter((n) => n.kind === 'function')).toHaveLength(0);
const configure = result.nodes.find((n) => n.kind === 'method' && n.name === 'configure');
const containsEdge = result.edges.find(
(e) => e.source === cls?.id && e.target === configure?.id && e.kind === 'contains'
);
expect(containsEdge).toBeDefined();
});
it('should keep kind function for a inside a cffunction body', () => {
const code = `\n\n\nfunction innerHelper() { return 1; }\n\n\n\n`;
const result = extractFromSource('Outer.cfc', code);
expect(result.nodes.find((n) => n.name === 'outer')?.kind).toBe('method');
expect(result.nodes.find((n) => n.name === 'innerHelper')?.kind).toBe('function');
});
});
describe('Calls in tag expressions outside / (#2091)', () => {
const callsFrom = (result: ReturnType, fromId: string | undefined) =>
result.unresolvedReferences
.filter((r) => r.fromNodeId === fromId && (r.referenceKind === 'calls' || r.referenceKind === 'instantiates'))
.map((r) => `${r.referenceKind === 'instantiates' ? 'new ' : ''}${r.referenceName}@${r.line}`)
.sort();
it('should extract calls in , , and , attributed to the enclosing method', () => {
const code = `
\t
\t\t
\t\t
\t\t
\t\t
\t\t
\t
\t
`;
const result = extractFromSource('Svc.cfc', code);
const a = result.nodes.find((n) => n.kind === 'method' && n.name === 'a');
expect(a).toBeDefined();
expect(callsFrom(result, a?.id)).toEqual(['b@3', 'c@4', 'd@7', 'e@5']);
});
it('should extract #hash# expressions in output, strings and tag attributes β once each, alongside bodies', () => {
const code = `
#fmt(y)# and #variables.mailer.send()#
SELECT #col()# FROM t
`;
const result = extractFromSource('View.cfc', code);
const render = result.nodes.find((n) => n.kind === 'method' && n.name === 'render');
expect(callsFrom(result, render?.id)).toEqual([
'col@7',
'dsn@7',
'fmt@5',
'getItems@6',
'new Widget@8',
'svc.load@3',
'userName@4',
'variables.mailer.send@5',
]);
// `` is a function local β no variable node for it.
expect(result.nodes.find((n) => n.name === 'y')).toBeUndefined();
});
it('should attribute component-scope and template-scope calls to the component and the file', () => {
const component = `\n\n\n\n`;
const cfc = extractFromSource('Pseudo.cfc', component);
const cls = cfc.nodes.find((n) => n.kind === 'class');
expect(callsFrom(cfc, cls?.id)).toEqual(['setup@2']);
const template = `\n#renderList(items)#\n`;
const cfm = extractFromSource('index.cfm', template);
const file = cfm.nodes.find((n) => n.kind === 'file');
expect(callsFrom(cfm, file?.id)).toEqual(['loadItems@1', 'renderList@2']);
});
it("should keep each call's own line and column (several per line, and across lines)", () => {
const code = `
#last()#
`;
const lines = code.split('\n');
const result = extractFromSource('Pos.cfc', code);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
expect(calls.map((r) => r.referenceName).sort()).toEqual(['a', 'b.c', 'count', 'd', 'e', 'last', 'other.fetch', 'sum']);
for (const r of calls) {
expect(lines[r.line - 1]!.slice(r.column).startsWith(r.referenceName)).toBe(true);
}
});
it('should read a attribute as an expression', () => {
const code = `\n\n\n\n\n\n`;
const result = extractFromSource('Queue.cfc', code);
const drain = result.nodes.find((n) => n.kind === 'method' && n.name === 'drain');
expect(callsFrom(result, drain?.id)).toEqual(['hasNext@3']);
});
it('should not read HTML \nalso(not)
\n\n\n`;
const result = extractFromSource('Page.cfc', code);
expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'calls')).toEqual([]);
});
});
describe(' inside a generic container tag (#2091)', () => {
it('should extract a method nested in (the Application.cfc shape)', () => {
const code = `
\t
\t\t
\t\t\t
\t\t
\t
`;
const result = extractFromSource('Application.cfc', code);
const cls = result.nodes.find((n) => n.kind === 'class');
const method = result.nodes.find((n) => n.kind === 'method' && n.name === 'onRequestStart');
expect(method).toBeDefined();
expect(method?.qualifiedName).toBe('Application::onRequestStart');
expect(result.edges.some((e) => e.kind === 'contains' && e.source === cls?.id && e.target === method?.id)).toBe(true);
const call = result.unresolvedReferences.find((r) => r.referenceName === 'loadConfig');
expect(call?.fromNodeId).toBe(method?.id);
});
it('should extract a top-level function nested in in a template', () => {
const code = `\n\n\n`;
const result = extractFromSource('lib.cfm', code);
expect(result.nodes.find((n) => n.name === 'helper')?.kind).toBe('function');
});
});
describe(' (#2091)', () => {
const code = `
\t
\t\t
\t
\t
`;
it('should extract an interface node named from the file, with its functions as methods', () => {
const result = extractFromSource('ISearchable.cfc', code);
const iface = result.nodes.find((n) => n.kind === 'interface');
expect(iface?.name).toBe('ISearchable');
expect(iface?.startLine).toBe(1);
expect(iface?.endLine).toBe(6);
expect(result.nodes.filter((n) => n.kind === 'class')).toHaveLength(0);
const methods = result.nodes.filter((n) => n.kind === 'method');
expect(methods.map((m) => m.qualifiedName).sort()).toEqual(['ISearchable::count', 'ISearchable::search']);
for (const m of methods) {
expect(result.edges.some((e) => e.kind === 'contains' && e.source === iface?.id && e.target === m.id)).toBe(true);
}
expect(methods.find((m) => m.name === 'search')?.returnType).toBe('any');
});
it('should extract each interface it extends', () => {
const result = extractFromSource('ISearchable.cfc', code);
const iface = result.nodes.find((n) => n.kind === 'interface');
const ext = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends');
expect(ext.map((r) => r.referenceName)).toEqual(['IBase', 'IOther']);
expect(ext.every((r) => r.fromNodeId === iface?.id)).toBe(true);
});
});
});
describe('COBOL Extraction', () => {
it('should detect .cbl/.cob/.cpy as cobol (case-insensitive)', () => {
expect(detectLanguage('app/cbl/CBACT01C.cbl')).toBe('cobol');
expect(detectLanguage('app/cbl/CBSTM03A.CBL')).toBe('cobol');
expect(detectLanguage('prog.cob')).toBe('cobol');
expect(detectLanguage('app/cpy/CVACT01Y.cpy')).toBe('cobol');
expect(isSourceFile('CBACT01C.cbl')).toBe(true);
});
const FIXED = (body: string) =>
body
.split('\n')
.map((l) => (l.length > 0 ? ' ' + l : l))
.join('\n');
const PROGRAM = FIXED(`IDENTIFICATION DIVISION.
PROGRAM-ID. TESTPROG.
DATA DIVISION.
WORKING-STORAGE SECTION.
01 WS-TOTALS.
05 WS-COUNT PIC 9(4) VALUE ZERO.
88 WS-DONE VALUE 'Y'.
77 WS-FLAG PIC X.
COPY CVACT01Y.
PROCEDURE DIVISION.
MAIN-SECTION SECTION.
0000-MAIN.
PERFORM 1000-INIT
PERFORM 2000-PROCESS THRU 2000-EXIT
CALL 'CBACT01C' USING WS-TOTALS
CALL WS-FLAG
GO TO 9999-END
.
1000-INIT.
MOVE ZERO TO WS-COUNT.
2000-PROCESS.
EXEC CICS LINK PROGRAM('COCOM01C') COMMAREA(WS-TOTALS)
END-EXEC.
2000-EXIT.
EXIT.
9999-END.
GOBACK.
`);
it('should extract the program as a module node', () => {
const result = extractFromSource('TESTPROG.cbl', PROGRAM);
const moduleNode = result.nodes.find((n) => n.kind === 'module');
expect(moduleNode).toBeDefined();
expect(moduleNode?.name).toBe('TESTPROG');
});
it('should extract sections and paragraphs as functions with reconstructed extents', () => {
const result = extractFromSource('TESTPROG.cbl', PROGRAM);
const fns = result.nodes.filter((n) => n.kind === 'function');
const names = fns.map((f) => f.name);
expect(names).toContain('MAIN-SECTION');
expect(names).toContain('0000-MAIN');
expect(names).toContain('2000-PROCESS');
// A paragraph spans from its header to the next header, not just one line.
const main = fns.find((f) => f.name === '0000-MAIN');
expect(main).toBeDefined();
expect(main!.endLine).toBeGreaterThan(main!.startLine + 3);
// Paragraphs are contained in their section (qualified name includes it).
expect(main!.qualifiedName).toContain('MAIN-SECTION');
});
it('should extract PERFORM, PERFORM THRU, GO TO, and CALL literal as calls references', () => {
const result = extractFromSource('TESTPROG.cbl', PROGRAM);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
const targets = calls.map((c) => c.referenceName);
expect(targets).toContain('1000-INIT');
expect(targets).toContain('2000-PROCESS'); // PERFORM ... THRU start
expect(targets).toContain('2000-EXIT'); // PERFORM ... THRU end
expect(targets).toContain('9999-END'); // GO TO
expect(targets).toContain('CBACT01C'); // CALL 'literal'
expect(targets).toContain('COCOM01C'); // EXEC CICS LINK PROGRAM('...')
// Dynamic CALL through a data name is skipped β announce, don't guess.
expect(targets).not.toContain('WS-FLAG');
});
it('should extract COPY as an import node and imports reference', () => {
const result = extractFromSource('TESTPROG.cbl', PROGRAM);
const importNode = result.nodes.find((n) => n.kind === 'import');
expect(importNode).toBeDefined();
expect(importNode?.name).toBe('CVACT01Y');
const importRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'imports');
expect(importRefs.map((r) => r.referenceName)).toContain('CVACT01Y');
});
it('should extract data items as variables, fields, and 88-level constants', () => {
const result = extractFromSource('TESTPROG.cbl', PROGRAM);
const group = result.nodes.find((n) => n.name === 'WS-TOTALS');
expect(group?.kind).toBe('variable');
const nested = result.nodes.find((n) => n.name === 'WS-COUNT');
expect(nested?.kind).toBe('field');
expect(nested?.qualifiedName).toContain('WS-TOTALS');
const condition = result.nodes.find((n) => n.name === 'WS-DONE');
expect(condition?.kind).toBe('constant');
const standalone = result.nodes.find((n) => n.name === 'WS-FLAG');
expect(standalone?.kind).toBe('variable');
});
it('should extract EXEC SQL INCLUDE as an import', () => {
const code = FIXED(`IDENTIFICATION DIVISION.
PROGRAM-ID. SQLPROG.
DATA DIVISION.
WORKING-STORAGE SECTION.
EXEC SQL INCLUDE SQLCA END-EXEC.
01 WS-X PIC X.
PROCEDURE DIVISION.
P1.
GOBACK.
`);
const result = extractFromSource('SQLPROG.cbl', code);
const importNode = result.nodes.find((n) => n.kind === 'import' && n.name === 'SQLCA');
expect(importNode).toBeDefined();
// The EXEC block must not break the rest of the file.
expect(result.nodes.find((n) => n.name === 'WS-X')).toBeDefined();
});
it('should extract a standalone data copybook (.cpy fragment)', () => {
const code = FIXED(`01 ACCOUNT-RECORD.
05 ACCT-ID PIC 9(11).
05 ACCT-CURR-BAL PIC S9(10)V99.
`);
const result = extractFromSource('CVACT01Y.cpy', code);
const record = result.nodes.find((n) => n.name === 'ACCOUNT-RECORD');
expect(record?.kind).toBe('variable');
const field = result.nodes.find((n) => n.name === 'ACCT-ID');
expect(field?.kind).toBe('field');
});
it('should extract a procedure copybook (.cpy fragment with paragraphs)', () => {
const code = FIXED(`EDIT-DATE.
MOVE 1 TO WS-X
PERFORM VALIDATE-YEAR.
VALIDATE-YEAR.
CONTINUE.
`);
const result = extractFromSource('CSUTLDPY.cpy', code);
const fns = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
expect(fns).toContain('EDIT-DATE');
expect(fns).toContain('VALIDATE-YEAR');
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
expect(calls.map((c) => c.referenceName)).toContain('VALIDATE-YEAR');
});
it('should emit write-site references for MOVE/ADD/COMPUTE targets', () => {
const code = FIXED(`IDENTIFICATION DIVISION.
PROGRAM-ID. WRITEREF.
DATA DIVISION.
WORKING-STORAGE SECTION.
01 WS-TOTAL PIC S9(7)V99.
01 WS-COUNT PIC 9(4).
PROCEDURE DIVISION.
P1.
MOVE ZERO TO WS-TOTAL
ADD 1 TO WS-COUNT
COMPUTE WS-TOTAL = WS-TOTAL + 1
SUBTRACT 1 FROM WS-COUNT
MOVE 1 TO RETURN-CODE.
`);
const result = extractFromSource('WRITEREF.cbl', code);
const writes = result.unresolvedReferences.filter((r) => r.referenceKind === 'references');
const names = writes.map((w) => w.referenceName);
expect(names.filter((n) => n === 'WS-TOTAL').length).toBeGreaterThanOrEqual(2); // MOVE + COMPUTE
expect(names).toContain('WS-COUNT'); // ADD and SUBTRACT targets
// Special registers carry no declaration β never referenced.
expect(names).not.toContain('RETURN-CODE');
});
it('should emit cics-transid references for RETURN TRANSID, literal and via same-file VALUE', () => {
const code = FIXED(`IDENTIFICATION DIVISION.
PROGRAM-ID. TXPROG.
DATA DIVISION.
WORKING-STORAGE SECTION.
01 WS-TRANID PIC X(04) VALUE 'CB00'.
PROCEDURE DIVISION.
P1.
EXEC CICS RETURN TRANSID('CC00') COMMAREA(WS-X) END-EXEC
.
P2.
EXEC CICS RETURN TRANSID(WS-TRANID) END-EXEC
.
P3.
EXEC CICS XCTL PROGRAM(WS-UNKNOWN-VAR) END-EXEC
.
`);
const result = extractFromSource('TXPROG.cbl', code);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
const names = calls.map((c) => c.referenceName);
expect(names).toContain('cics-transid:CC00'); // literal
expect(names).toContain('cics-transid:CB00'); // dereferenced through WS-TRANID
// Un-derefable program variable: dynamic dispatch, no guessed edge.
expect(names.filter((n) => n.startsWith('cics-transid:')).length).toBe(2);
});
it('should shift free-format source so it still extracts (preParse)', () => {
const code = `IDENTIFICATION DIVISION.
PROGRAM-ID. FREEPROG.
PROCEDURE DIVISION.
DO-WORK.
DISPLAY 'HI'.
`;
const result = extractFromSource('freeprog.cbl', code);
expect(result.nodes.find((n) => n.kind === 'module')?.name).toBe('FREEPROG');
expect(result.nodes.find((n) => n.kind === 'function')?.name).toBe('DO-WORK');
});
});
// =============================================================================
// VB.NET (.vb) β vendored patched govindbanura/tree-sitter-vbnet grammar
// =============================================================================
describe('VB.NET Extraction', () => {
it('should detect .vb as vbnet', () => {
expect(detectLanguage('Service.vb')).toBe('vbnet');
expect(detectLanguage('app/Forms/MainForm.vb')).toBe('vbnet');
expect(isSourceFile('Service.vb')).toBe(true);
});
const SAMPLE = `Imports System
Imports System.Collections.Generic
Namespace Acme.Billing
Public Interface IRepository
Function GetById(ByVal id As Integer) As Invoice
End Interface
Public Enum InvoiceState
Draft = 0
Sent
Paid
End Enum
Public Structure Money
Public Amount As Decimal
End Structure
Public MustInherit Class EntityBase
Public Property Id As Integer
End Class
Public Class Invoice
Inherits EntityBase
Implements IRepository
Private ReadOnly _lines As New List(Of String)
Public Const MaxLines As Integer = 100
Public Event Paid(ByVal amount As Decimal)
Public Property State As InvoiceState
Public Sub New(ByVal id As Integer)
Me.Id = id
End Sub
Public Function GetById(ByVal id As Integer) As Invoice Implements IRepository.GetById
Return New Invoice(id)
End Function
Public Sub AddLine(ByVal description As String)
_lines.Add(description)
Validate(description)
End Sub
Private Sub Validate(ByVal text As String)
If text.Length > MaxLines Then Throw New ArgumentException("too long")
End Sub
End Class
' lowercase keywords: VB is case-insensitive
public module Helpers
public function Twice(byval n as integer) as integer
return n * 2
end function
Public Sub Run()
Dim inv = New Invoice(1)
inv.AddLine("widget")
Dim d As New Dictionary(Of String, Integer)
Helpers.Twice(21)
End Sub
end module
End Namespace
`;
it('should extract classes, modules, interfaces, structures, and enums', () => {
const result = extractFromSource('Invoice.vb', SAMPLE);
const kinds = (kind: string) => result.nodes.filter((n) => n.kind === kind).map((n) => n.name);
expect(kinds('class')).toEqual(expect.arrayContaining(['EntityBase', 'Invoice', 'Helpers']));
expect(kinds('interface')).toContain('IRepository');
expect(kinds('struct')).toContain('Money');
expect(kinds('enum')).toContain('InvoiceState');
expect(kinds('enum_member')).toEqual(expect.arrayContaining(['Draft', 'Sent', 'Paid']));
});
it('should extract methods, constructors, properties, fields, and events (case-insensitive keywords)', () => {
const result = extractFromSource('Invoice.vb', SAMPLE);
const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
expect(methods).toEqual(expect.arrayContaining(['GetById', 'AddLine', 'Validate', 'Twice', 'Run']));
const props = result.nodes.filter((n) => n.kind === 'property').map((n) => n.name);
expect(props).toEqual(expect.arrayContaining(['Id', 'State']));
const fields = result.nodes.filter((n) => n.kind === 'field' || n.kind === 'constant').map((n) => n.name);
expect(fields).toEqual(expect.arrayContaining(['_lines', 'MaxLines']));
// Event declarations index as findable members
expect(fields).toContain('Paid');
});
it('should qualify types with their namespace', () => {
const result = extractFromSource('Invoice.vb', SAMPLE);
const invoice = result.nodes.find((n) => n.kind === 'class' && n.name === 'Invoice');
expect(invoice?.qualifiedName).toContain('Acme.Billing');
});
it('should emit Inherits as extends and Implements as implements references', () => {
const result = extractFromSource('Invoice.vb', SAMPLE);
const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends');
expect(extendsRefs.map((r) => r.referenceName)).toContain('EntityBase');
const implementsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'implements');
expect(implementsRefs.map((r) => r.referenceName)).toContain('IRepository');
});
it('should extract calls through both invocation and index-shaped parens', () => {
const result = extractFromSource('Invoice.vb', SAMPLE);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
// `_lines.Add(description)` parses as array_access (non-empty parens) β still a call site
expect(calls).toContain('_lines.Add');
// bare call with args
expect(calls).toContain('Validate');
// qualified module call
expect(calls).toContain('Helpers.Twice');
});
it('should emit instantiates for New, with VB generic syntax stripped', () => {
const result = extractFromSource('Invoice.vb', SAMPLE);
const insts = result.unresolvedReferences.filter((r) => r.referenceKind === 'instantiates').map((r) => r.referenceName);
expect(insts).toContain('Invoice');
// `As New Dictionary(Of String, Integer)` β bare type name, not `Dictionary(Of ...)`
expect(insts.some((n) => n.includes('(') || /\bOf\b/.test(n))).toBe(false);
});
it('should extract Imports as import nodes', () => {
const result = extractFromSource('Invoice.vb', SAMPLE);
const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
expect(imports).toEqual(expect.arrayContaining(['System', 'System.Collections.Generic']));
});
it('should parse a file without a trailing newline (preParse guard)', () => {
const code = 'Class Tail\n Sub Go()\n Log("x")\n End Sub\nEnd Class';
const result = extractFromSource('Tail.vb', code);
expect(result.nodes.find((n) => n.kind === 'class')?.name).toBe('Tail');
expect(result.nodes.find((n) => n.kind === 'method')?.name).toBe('Go');
});
});
describe('VB.NET Extraction β scanner-backed constructs', () => {
it('should parse XML literals as opaque literals without breaking siblings', () => {
const code = `Class Muxer
Function WriteTags() As Object
Dim xml =
<%= From tag In Tags Select <%= tag.Name %> %>
Return xml
End Function
Sub After()
Log("still extracted")
End Sub
End Class
`;
const result = extractFromSource('Muxer.vb', code);
const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
expect(methods).toEqual(expect.arrayContaining(['WriteTags', 'After']));
});
it('should parse multi-line LINQ query clauses', () => {
const code = `Class T
Function Big() As Integer
Dim big = From l In _lines
Where l.Length > 3
Select l.Length
Return big.Sum()
End Function
End Class
`;
const result = extractFromSource('Linq.vb', code);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
expect(calls).toContain('big.Sum');
expect(result.nodes.find((n) => n.kind === 'method')?.name).toBe('Big');
});
it('should extract MustOverride members without derailing following members', () => {
const code = `MustInherit Class VideoEncoder
MustOverride ReadOnly Property OutputExt As String
Public MustOverride Sub ShowConfigDialog(Optional param As Object = Nothing)
MustOverride Function GetError() As String
Sub New()
CanEdit = True
End Sub
End Class
`;
const result = extractFromSource('VideoEncoder.vb', code);
const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
expect(methods).toEqual(expect.arrayContaining(['ShowConfigDialog', 'GetError', 'New']));
const props = result.nodes.filter((n) => n.kind === 'property').map((n) => n.name);
expect(props).toContain('OutputExt');
});
it('should parse nullable declarator shorthand (Dim x? = expr)', () => {
const code = `Class T
Sub M(folderInfo As Object)
Dim SteamFolderData? = Parser.GetSteamNameAndID(folderInfo)
Use(SteamFolderData)
End Sub
End Class
`;
const result = extractFromSource('Factory.vb', code);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
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');
// Arity is part of an Erlang function's identity β qualifiedName carries it (#1610).
expect(start?.qualifiedName).toBe('my_server::start/0');
});
it('should give same-name different-arity functions separate arity-qualified nodes (#1610)', () => {
const code = `-module(gap).
-export([f/1, f/2]).
f(X) -> X + 1.
f(X, Y) -> X + Y.
`;
const result = extractFromSource('src/gap.erl', code);
const fns = result.nodes.filter((n) => n.kind === 'function' && n.name === 'f');
expect(fns).toHaveLength(2);
expect(fns.map((n) => n.qualifiedName).sort()).toEqual(['gap::f/1', 'gap::f/2']);
const f1 = fns.find((n) => n.qualifiedName === 'gap::f/1')!;
const f2 = fns.find((n) => n.qualifiedName === 'gap::f/2')!;
expect([f1.startLine, f1.endLine]).toEqual([4, 4]);
expect([f2.startLine, f2.endLine]).toEqual([5, 5]);
expect(f1.signature).toBe('f(X)');
expect(f2.signature).toBe('f(X, Y)');
});
it('should split interleaved same-name defs by arity with distinct qualified names', () => {
const code = `-module(inter).
f(X) -> X + 1;
f(Y) -> Y.
g() -> ok.
f(X, Y) -> X + Y.
`;
const result = extractFromSource('src/inter.erl', code);
const fs = result.nodes.filter((n) => n.kind === 'function' && n.name === 'f');
expect(fs).toHaveLength(2);
expect(fs.map((n) => n.qualifiedName).sort()).toEqual(['inter::f/1', 'inter::f/2']);
// Clauses of the same arity still merge into one span.
const f1 = fs.find((n) => n.qualifiedName === 'inter::f/1')!;
expect([f1.startLine, f1.endLine]).toEqual([3, 4]);
});
it('should flag exported per arity (#1610)', () => {
const code = `-module(m).
-export([f/1]).
f(X) -> X.
f(X, Y) -> {X, Y}.
`;
const result = extractFromSource('src/m.erl', code);
expect(result.nodes.find((n) => n.qualifiedName === 'm::f/1')?.isExported).toBe(true);
expect(result.nodes.find((n) => n.qualifiedName === 'm::f/2')?.isExported).toBe(false);
});
it('should attach a -spec sitting between two arities to the arity it names (#1610)', () => {
const code = `-module(deleg).
-export([header/2, header/3]).
header(Name, Req) ->
header(Name, Req, undefined).
-spec header(binary(), map(), any()) -> any().
header(Name, Headers, Default) ->
maps:get(Name, Headers, Default).
`;
const result = extractFromSource('src/deleg.erl', code);
const h2 = result.nodes.find((n) => n.qualifiedName === 'deleg::header/2')!;
const h3 = result.nodes.find((n) => n.qualifiedName === 'deleg::header/3')!;
expect(h2.signature).toBe('header(Name, Req)');
expect(h3.signature).toBe('-spec header(binary(), map(), any()) -> any().');
expect([h2.startLine, h2.endLine]).toEqual([4, 5]);
expect(h3.startLine).toBe(8);
// The delegation call carries the callee's arity β no more self-loop.
const calls = result.unresolvedReferences
.filter((r) => r.referenceKind === 'calls')
.map((r) => r.referenceName);
expect(calls).toContain('header/3');
});
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/1');
// `mod:fn(...)` is emitted as `mod::fn/arity` β the same shape the
// module namespace + arity suffix gives every function's qualifiedName,
// so it resolves via the qualified-name matcher (#1610).
expect(calls).toContain('other_mod::process/1');
});
it('should carry written arity on fun references and static MFA lists (#1610)', () => {
const code = `-module(m).
-export([go/0]).
go() ->
lists:map(fun bump/1, [1]),
Prod = fun other_mod:produce/2,
proc_lib:spawn_link(?MODULE, work, [a, b]),
Prod.
bump(X) -> X + 1.
work(_A, _B) -> ok.
`;
const result = extractFromSource('src/m.erl', code);
const refs = result.unresolvedReferences;
expect(refs.some((r) => r.referenceKind === 'references' && r.referenceName === 'bump/1')).toBe(true);
expect(refs.some((r) => r.referenceKind === 'references' && r.referenceName === 'other_mod::produce/2')).toBe(true);
expect(refs.some((r) => r.referenceKind === 'calls' && r.referenceName === 'work/2')).toBe(true);
});
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.some((c) => c.startsWith('handle'))).toBe(false);
expect(calls.some((c) => c.startsWith('Mod::'))).toBe(false);
expect(calls.some((c) => c === 'F' || c.startsWith('F/'))).toBe(false);
});
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, at
// OTP's fixed handler arities (#1610).
expect(calls.filter((c) => c === 'kv_store::handle_call/3')).toHaveLength(2);
expect(calls).toContain('kv_store::handle_cast/2');
});
it('should connect gen_server calls to a registered-name module, directly or via an atom macro', () => {
const code = `-module(kv_client).
-export([fetch/1, evict/1]).
-define(STORE, kv_store).
fetch(Key) ->
gen_server:call(kv_store, {get, Key}).
evict(Key) ->
gen_server:cast(?STORE, {evict, Key}).
`;
const result = extractFromSource('src/kv_client.erl', code);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
// OTP's {local, ?MODULE} convention names a server after its module β
// a cross-module registered name targets that module's handlers. A name
// matching no module simply never resolves downstream.
expect(calls).toContain('kv_store::handle_call/3');
expect(calls).toContain('kv_store::handle_cast/2');
});
it('should not connect gen_server calls with dynamic targets', () => {
const code = `-module(m).
-export([go/2]).
go(Pid, Msg) ->
gen_server:call(Pid, Msg),
gen_server:cast({global, some_name}, Msg),
gen_server:call({some_name, node()}, 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/2'); // ?MODULE β bare-with-arity, same-file resolution
expect(calls).toContain('monitor_loop/1');
expect(calls).toContain('other_mod::handle/1');
expect(calls).toContain('other_mod::tick/0');
});
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/0');
expect(calls.filter((c) => !['spawn/3', 'spawn/1', 'apply/3', 'helper/0'].includes(c))).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/0');
});
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/1');
expect(refs).toContain('m::notify/1');
});
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/1');
expect(calls).toContain('second/1');
});
});
describe('escript and app resource files', () => {
it('should extract functions and calls from an escript behind a shebang', () => {
const code = `#!/usr/bin/env escript
%%! -smp enable
main([Path]) ->
Result = analyze(Path),
io:format("~p~n", [Result]).
analyze(Path) ->
{ok, Bin} = file:read_file(Path),
byte_size(Bin).
`;
const result = extractFromSource('bin/tool.escript', code);
const fns = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
expect(fns).toContain('main');
expect(fns).toContain('analyze');
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
expect(calls).toContain('analyze/1');
expect(calls).toContain('io::format/2');
});
it('should link an app resource file to its callback module and dependency apps', () => {
const code = `{application, sample, [
{description, "Sample application"},
{vsn, "1.0.0"},
{registered, [sample_server]},
{mod, {sample_app, []}},
{applications, [kernel, stdlib, sample_core]},
{included_applications, [sample_extra]},
{env, [{limit, 100}]},
{modules, []}
]}.
`;
const result = extractFromSource('src/sample.app.src', code);
const refs = result.unresolvedReferences.map((r) => `${r.referenceKind}:${r.referenceName}`);
// The application-callback module is the app's entry point.
expect(refs).toContain('references:sample_app');
// Dependencies resolve to umbrella siblings; kernel/stdlib just drop.
expect(refs).toContain('imports:kernel');
expect(refs).toContain('imports:sample_core');
expect(refs).toContain('imports:sample_extra');
// Registered names, env values, and the like carry no graph structure.
expect(refs.filter((r) => r.endsWith(':sample_server'))).toHaveLength(0);
expect(refs.filter((r) => r.endsWith(':limit'))).toHaveLength(0);
});
});
describe('Macro linkage', () => {
it('should attribute macro-body calls to the macro and link function-like uses into the chain', () => {
const code = `-module(m).
-export([do_thing/1]).
-define(LOG_AUDIT(Event), audit_logger:log(Event, ?MODULE)).
do_thing(X) ->
?LOG_AUDIT({thing, X}),
ok.
`;
const result = extractFromSource('src/m.erl', code);
const macro = result.nodes.find((n) => n.kind === 'constant' && n.name === 'LOG_AUDIT');
const doThing = result.nodes.find((n) => n.kind === 'function' && n.name === 'do_thing');
const refsFrom = (id?: string) =>
result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => `${r.referenceKind}:${r.referenceName}`);
// The body's remote call belongs to the macro node β true exactly once.
expect(refsFrom(macro?.id)).toContain('calls:audit_logger::log/2');
// The use site joins the call chain: do_thing -callsβ LOG_AUDIT.
expect(refsFrom(doThing?.id)).toContain('calls:LOG_AUDIT');
});
it('should reference bare macro reads without polluting call chains', () => {
const code = `-module(m).
-export([wait/0]).
-define(TIMEOUT, 5000).
wait() ->
receive after ?TIMEOUT -> ok end.
`;
const result = extractFromSource('src/m.erl', code);
const refs = result.unresolvedReferences.map((r) => `${r.referenceKind}:${r.referenceName}`);
expect(refs).toContain('references:TIMEOUT');
expect(refs).not.toContain('calls:TIMEOUT');
});
it('should skip compiler-predefined macros and keep walking macro-use arguments', () => {
const code = `-module(m).
-export([check/0]).
check() ->
?assertEqual(ok, prepare()),
{?MODULE, ?LINE, ?FUNCTION_NAME}.
prepare() -> ok.
`;
const result = extractFromSource('src/m.erl', code);
const refs = result.unresolvedReferences.map((r) => r.referenceName);
// The nested call inside the macro's arguments still attributes to check/0.
expect(refs).toContain('prepare/0');
// ?assertEqual (an OTP header macro) is emitted and simply never resolvesβ¦
expect(refs).toContain('assertEqual');
// β¦but predefined macros have no definition to link.
expect(refs).not.toContain('MODULE');
expect(refs).not.toContain('LINE');
expect(refs).not.toContain('FUNCTION_NAME');
});
it('should chain macro-to-macro uses', () => {
const code = `-module(m).
-define(TARGET, target_fn()).
-define(ALIAS, ?TARGET).
`;
const result = extractFromSource('src/m.erl', code);
const target = result.nodes.find((n) => n.kind === 'constant' && n.name === 'TARGET');
const alias = result.nodes.find((n) => n.kind === 'constant' && n.name === 'ALIAS');
const refsFrom = (id?: string) =>
result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => `${r.referenceKind}:${r.referenceName}`);
expect(refsFrom(target?.id)).toContain('calls:target_fn/0');
expect(refsFrom(alias?.id)).toContain('references:TARGET');
});
});
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);
});
});
});
describe('Terraform Extraction', () => {
describe('Language detection', () => {
it('should detect Terraform files', () => {
expect(detectLanguage('main.tf')).toBe('terraform');
expect(detectLanguage('terraform.tfvars')).toBe('terraform');
expect(detectLanguage('versions.tofu')).toBe('terraform');
});
it('should report Terraform as supported', () => {
expect(isLanguageSupported('terraform')).toBe(true);
expect(getSupportedLanguages()).toContain('terraform');
});
});
describe('Block extraction', () => {
it('should extract a resource block as a class with qualified type.name', () => {
const code = `
resource "aws_s3_bucket" "my_bucket" {
bucket = "example"
}
`;
const result = extractFromSource('main.tf', code);
const res = result.nodes.find((n) => n.name === 'aws_s3_bucket.my_bucket');
expect(res).toBeDefined();
expect(res?.kind).toBe('class');
expect(res?.qualifiedName).toBe('aws_s3_bucket.my_bucket');
expect(res?.signature).toBe('resource "aws_s3_bucket" "my_bucket"');
expect(res?.language).toBe('terraform');
});
it('should extract a data block under the data.* qualified name', () => {
const code = `
data "aws_caller_identity" "current" {}
`;
const result = extractFromSource('main.tf', code);
const node = result.nodes.find((n) => n.qualifiedName === 'data.aws_caller_identity.current');
expect(node).toBeDefined();
expect(node?.kind).toBe('class');
});
it('should extract a variable block as variable with qualified name var.X', () => {
const code = `
variable "region" {
type = string
default = "us-east-1"
}
`;
const result = extractFromSource('variables.tf', code);
const v = result.nodes.find((n) => n.qualifiedName === 'var.region');
expect(v).toBeDefined();
expect(v?.kind).toBe('variable');
expect(v?.name).toBe('region');
});
it('should extract an output block as variable with qualified name output.X', () => {
const code = `
output "bucket_arn" {
value = aws_s3_bucket.my_bucket.arn
}
`;
const result = extractFromSource('outputs.tf', code);
const out = result.nodes.find((n) => n.qualifiedName === 'output.bucket_arn');
expect(out).toBeDefined();
expect(out?.kind).toBe('variable');
});
it('should extract a module block as module with qualified name module.X', () => {
const code = `
module "vpc" {
source = "./modules/vpc"
cidr = var.vpc_cidr
}
`;
const result = extractFromSource('main.tf', code);
const m = result.nodes.find((n) => n.qualifiedName === 'module.vpc');
expect(m).toBeDefined();
expect(m?.kind).toBe('module');
});
it('should extract a provider block as namespace', () => {
const code = `
provider "aws" {
region = "us-east-1"
}
`;
const result = extractFromSource('main.tf', code);
const p = result.nodes.find((n) => n.qualifiedName === 'provider.aws');
expect(p).toBeDefined();
expect(p?.kind).toBe('namespace');
});
it('should extract every locals attribute as its own constant with local.K qualified name', () => {
const code = `
locals {
prefix = "prod"
full_name = "\${local.prefix}-app"
max_retries = 3
}
`;
const result = extractFromSource('locals.tf', code);
const names = result.nodes
.filter((n) => n.kind === 'constant')
.map((n) => n.qualifiedName)
.sort();
expect(names).toEqual(['local.full_name', 'local.max_retries', 'local.prefix']);
});
it('should ignore a terraform settings block', () => {
const code = `
terraform {
required_version = ">= 1.5"
}
`;
const result = extractFromSource('versions.tf', code);
const symbols = result.nodes.filter((n) => n.kind !== 'file');
expect(symbols).toHaveLength(0);
});
it('should index .tfvars top-level attributes via the same parser path', () => {
// .tfvars files have no blocks β just bare attributes, each of which
// SETS the root module variable of that name. No symbols are declared,
// but every top-level assignment references its variable so "what sets
// var.region" is answerable.
const code = `
region = "us-east-1"
environment = "prod"
`;
const result = extractFromSource('terraform.tfvars', code);
expect(result.errors.filter((e) => e.severity === 'error')).toHaveLength(0);
const symbols = result.nodes.filter((n) => n.kind !== 'file');
expect(symbols).toHaveLength(0);
const refs = result.unresolvedReferences.map((r) => r.referenceName);
expect(refs).toContain('var.region');
expect(refs).toContain('var.environment');
});
});
describe('Reference extraction', () => {
it('should emit a reference for var.X used inside a resource', () => {
const code = `
variable "region" {}
resource "aws_s3_bucket" "b" {
bucket = var.region
}
`;
const result = extractFromSource('main.tf', code);
const refs = result.unresolvedReferences.map((r) => r.referenceName);
expect(refs).toContain('var.region');
});
it('should emit a reference for module.M.