arkts-resolution.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. /**
  2. * ArkTS end-to-end resolution tests.
  3. *
  4. * Pins the precision contract for build()-DSL attribute chains: a chained
  5. * `.attr(...)` resolves ONLY to a decorator-marked attribute helper
  6. * (`@Extend`/`@Styles`/…) — a framework attribute like `.width(...)` must
  7. * NEVER link to an arbitrary same-named symbol elsewhere in the project
  8. * (measured on the OpenHarmony samples monorepo, that fallthrough produced
  9. * 36k wrong edges — single properties with thousands of false callers).
  10. *
  11. * Also pins the ohpm workspace bridge: a bare `import { X } from "data"`
  12. * follows the oh-package.json5 `file:` dependency to the member module.
  13. */
  14. import { describe, it, expect, beforeAll, afterEach } from 'vitest';
  15. import * as fs from 'fs';
  16. import * as path from 'path';
  17. import * as os from 'os';
  18. import { CodeGraph } from '../src';
  19. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  20. beforeAll(async () => {
  21. await initGrammars();
  22. await loadAllGrammars();
  23. });
  24. describe('ArkTS attribute-chain resolution precision', () => {
  25. let tmpDir: string | undefined;
  26. afterEach(() => {
  27. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  28. tmpDir = undefined;
  29. });
  30. it('links .titleStyle() to the @Extend helper but never .width() to a decoy symbol', async () => {
  31. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkts-'));
  32. fs.mkdirSync(path.join(tmpDir, 'pages'));
  33. fs.mkdirSync(path.join(tmpDir, 'decoy'));
  34. // A decoy: symbols named after framework attributes, in another file.
  35. fs.writeFileSync(
  36. path.join(tmpDir, 'decoy/Decoy.ets'),
  37. 'export class Decoy {\n' +
  38. ' width: number = 0;\n' +
  39. '}\n' +
  40. 'export function height(v: number): number {\n' +
  41. ' return v * 2;\n' +
  42. '}\n'
  43. );
  44. fs.writeFileSync(
  45. path.join(tmpDir, 'pages/Home.ets'),
  46. '@Extend(Text) function titleStyle(size: number) {\n' +
  47. ' .fontSize(size)\n' +
  48. '}\n' +
  49. '\n' +
  50. '@Component\n' +
  51. 'struct Home {\n' +
  52. ' build() {\n' +
  53. ' Column() {\n' +
  54. ' Text("hello")\n' +
  55. ' .titleStyle(24)\n' +
  56. ' .width(100)\n' +
  57. ' }\n' +
  58. ' .height(50)\n' +
  59. ' }\n' +
  60. '}\n'
  61. );
  62. const cg = CodeGraph.initSync(tmpDir);
  63. await cg.indexAll();
  64. const fns = cg.getNodesByKind('function');
  65. const titleStyle = fns.find((n) => n.name === 'titleStyle');
  66. expect(titleStyle).toBeDefined();
  67. expect(titleStyle?.decorators).toContain('Extend');
  68. const structs = cg.getNodesByKind('struct');
  69. const home = structs.find((n) => n.name === 'Home');
  70. expect(home).toBeDefined();
  71. // build -> titleStyle via the decorator-gated attribute strategy.
  72. const methods = cg.getNodesByKind('method');
  73. const build = methods.find((n) => n.qualifiedName === 'Home::build');
  74. expect(build).toBeDefined();
  75. const buildCallees = cg.getOutgoingEdges(build!.id).map((e) => e.target);
  76. expect(buildCallees).toContain(titleStyle!.id);
  77. // The decoys named after framework attributes must have NO callers.
  78. const decoyWidth = cg
  79. .getNodesByKind('property')
  80. .find((n) => n.name === 'width' && n.filePath.includes('Decoy'));
  81. expect(decoyWidth).toBeDefined();
  82. expect(cg.getIncomingEdges(decoyWidth!.id).filter((e) => e.kind === 'calls')).toHaveLength(0);
  83. const decoyHeight = fns.find((n) => n.name === 'height' && n.filePath.includes('Decoy'));
  84. expect(decoyHeight).toBeDefined();
  85. expect(cg.getIncomingEdges(decoyHeight!.id).filter((e) => e.kind === 'calls')).toHaveLength(0);
  86. });
  87. });
  88. describe('ArkTS ohpm workspace import resolution', () => {
  89. let tmpDir: string | undefined;
  90. afterEach(() => {
  91. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  92. tmpDir = undefined;
  93. });
  94. it('resolves a bare workspace import through oh-package.json5 file: deps', async () => {
  95. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ohpm-'));
  96. fs.mkdirSync(path.join(tmpDir, 'core/data/src/main/ets'), { recursive: true });
  97. fs.mkdirSync(path.join(tmpDir, 'feature/goods/src/main/ets'), { recursive: true });
  98. // Member module "data" with an Index.ets barrel (ohpm entry convention).
  99. fs.writeFileSync(
  100. path.join(tmpDir, 'core/data/oh-package.json5'),
  101. '{\n // ohpm module manifest\n "name": "data",\n "main": "Index.ets",\n}\n'
  102. );
  103. fs.writeFileSync(
  104. path.join(tmpDir, 'core/data/Index.ets'),
  105. "export { CartRepository } from './src/main/ets/CartRepository';\n"
  106. );
  107. fs.writeFileSync(
  108. path.join(tmpDir, 'core/data/src/main/ets/CartRepository.ets'),
  109. 'export class CartRepository {\n' +
  110. ' addToCart(id: string): void {\n' +
  111. ' console.log(id);\n' +
  112. ' }\n' +
  113. '}\n'
  114. );
  115. // Consumer module declares the sibling via a file: dependency and imports
  116. // it by bare name.
  117. fs.writeFileSync(
  118. path.join(tmpDir, 'feature/goods/oh-package.json5'),
  119. '{\n "name": "goods",\n "dependencies": {\n "data": "file:../../core/data", // local module\n },\n}\n'
  120. );
  121. fs.writeFileSync(
  122. path.join(tmpDir, 'feature/goods/src/main/ets/GoodsViewModel.ets'),
  123. 'import { CartRepository } from "data";\n' +
  124. '\n' +
  125. 'export class GoodsViewModel {\n' +
  126. ' private cart: CartRepository = new CartRepository();\n' +
  127. '\n' +
  128. ' add(id: string): void {\n' +
  129. ' this.cart.addToCart(id);\n' +
  130. ' }\n' +
  131. '}\n'
  132. );
  133. const cg = CodeGraph.initSync(tmpDir);
  134. await cg.indexAll();
  135. const classes = cg.getNodesByKind('class');
  136. const repo = classes.find((n) => n.name === 'CartRepository');
  137. const vm = classes.find((n) => n.name === 'GoodsViewModel');
  138. expect(repo).toBeDefined();
  139. expect(vm).toBeDefined();
  140. // add() -> addToCart() across the module boundary.
  141. const methods = cg.getNodesByKind('method');
  142. const add = methods.find((n) => n.qualifiedName === 'GoodsViewModel::add');
  143. const addToCart = methods.find((n) => n.qualifiedName === 'CartRepository::addToCart');
  144. expect(add).toBeDefined();
  145. expect(addToCart).toBeDefined();
  146. const targets = cg.getOutgoingEdges(add!.id).map((e) => e.target);
  147. expect(targets).toContain(addToCart!.id);
  148. });
  149. });
  150. describe('ArkUI state → build() re-render bridge (assignment-gated)', () => {
  151. let tmpDir: string | undefined;
  152. afterEach(() => {
  153. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  154. tmpDir = undefined;
  155. });
  156. it('links assigning methods to build(), but not read-only methods', async () => {
  157. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-state-'));
  158. fs.writeFileSync(
  159. path.join(tmpDir, 'Page.ets'),
  160. '@Entry\n@Component\nstruct Page {\n' +
  161. ' @State todos: string[] = [];\n' +
  162. ' @State count: number = 0;\n' +
  163. '\n' +
  164. ' addTodo(t: string): void {\n' +
  165. ' this.todos.push(t);\n' +
  166. ' }\n' +
  167. '\n' +
  168. ' reset(): void {\n' +
  169. ' this.count = 0;\n' +
  170. ' }\n' +
  171. '\n' +
  172. ' describeCount(): string {\n' +
  173. ' return `count is ${this.count}`;\n' +
  174. ' }\n' +
  175. '\n' +
  176. ' build() {\n' +
  177. ' Column() {\n' +
  178. ' Text(this.describeCount())\n' +
  179. ' }\n' +
  180. ' }\n' +
  181. '}\n'
  182. );
  183. const cg = CodeGraph.initSync(tmpDir);
  184. await cg.indexAll();
  185. const methods = cg.getNodesByKind('method');
  186. const build = methods.find((n) => n.qualifiedName === 'Page::build')!;
  187. const addTodo = methods.find((n) => n.qualifiedName === 'Page::addTodo')!;
  188. const reset = methods.find((n) => n.qualifiedName === 'Page::reset')!;
  189. const describeCount = methods.find((n) => n.qualifiedName === 'Page::describeCount')!;
  190. const synthEdgesTo = (from: string) =>
  191. cg
  192. .getOutgoingEdges(from)
  193. .filter(
  194. (e) =>
  195. e.target === build.id &&
  196. (e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'arkui-state'
  197. );
  198. // Array mutator and plain assignment both count as state writes.
  199. expect(synthEdgesTo(addTodo.id)).toHaveLength(1);
  200. expect(synthEdgesTo(reset.id)).toHaveLength(1);
  201. // A read-only method gets NO re-render edge — the precision line.
  202. expect(synthEdgesTo(describeCount.id)).toHaveLength(0);
  203. });
  204. });
  205. describe('ArkUI @ohos.events.emitter bridge', () => {
  206. let tmpDir: string | undefined;
  207. afterEach(() => {
  208. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  209. tmpDir = undefined;
  210. });
  211. it('links emit → on through a shared named constant, chased through a local EventsId', async () => {
  212. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-emitter-'));
  213. fs.writeFileSync(
  214. path.join(tmpDir, 'Bus.ets'),
  215. "import emitter from '@ohos.events.emitter';\n" +
  216. '\n' +
  217. 'export class EmitterConst {\n' +
  218. ' static readonly ADD_EVENT_ID: number = 2;\n' +
  219. '}\n' +
  220. '\n' +
  221. 'class EventsId {\n' +
  222. ' eventId: number;\n' +
  223. ' constructor(eventId: number) {\n' +
  224. ' this.eventId = eventId;\n' +
  225. ' }\n' +
  226. '}\n' +
  227. '\n' +
  228. 'export class Bus {\n' +
  229. ' subscribeCart(callback: Function): void {\n' +
  230. ' let addGoodDataId: EventsId = new EventsId(EmitterConst.ADD_EVENT_ID);\n' +
  231. ' emitter.on(addGoodDataId, (eventData) => {\n' +
  232. ' callback(eventData);\n' +
  233. ' });\n' +
  234. ' }\n' +
  235. '\n' +
  236. ' publishAdd(goodId: number): void {\n' +
  237. ' let addToCartId: EventsId = new EventsId(EmitterConst.ADD_EVENT_ID);\n' +
  238. ' emitter.emit(addToCartId);\n' +
  239. ' }\n' +
  240. '}\n'
  241. );
  242. const cg = CodeGraph.initSync(tmpDir);
  243. await cg.indexAll();
  244. const methods = cg.getNodesByKind('method');
  245. const publishAdd = methods.find((n) => n.qualifiedName === 'Bus::publishAdd')!;
  246. const subscribeCart = methods.find((n) => n.qualifiedName === 'Bus::subscribeCart')!;
  247. const bridged = cg
  248. .getOutgoingEdges(publishAdd.id)
  249. .filter(
  250. (e) =>
  251. e.target === subscribeCart.id &&
  252. (e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'arkui-emitter'
  253. );
  254. expect(bridged).toHaveLength(1);
  255. });
  256. it('numeric-literal event ids never pair across files', async () => {
  257. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-emitter2-'));
  258. fs.writeFileSync(
  259. path.join(tmpDir, 'A.ets'),
  260. "import emitter from '@ohos.events.emitter';\n" +
  261. 'export function fireA(): void {\n' +
  262. ' emitter.emit({ eventId: 1 });\n' +
  263. '}\n'
  264. );
  265. fs.writeFileSync(
  266. path.join(tmpDir, 'B.ets'),
  267. "import emitter from '@ohos.events.emitter';\n" +
  268. 'export function listenB(): void {\n' +
  269. ' emitter.on({ eventId: 1 }, () => {});\n' +
  270. '}\n'
  271. );
  272. const cg = CodeGraph.initSync(tmpDir);
  273. await cg.indexAll();
  274. const fns = cg.getNodesByKind('function');
  275. const fireA = fns.find((n) => n.name === 'fireA')!;
  276. const listenB = fns.find((n) => n.name === 'listenB')!;
  277. const bridged = cg
  278. .getOutgoingEdges(fireA.id)
  279. .filter((e) => e.target === listenB.id);
  280. expect(bridged).toHaveLength(0);
  281. });
  282. });
  283. describe('ArkUI router bridge (pushUrl literal → @Entry struct)', () => {
  284. let tmpDir: string | undefined;
  285. afterEach(() => {
  286. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  287. tmpDir = undefined;
  288. });
  289. it('links the navigating method to the target page struct, standard layout only', async () => {
  290. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-router-'));
  291. fs.mkdirSync(path.join(tmpDir, 'entry/src/main/ets/pages'), { recursive: true });
  292. fs.writeFileSync(
  293. path.join(tmpDir, 'entry/src/main/ets/pages/Detail.ets'),
  294. '@Entry\n@Component\nstruct Detail {\n build() {\n Column() {\n Text("detail")\n }\n }\n}\n'
  295. );
  296. fs.writeFileSync(
  297. path.join(tmpDir, 'entry/src/main/ets/pages/Home.ets'),
  298. "import router from '@ohos.router';\n" +
  299. '\n' +
  300. '@Entry\n@Component\nstruct Home {\n' +
  301. ' openDetail(id: string): void {\n' +
  302. " router.pushUrl({ url: 'pages/Detail', params: { id: id } });\n" +
  303. ' }\n' +
  304. '\n' +
  305. ' build() {\n' +
  306. ' Column() {\n' +
  307. " Button('go').onClick(this.openDetail)\n" +
  308. ' }\n' +
  309. ' }\n' +
  310. '}\n'
  311. );
  312. const cg = CodeGraph.initSync(tmpDir);
  313. await cg.indexAll();
  314. const methods = cg.getNodesByKind('method');
  315. const openDetail = methods.find((n) => n.qualifiedName === 'Home::openDetail')!;
  316. const detail = cg.getNodesByKind('struct').find((n) => n.name === 'Detail')!;
  317. const bridged = cg
  318. .getOutgoingEdges(openDetail.id)
  319. .filter(
  320. (e) =>
  321. e.target === detail.id &&
  322. (e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'arkui-route'
  323. );
  324. expect(bridged).toHaveLength(1);
  325. });
  326. });
  327. describe('ohpm main entry (custom barrel + .ts consumer)', () => {
  328. let tmpDir: string | undefined;
  329. afterEach(() => {
  330. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  331. tmpDir = undefined;
  332. });
  333. it('resolves a bare import through a custom main, from an .ets AND a .ts consumer', async () => {
  334. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ohpm-main-'));
  335. fs.mkdirSync(path.join(tmpDir, 'core/data/src'), { recursive: true });
  336. fs.mkdirSync(path.join(tmpDir, 'feature/goods/src'), { recursive: true });
  337. // Custom entry — NOT the Index.ets convention.
  338. fs.writeFileSync(
  339. path.join(tmpDir, 'core/data/oh-package.json5'),
  340. '{\n "name": "data",\n "main": "src/entry.ets",\n}\n'
  341. );
  342. fs.writeFileSync(
  343. path.join(tmpDir, 'core/data/src/entry.ets'),
  344. "export { CartRepository } from './CartRepository';\n"
  345. );
  346. fs.writeFileSync(
  347. path.join(tmpDir, 'core/data/src/CartRepository.ets'),
  348. 'export class CartRepository {\n addToCart(id: string): void {\n console.log(id);\n }\n}\n'
  349. );
  350. fs.writeFileSync(
  351. path.join(tmpDir, 'feature/goods/oh-package.json5'),
  352. '{\n "name": "goods",\n "dependencies": {\n "data": "file:../../core/data",\n },\n}\n'
  353. );
  354. fs.writeFileSync(
  355. path.join(tmpDir, 'feature/goods/src/GoodsVm.ets'),
  356. 'import { CartRepository } from "data";\n' +
  357. 'export class GoodsVm {\n' +
  358. ' private cart: CartRepository = new CartRepository();\n' +
  359. ' add(id: string): void {\n this.cart.addToCart(id);\n }\n' +
  360. '}\n'
  361. );
  362. // The .ts consumer — resolves through the manifest's entry, no `.ets`
  363. // in the TypeScript candidate list required.
  364. fs.writeFileSync(
  365. path.join(tmpDir, 'feature/goods/src/report.ts'),
  366. 'import { CartRepository } from "data";\n' +
  367. 'export function report(cart: CartRepository): string {\n' +
  368. ' return typeof cart;\n' +
  369. '}\n'
  370. );
  371. const cg = CodeGraph.initSync(tmpDir);
  372. await cg.indexAll();
  373. const classes = cg.getNodesByKind('class');
  374. const repo = classes.find((n) => n.name === 'CartRepository')!;
  375. expect(repo).toBeDefined();
  376. // .ets consumer: cross-module method call connects.
  377. const methods = cg.getNodesByKind('method');
  378. const add = methods.find((n) => n.qualifiedName === 'GoodsVm::add')!;
  379. const addToCart = methods.find((n) => n.qualifiedName === 'CartRepository::addToCart')!;
  380. expect(cg.getOutgoingEdges(add.id).map((e) => e.target)).toContain(addToCart.id);
  381. // .ts consumer: the type annotation reference reaches the .ets class.
  382. const report = cg.getNodesByKind('function').find((n) => n.name === 'report')!;
  383. expect(cg.getOutgoingEdges(report.id).map((e) => e.target)).toContain(repo.id);
  384. });
  385. });