feat(extraction): add ArkTS language support with ArkUI dispatch bridges (#396, #512, #890 via #648) (#1186)
Adds ArkTS (.ets, HarmonyOS/OpenHarmony) as a first-class language: full TypeScript-grade extraction via the harmony-contrib tree-sitter grammar (MIT, vendored byte-identical from the tree-sitter-arkts 0.2.0 npm tarball), plus the ArkUI constructs that make HarmonyOS apps traceable: - @Component/@ComponentV2 structs with decorators from both grammar positions; members extract as class members with qualified names. - build() component trees: child instantiation edges via arkui_component_expression, no synthesizer needed. - Attribute chains emitted dot-prefixed and resolved ONLY against @Extend/@Styles/@AnimatableExtend/@Builder helpers (unique-or-drop) — bare-name fallthrough produced 36,840 wrong edges (17% of calls) on the OpenHarmony samples monorepo. All four grammar chain shapes handled, including the detached-chain forms. - .onClick(this.handler) method-reference bindings. - ohpm workspace modules: bare imports follow oh-package.json5 file: deps (ambiguous names dropped), honoring each module's main entry — which also lets .ts consumers resolve .ets modules. - ArkUI dynamic-dispatch bridges, all provenance:'heuristic' with wiring-site metadata: assignment-gated state->build() re-render (V1 @State family + V2 @Local/@Provider/@Consumer), @ohos.events.emitter emit->subscriber pairing on static event keys (numeric ids same-file, named constants same-module, fan-out capped), and router.pushUrl literal urls -> the target page's @Entry struct. - $r/$rawfile resource intrinsics treated as built-ins; arkts joins the web language family, value-reference edges, re-export chase, and the other TS-applicable gates. Also ships a language-agnostic index-completeness guard: indexAll stamps index_state (indexing -> complete/partial/failed), reconciles discovered vs accounted files (a loaded run silently dropped 37 files), and codegraph status surfaces truncated/partial indexes in human and --json output. Validated on HarmoneyOpenEye (82 files), CoolMallArkTS (528, modular ohpm + ArkUI V2), and openharmony/applications_app_samples (11,693 files, 202,890 nodes stable across re-index, attribute false-positive audit 36,840 -> 588 residual all-plausible). Supersedes PRs #656 and #988 with credit — both informed this implementation. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f8cdbe3c67
commit
99152212a9
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* ArkTS end-to-end resolution tests.
|
||||
*
|
||||
* Pins the precision contract for build()-DSL attribute chains: a chained
|
||||
* `.attr(...)` resolves ONLY to a decorator-marked attribute helper
|
||||
* (`@Extend`/`@Styles`/…) — a framework attribute like `.width(...)` must
|
||||
* NEVER link to an arbitrary same-named symbol elsewhere in the project
|
||||
* (measured on the OpenHarmony samples monorepo, that fallthrough produced
|
||||
* 36k wrong edges — single properties with thousands of false callers).
|
||||
*
|
||||
* Also pins the ohpm workspace bridge: a bare `import { X } from "data"`
|
||||
* follows the oh-package.json5 `file:` dependency to the member module.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { CodeGraph } from '../src';
|
||||
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
|
||||
|
||||
beforeAll(async () => {
|
||||
await initGrammars();
|
||||
await loadAllGrammars();
|
||||
});
|
||||
|
||||
describe('ArkTS attribute-chain resolution precision', () => {
|
||||
let tmpDir: string | undefined;
|
||||
afterEach(() => {
|
||||
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = undefined;
|
||||
});
|
||||
|
||||
it('links .titleStyle() to the @Extend helper but never .width() to a decoy symbol', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkts-'));
|
||||
fs.mkdirSync(path.join(tmpDir, 'pages'));
|
||||
fs.mkdirSync(path.join(tmpDir, 'decoy'));
|
||||
|
||||
// A decoy: symbols named after framework attributes, in another file.
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'decoy/Decoy.ets'),
|
||||
'export class Decoy {\n' +
|
||||
' width: number = 0;\n' +
|
||||
'}\n' +
|
||||
'export function height(v: number): number {\n' +
|
||||
' return v * 2;\n' +
|
||||
'}\n'
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'pages/Home.ets'),
|
||||
'@Extend(Text) function titleStyle(size: number) {\n' +
|
||||
' .fontSize(size)\n' +
|
||||
'}\n' +
|
||||
'\n' +
|
||||
'@Component\n' +
|
||||
'struct Home {\n' +
|
||||
' build() {\n' +
|
||||
' Column() {\n' +
|
||||
' Text("hello")\n' +
|
||||
' .titleStyle(24)\n' +
|
||||
' .width(100)\n' +
|
||||
' }\n' +
|
||||
' .height(50)\n' +
|
||||
' }\n' +
|
||||
'}\n'
|
||||
);
|
||||
|
||||
const cg = CodeGraph.initSync(tmpDir);
|
||||
await cg.indexAll();
|
||||
|
||||
const fns = cg.getNodesByKind('function');
|
||||
const titleStyle = fns.find((n) => n.name === 'titleStyle');
|
||||
expect(titleStyle).toBeDefined();
|
||||
expect(titleStyle?.decorators).toContain('Extend');
|
||||
|
||||
const structs = cg.getNodesByKind('struct');
|
||||
const home = structs.find((n) => n.name === 'Home');
|
||||
expect(home).toBeDefined();
|
||||
|
||||
// build -> titleStyle via the decorator-gated attribute strategy.
|
||||
const methods = cg.getNodesByKind('method');
|
||||
const build = methods.find((n) => n.qualifiedName === 'Home::build');
|
||||
expect(build).toBeDefined();
|
||||
const buildCallees = cg.getOutgoingEdges(build!.id).map((e) => e.target);
|
||||
expect(buildCallees).toContain(titleStyle!.id);
|
||||
|
||||
// The decoys named after framework attributes must have NO callers.
|
||||
const decoyWidth = cg
|
||||
.getNodesByKind('property')
|
||||
.find((n) => n.name === 'width' && n.filePath.includes('Decoy'));
|
||||
expect(decoyWidth).toBeDefined();
|
||||
expect(cg.getIncomingEdges(decoyWidth!.id).filter((e) => e.kind === 'calls')).toHaveLength(0);
|
||||
|
||||
const decoyHeight = fns.find((n) => n.name === 'height' && n.filePath.includes('Decoy'));
|
||||
expect(decoyHeight).toBeDefined();
|
||||
expect(cg.getIncomingEdges(decoyHeight!.id).filter((e) => e.kind === 'calls')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ArkTS ohpm workspace import resolution', () => {
|
||||
let tmpDir: string | undefined;
|
||||
afterEach(() => {
|
||||
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = undefined;
|
||||
});
|
||||
|
||||
it('resolves a bare workspace import through oh-package.json5 file: deps', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ohpm-'));
|
||||
fs.mkdirSync(path.join(tmpDir, 'core/data/src/main/ets'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpDir, 'feature/goods/src/main/ets'), { recursive: true });
|
||||
|
||||
// Member module "data" with an Index.ets barrel (ohpm entry convention).
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'core/data/oh-package.json5'),
|
||||
'{\n // ohpm module manifest\n "name": "data",\n "main": "Index.ets",\n}\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'core/data/Index.ets'),
|
||||
"export { CartRepository } from './src/main/ets/CartRepository';\n"
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'core/data/src/main/ets/CartRepository.ets'),
|
||||
'export class CartRepository {\n' +
|
||||
' addToCart(id: string): void {\n' +
|
||||
' console.log(id);\n' +
|
||||
' }\n' +
|
||||
'}\n'
|
||||
);
|
||||
|
||||
// Consumer module declares the sibling via a file: dependency and imports
|
||||
// it by bare name.
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'feature/goods/oh-package.json5'),
|
||||
'{\n "name": "goods",\n "dependencies": {\n "data": "file:../../core/data", // local module\n },\n}\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'feature/goods/src/main/ets/GoodsViewModel.ets'),
|
||||
'import { CartRepository } from "data";\n' +
|
||||
'\n' +
|
||||
'export class GoodsViewModel {\n' +
|
||||
' private cart: CartRepository = new CartRepository();\n' +
|
||||
'\n' +
|
||||
' add(id: string): void {\n' +
|
||||
' this.cart.addToCart(id);\n' +
|
||||
' }\n' +
|
||||
'}\n'
|
||||
);
|
||||
|
||||
const cg = CodeGraph.initSync(tmpDir);
|
||||
await cg.indexAll();
|
||||
|
||||
const classes = cg.getNodesByKind('class');
|
||||
const repo = classes.find((n) => n.name === 'CartRepository');
|
||||
const vm = classes.find((n) => n.name === 'GoodsViewModel');
|
||||
expect(repo).toBeDefined();
|
||||
expect(vm).toBeDefined();
|
||||
|
||||
// add() -> addToCart() across the module boundary.
|
||||
const methods = cg.getNodesByKind('method');
|
||||
const add = methods.find((n) => n.qualifiedName === 'GoodsViewModel::add');
|
||||
const addToCart = methods.find((n) => n.qualifiedName === 'CartRepository::addToCart');
|
||||
expect(add).toBeDefined();
|
||||
expect(addToCart).toBeDefined();
|
||||
const targets = cg.getOutgoingEdges(add!.id).map((e) => e.target);
|
||||
expect(targets).toContain(addToCart!.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ArkUI state → build() re-render bridge (assignment-gated)', () => {
|
||||
let tmpDir: string | undefined;
|
||||
afterEach(() => {
|
||||
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = undefined;
|
||||
});
|
||||
|
||||
it('links assigning methods to build(), but not read-only methods', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-state-'));
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'Page.ets'),
|
||||
'@Entry\n@Component\nstruct Page {\n' +
|
||||
' @State todos: string[] = [];\n' +
|
||||
' @State count: number = 0;\n' +
|
||||
'\n' +
|
||||
' addTodo(t: string): void {\n' +
|
||||
' this.todos.push(t);\n' +
|
||||
' }\n' +
|
||||
'\n' +
|
||||
' reset(): void {\n' +
|
||||
' this.count = 0;\n' +
|
||||
' }\n' +
|
||||
'\n' +
|
||||
' describeCount(): string {\n' +
|
||||
' return `count is ${this.count}`;\n' +
|
||||
' }\n' +
|
||||
'\n' +
|
||||
' build() {\n' +
|
||||
' Column() {\n' +
|
||||
' Text(this.describeCount())\n' +
|
||||
' }\n' +
|
||||
' }\n' +
|
||||
'}\n'
|
||||
);
|
||||
|
||||
const cg = CodeGraph.initSync(tmpDir);
|
||||
await cg.indexAll();
|
||||
|
||||
const methods = cg.getNodesByKind('method');
|
||||
const build = methods.find((n) => n.qualifiedName === 'Page::build')!;
|
||||
const addTodo = methods.find((n) => n.qualifiedName === 'Page::addTodo')!;
|
||||
const reset = methods.find((n) => n.qualifiedName === 'Page::reset')!;
|
||||
const describeCount = methods.find((n) => n.qualifiedName === 'Page::describeCount')!;
|
||||
|
||||
const synthEdgesTo = (from: string) =>
|
||||
cg
|
||||
.getOutgoingEdges(from)
|
||||
.filter(
|
||||
(e) =>
|
||||
e.target === build.id &&
|
||||
(e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'arkui-state'
|
||||
);
|
||||
|
||||
// Array mutator and plain assignment both count as state writes.
|
||||
expect(synthEdgesTo(addTodo.id)).toHaveLength(1);
|
||||
expect(synthEdgesTo(reset.id)).toHaveLength(1);
|
||||
// A read-only method gets NO re-render edge — the precision line.
|
||||
expect(synthEdgesTo(describeCount.id)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ArkUI @ohos.events.emitter bridge', () => {
|
||||
let tmpDir: string | undefined;
|
||||
afterEach(() => {
|
||||
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = undefined;
|
||||
});
|
||||
|
||||
it('links emit → on through a shared named constant, chased through a local EventsId', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-emitter-'));
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'Bus.ets'),
|
||||
"import emitter from '@ohos.events.emitter';\n" +
|
||||
'\n' +
|
||||
'export class EmitterConst {\n' +
|
||||
' static readonly ADD_EVENT_ID: number = 2;\n' +
|
||||
'}\n' +
|
||||
'\n' +
|
||||
'class EventsId {\n' +
|
||||
' eventId: number;\n' +
|
||||
' constructor(eventId: number) {\n' +
|
||||
' this.eventId = eventId;\n' +
|
||||
' }\n' +
|
||||
'}\n' +
|
||||
'\n' +
|
||||
'export class Bus {\n' +
|
||||
' subscribeCart(callback: Function): void {\n' +
|
||||
' let addGoodDataId: EventsId = new EventsId(EmitterConst.ADD_EVENT_ID);\n' +
|
||||
' emitter.on(addGoodDataId, (eventData) => {\n' +
|
||||
' callback(eventData);\n' +
|
||||
' });\n' +
|
||||
' }\n' +
|
||||
'\n' +
|
||||
' publishAdd(goodId: number): void {\n' +
|
||||
' let addToCartId: EventsId = new EventsId(EmitterConst.ADD_EVENT_ID);\n' +
|
||||
' emitter.emit(addToCartId);\n' +
|
||||
' }\n' +
|
||||
'}\n'
|
||||
);
|
||||
|
||||
const cg = CodeGraph.initSync(tmpDir);
|
||||
await cg.indexAll();
|
||||
|
||||
const methods = cg.getNodesByKind('method');
|
||||
const publishAdd = methods.find((n) => n.qualifiedName === 'Bus::publishAdd')!;
|
||||
const subscribeCart = methods.find((n) => n.qualifiedName === 'Bus::subscribeCart')!;
|
||||
const bridged = cg
|
||||
.getOutgoingEdges(publishAdd.id)
|
||||
.filter(
|
||||
(e) =>
|
||||
e.target === subscribeCart.id &&
|
||||
(e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'arkui-emitter'
|
||||
);
|
||||
expect(bridged).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('numeric-literal event ids never pair across files', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-emitter2-'));
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'A.ets'),
|
||||
"import emitter from '@ohos.events.emitter';\n" +
|
||||
'export function fireA(): void {\n' +
|
||||
' emitter.emit({ eventId: 1 });\n' +
|
||||
'}\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'B.ets'),
|
||||
"import emitter from '@ohos.events.emitter';\n" +
|
||||
'export function listenB(): void {\n' +
|
||||
' emitter.on({ eventId: 1 }, () => {});\n' +
|
||||
'}\n'
|
||||
);
|
||||
|
||||
const cg = CodeGraph.initSync(tmpDir);
|
||||
await cg.indexAll();
|
||||
|
||||
const fns = cg.getNodesByKind('function');
|
||||
const fireA = fns.find((n) => n.name === 'fireA')!;
|
||||
const listenB = fns.find((n) => n.name === 'listenB')!;
|
||||
const bridged = cg
|
||||
.getOutgoingEdges(fireA.id)
|
||||
.filter((e) => e.target === listenB.id);
|
||||
expect(bridged).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ArkUI router bridge (pushUrl literal → @Entry struct)', () => {
|
||||
let tmpDir: string | undefined;
|
||||
afterEach(() => {
|
||||
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = undefined;
|
||||
});
|
||||
|
||||
it('links the navigating method to the target page struct, standard layout only', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-router-'));
|
||||
fs.mkdirSync(path.join(tmpDir, 'entry/src/main/ets/pages'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'entry/src/main/ets/pages/Detail.ets'),
|
||||
'@Entry\n@Component\nstruct Detail {\n build() {\n Column() {\n Text("detail")\n }\n }\n}\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'entry/src/main/ets/pages/Home.ets'),
|
||||
"import router from '@ohos.router';\n" +
|
||||
'\n' +
|
||||
'@Entry\n@Component\nstruct Home {\n' +
|
||||
' openDetail(id: string): void {\n' +
|
||||
" router.pushUrl({ url: 'pages/Detail', params: { id: id } });\n" +
|
||||
' }\n' +
|
||||
'\n' +
|
||||
' build() {\n' +
|
||||
' Column() {\n' +
|
||||
" Button('go').onClick(this.openDetail)\n" +
|
||||
' }\n' +
|
||||
' }\n' +
|
||||
'}\n'
|
||||
);
|
||||
|
||||
const cg = CodeGraph.initSync(tmpDir);
|
||||
await cg.indexAll();
|
||||
|
||||
const methods = cg.getNodesByKind('method');
|
||||
const openDetail = methods.find((n) => n.qualifiedName === 'Home::openDetail')!;
|
||||
const detail = cg.getNodesByKind('struct').find((n) => n.name === 'Detail')!;
|
||||
const bridged = cg
|
||||
.getOutgoingEdges(openDetail.id)
|
||||
.filter(
|
||||
(e) =>
|
||||
e.target === detail.id &&
|
||||
(e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'arkui-route'
|
||||
);
|
||||
expect(bridged).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ohpm main entry (custom barrel + .ts consumer)', () => {
|
||||
let tmpDir: string | undefined;
|
||||
afterEach(() => {
|
||||
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = undefined;
|
||||
});
|
||||
|
||||
it('resolves a bare import through a custom main, from an .ets AND a .ts consumer', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ohpm-main-'));
|
||||
fs.mkdirSync(path.join(tmpDir, 'core/data/src'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpDir, 'feature/goods/src'), { recursive: true });
|
||||
|
||||
// Custom entry — NOT the Index.ets convention.
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'core/data/oh-package.json5'),
|
||||
'{\n "name": "data",\n "main": "src/entry.ets",\n}\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'core/data/src/entry.ets'),
|
||||
"export { CartRepository } from './CartRepository';\n"
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'core/data/src/CartRepository.ets'),
|
||||
'export class CartRepository {\n addToCart(id: string): void {\n console.log(id);\n }\n}\n'
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'feature/goods/oh-package.json5'),
|
||||
'{\n "name": "goods",\n "dependencies": {\n "data": "file:../../core/data",\n },\n}\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'feature/goods/src/GoodsVm.ets'),
|
||||
'import { CartRepository } from "data";\n' +
|
||||
'export class GoodsVm {\n' +
|
||||
' private cart: CartRepository = new CartRepository();\n' +
|
||||
' add(id: string): void {\n this.cart.addToCart(id);\n }\n' +
|
||||
'}\n'
|
||||
);
|
||||
// The .ts consumer — resolves through the manifest's entry, no `.ets`
|
||||
// in the TypeScript candidate list required.
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'feature/goods/src/report.ts'),
|
||||
'import { CartRepository } from "data";\n' +
|
||||
'export function report(cart: CartRepository): string {\n' +
|
||||
' return typeof cart;\n' +
|
||||
'}\n'
|
||||
);
|
||||
|
||||
const cg = CodeGraph.initSync(tmpDir);
|
||||
await cg.indexAll();
|
||||
|
||||
const classes = cg.getNodesByKind('class');
|
||||
const repo = classes.find((n) => n.name === 'CartRepository')!;
|
||||
expect(repo).toBeDefined();
|
||||
|
||||
// .ets consumer: cross-module method call connects.
|
||||
const methods = cg.getNodesByKind('method');
|
||||
const add = methods.find((n) => n.qualifiedName === 'GoodsVm::add')!;
|
||||
const addToCart = methods.find((n) => n.qualifiedName === 'CartRepository::addToCart')!;
|
||||
expect(cg.getOutgoingEdges(add.id).map((e) => e.target)).toContain(addToCart.id);
|
||||
|
||||
// .ts consumer: the type annotation reference reaches the .ets class.
|
||||
const report = cg.getNodesByKind('function').find((n) => n.name === 'report')!;
|
||||
expect(cg.getOutgoingEdges(report.id).map((e) => e.target)).toContain(repo.id);
|
||||
});
|
||||
});
|
||||
@@ -138,6 +138,12 @@ describe('Language Detection', () => {
|
||||
expect(detectLanguage('versions.tofu')).toBe('terraform');
|
||||
});
|
||||
|
||||
it('should detect ArkTS files', () => {
|
||||
expect(detectLanguage('entry/src/main/ets/pages/Index.ets')).toBe('arkts');
|
||||
// Plain `.ts` in a HarmonyOS project is still TypeScript.
|
||||
expect(detectLanguage('entry/src/main/ets/common/utils.ts')).toBe('typescript');
|
||||
});
|
||||
|
||||
it('should return unknown for unsupported extensions', () => {
|
||||
expect(detectLanguage('styles.css')).toBe('unknown');
|
||||
expect(detectLanguage('data.json')).toBe('unknown');
|
||||
@@ -10238,3 +10244,285 @@ resource "aws_instance" "x" {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// ArkTS (HarmonyOS / OpenHarmony declarative UI — `.ets`)
|
||||
// =============================================================================
|
||||
|
||||
describe('ArkTS Extraction', () => {
|
||||
it('reports ArkTS as supported', () => {
|
||||
expect(isLanguageSupported('arkts')).toBe(true);
|
||||
expect(getSupportedLanguages()).toContain('arkts');
|
||||
});
|
||||
|
||||
describe('@Component struct extraction', () => {
|
||||
const code = `
|
||||
import { TodoItem } from '../model/TodoItem';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct Index {
|
||||
@State message: string = 'Hello';
|
||||
@Prop count: number = 0;
|
||||
@StorageLink('theme') theme: string = 'light';
|
||||
private service: TodoService = new TodoService();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.message = 'loaded';
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Text(this.message).fontSize(50)
|
||||
}
|
||||
.height('100%')
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
it('extracts the struct with its ArkUI decorators', () => {
|
||||
const result = extractFromSource('pages/Index.ets', code);
|
||||
const comp = result.nodes.find((n) => n.kind === 'struct' && n.name === 'Index');
|
||||
expect(comp).toBeDefined();
|
||||
expect(comp?.language).toBe('arkts');
|
||||
expect(comp?.decorators).toEqual(expect.arrayContaining(['Entry', 'Component']));
|
||||
});
|
||||
|
||||
it('extracts an EXPORTED struct whose decorators sit on the export statement', () => {
|
||||
const result = extractFromSource(
|
||||
'components/Card.ets',
|
||||
`@Component\nexport struct Card {\n build() {\n Row() {}\n }\n}\n`
|
||||
);
|
||||
const card = result.nodes.find((n) => n.kind === 'struct' && n.name === 'Card');
|
||||
expect(card).toBeDefined();
|
||||
expect(card?.isExported).toBe(true);
|
||||
expect(card?.decorators).toContain('Component');
|
||||
});
|
||||
|
||||
it('extracts struct members: build(), lifecycle + regular methods with qualified names', () => {
|
||||
const result = extractFromSource('pages/Index.ets', code);
|
||||
const methods = result.nodes.filter((n) => n.kind === 'method');
|
||||
expect(methods.find((m) => m.qualifiedName === 'Index::build')).toBeDefined();
|
||||
expect(methods.find((m) => m.qualifiedName === 'Index::aboutToAppear')).toBeDefined();
|
||||
expect(methods.find((m) => m.qualifiedName === 'Index::load')).toBeDefined();
|
||||
});
|
||||
|
||||
it('extracts @State/@Prop/@StorageLink members as properties with their decorators', () => {
|
||||
const result = extractFromSource('pages/Index.ets', code);
|
||||
const message = result.nodes.find((n) => n.kind === 'property' && n.qualifiedName === 'Index::message');
|
||||
expect(message).toBeDefined();
|
||||
expect(message?.decorators).toContain('State');
|
||||
const count = result.nodes.find((n) => n.kind === 'property' && n.qualifiedName === 'Index::count');
|
||||
expect(count?.decorators).toContain('Prop');
|
||||
// Decorator-with-args: the decorator NAME is captured, not its argument.
|
||||
const theme = result.nodes.find((n) => n.kind === 'property' && n.qualifiedName === 'Index::theme');
|
||||
expect(theme?.decorators).toContain('StorageLink');
|
||||
});
|
||||
|
||||
it('emits intra-struct method call refs (this.load())', () => {
|
||||
const result = extractFromSource('pages/Index.ets', code);
|
||||
const call = result.unresolvedReferences.find(
|
||||
(r) => r.referenceKind === 'calls' && r.referenceName === 'load'
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('build() DSL call surface', () => {
|
||||
const code = `
|
||||
@Extend(Text) function titleStyle(size: number) {
|
||||
.fontSize(size)
|
||||
}
|
||||
|
||||
@Component
|
||||
struct Page {
|
||||
count: number = 0;
|
||||
|
||||
handleTap(): void {
|
||||
this.count += 1;
|
||||
}
|
||||
|
||||
@Builder
|
||||
headerBar(title: string) {
|
||||
Row() {
|
||||
Text(title).titleStyle(24)
|
||||
Button('Go').onClick(this.handleTap)
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Column({ space: 8 }) {
|
||||
this.headerBar('Home')
|
||||
ChildCard({ label: 'hi' })
|
||||
}
|
||||
.height('100%')
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function callRefsFrom(result: ReturnType<typeof extractFromSource>, methodName: string): string[] {
|
||||
const from = result.nodes.find((n) => n.kind === 'method' && n.name === methodName);
|
||||
return result.unresolvedReferences
|
||||
.filter((r) => r.referenceKind === 'calls' && r.fromNodeId === from?.id)
|
||||
.map((r) => r.referenceName);
|
||||
}
|
||||
|
||||
it('emits a call ref for a custom component instantiation inside build()', () => {
|
||||
const result = extractFromSource('pages/Page.ets', code);
|
||||
expect(callRefsFrom(result, 'build')).toContain('ChildCard');
|
||||
});
|
||||
|
||||
it('emits dot-prefixed call refs for chained attributes (@Extend/@Styles-only resolution)', () => {
|
||||
const result = extractFromSource('pages/Page.ets', code);
|
||||
// `.titleStyle(24)` chains on the Text component — one node, repeated
|
||||
// property/arguments field pairs, NOT nested call_expressions. The
|
||||
// leading dot routes the ref to the decorator-gated matcher strategy so
|
||||
// framework attributes (`.height` below) can never hit an arbitrary
|
||||
// same-named symbol.
|
||||
expect(callRefsFrom(result, 'headerBar')).toContain('.titleStyle');
|
||||
expect(callRefsFrom(result, 'build')).toContain('.height');
|
||||
expect(callRefsFrom(result, 'build')).not.toContain('height');
|
||||
});
|
||||
|
||||
it('recovers the detached-chain shape (chain on the line after a nested component)', () => {
|
||||
// Inside arkui_children, a chain starting after the closing `}` is
|
||||
// detached by the grammar into sibling leading_dot_expression +
|
||||
// parenthesized_expression statements — the close-button idiom.
|
||||
const detached = `
|
||||
@Component
|
||||
struct Panel {
|
||||
close(): void {}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Row() {
|
||||
Text('x')
|
||||
}
|
||||
.width(10)
|
||||
.onClick(this.close)
|
||||
.id('close_button')
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const result = extractFromSource('components/Panel.ets', detached);
|
||||
const refs = callRefsFrom(result, 'build');
|
||||
expect(refs).toContain('close');
|
||||
expect(refs).toContain('.width');
|
||||
expect(refs).not.toContain('width');
|
||||
});
|
||||
|
||||
it('dot-prefixes the innermost call of a proper-form detached chain', () => {
|
||||
// `.alignItems(x).layoutWeight(1)` under a leading_dot_expression: the
|
||||
// wrapper consumes the dot, so the innermost call has a bare identifier
|
||||
// function and would otherwise emit as a plain `alignItems(...)` call.
|
||||
const chained = `
|
||||
@Component
|
||||
struct Card {
|
||||
build() {
|
||||
Column() {
|
||||
List() {
|
||||
Text('x')
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
.height('100%')
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const result = extractFromSource('components/Card.ets', chained);
|
||||
const refs = callRefsFrom(result, 'build');
|
||||
expect(refs).toContain('.alignItems');
|
||||
expect(refs).not.toContain('alignItems');
|
||||
expect(refs).toContain('.layoutWeight');
|
||||
expect(refs).not.toContain('layoutWeight');
|
||||
});
|
||||
|
||||
it('emits a call ref for an .onClick(this.handler) method-reference binding', () => {
|
||||
const result = extractFromSource('pages/Page.ets', code);
|
||||
expect(callRefsFrom(result, 'headerBar')).toContain('handleTap');
|
||||
});
|
||||
|
||||
it('emits a call ref for a @Builder method invoked as this.headerBar()', () => {
|
||||
const result = extractFromSource('pages/Page.ets', code);
|
||||
expect(callRefsFrom(result, 'build')).toContain('headerBar');
|
||||
});
|
||||
|
||||
it('extracts a global @Extend function with its decorator', () => {
|
||||
const result = extractFromSource('pages/Page.ets', code);
|
||||
const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'titleStyle');
|
||||
expect(fn).toBeDefined();
|
||||
expect(fn?.decorators).toContain('Extend');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Global @Builder functions', () => {
|
||||
it('extracts a decorated global @Builder function with signature and decorator', () => {
|
||||
const result = extractFromSource(
|
||||
'common/builders.ets',
|
||||
`@Builder\nfunction EmptyHint(message: string) {\n Column() {\n Text(message).fontSize(16)\n }\n}\n`
|
||||
);
|
||||
const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'EmptyHint');
|
||||
expect(fn).toBeDefined();
|
||||
expect(fn?.signature).toBe('(message: string)');
|
||||
expect(fn?.decorators).toContain('Builder');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Standard TypeScript constructs in .ets', () => {
|
||||
it('extracts classes, interfaces, enums, type aliases and their members', () => {
|
||||
const code = `
|
||||
export enum Priority { Low, Medium = 2, High }
|
||||
|
||||
export interface Shape {
|
||||
area(): number;
|
||||
}
|
||||
|
||||
export type Handler = (e: string) => void;
|
||||
|
||||
export class Service {
|
||||
private count: number = 0;
|
||||
doWork(x: number): number {
|
||||
return this.helper(x);
|
||||
}
|
||||
helper(n: number): number { return n * 2; }
|
||||
}
|
||||
`;
|
||||
const result = extractFromSource('common/service.ets', code);
|
||||
expect(result.nodes.find((n) => n.kind === 'class' && n.name === 'Service')).toBeDefined();
|
||||
expect(result.nodes.find((n) => n.kind === 'enum' && n.name === 'Priority')).toBeDefined();
|
||||
const members = result.nodes.filter((n) => n.kind === 'enum_member').map((n) => n.qualifiedName);
|
||||
expect(members).toEqual(expect.arrayContaining(['Priority::Low', 'Priority::Medium', 'Priority::High']));
|
||||
expect(result.nodes.find((n) => n.kind === 'interface' && n.name === 'Shape')).toBeDefined();
|
||||
expect(result.nodes.find((n) => n.kind === 'type_alias' && n.name === 'Handler')).toBeDefined();
|
||||
const doWork = result.nodes.find((n) => n.qualifiedName === 'Service::doWork');
|
||||
expect(doWork?.kind).toBe('method');
|
||||
expect(doWork?.signature).toBe('(x: number): number');
|
||||
expect(
|
||||
result.unresolvedReferences.find((r) => r.referenceKind === 'calls' && r.referenceName === 'helper')
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Import extraction', () => {
|
||||
it('extracts relative, SDK (@ohos/@kit) and default imports', () => {
|
||||
const code = `
|
||||
import router from '@ohos.router';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { TodoItem } from '../model/TodoItem';
|
||||
import DataStore from '../data/DataStore';
|
||||
`;
|
||||
const result = extractFromSource('pages/imports.ets', code);
|
||||
const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
|
||||
expect(imports).toContain('@ohos.router');
|
||||
expect(imports).toContain('@kit.ArkUI');
|
||||
expect(imports).toContain('../model/TodoItem');
|
||||
expect(imports).toContain('../data/DataStore');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,3 +87,61 @@ describe('codegraph status --json — CI fields (#329)', () => {
|
||||
expect(ms).toBeLessThanOrEqual(after + 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('index completeness marker (index_state)', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-index-state-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('a clean full index stamps state=complete with reconciled counts', async () => {
|
||||
fs.writeFileSync(path.join(tempDir, 'a.ts'), 'export function f(): number { return 1; }\n');
|
||||
fs.writeFileSync(path.join(tempDir, 'b.ts'), 'import { f } from "./a";\nexport const y = f();\n');
|
||||
const cg = CodeGraph.initSync(tempDir);
|
||||
const result = await cg.indexAll();
|
||||
|
||||
// The scan's ground truth is reported and fully accounted for.
|
||||
expect(result.filesDiscovered).toBeDefined();
|
||||
expect(result.filesIndexed + result.filesSkipped + result.filesErrored).toBe(
|
||||
result.filesDiscovered
|
||||
);
|
||||
expect(result.errors.filter((e) => e.code === 'index_partial')).toHaveLength(0);
|
||||
expect(cg.getIndexState()).toBe('complete');
|
||||
cg.close();
|
||||
|
||||
const out = runStatusJson(tempDir);
|
||||
expect((out.index as Record<string, unknown>).state).toBe('complete');
|
||||
});
|
||||
|
||||
it('a run killed mid-index leaves state=indexing, and status --json surfaces it', async () => {
|
||||
fs.writeFileSync(path.join(tempDir, 'a.ts'), 'export const x = 1;\n');
|
||||
const cg = CodeGraph.initSync(tempDir);
|
||||
await cg.indexAll();
|
||||
cg.close();
|
||||
|
||||
// Simulate a kill between the start-marker write and completion: the
|
||||
// marker a dead process leaves behind is exactly 'indexing'. Written
|
||||
// straight into the DB — the process that died can't have cleaned it up.
|
||||
// (require, not import: vite tries to bundle a dynamic import specifier.)
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const db = new DatabaseSync(path.join(tempDir, '.codegraph', 'codegraph.db'));
|
||||
db.prepare(
|
||||
"INSERT INTO project_metadata (key, value, updated_at) VALUES ('index_state', 'indexing', 0) " +
|
||||
"ON CONFLICT(key) DO UPDATE SET value = 'indexing'"
|
||||
).run();
|
||||
db.close();
|
||||
|
||||
const out = runStatusJson(tempDir);
|
||||
expect((out.index as Record<string, unknown>).state).toBe('indexing');
|
||||
|
||||
const reopened = await CodeGraph.open(tempDir);
|
||||
expect(reopened.getIndexState()).toBe('indexing');
|
||||
reopened.close();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user