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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
747 changes: 747 additions & 0 deletions __tests__/arkui-framework.test.ts

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion src/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ const DEFAULT_BUILD_OPTIONS: Required<BuildContextOptions> = {
*/
const HIGH_VALUE_NODE_KINDS: NodeKind[] = [
'function', 'method', 'class', 'interface', 'type_alias', 'struct', 'trait',
'component', 'route', 'variable', 'constant', 'enum', 'module', 'namespace',
'component', 'route', 'arkui_page', 'variable', 'constant', 'enum', 'module', 'namespace',
];

/**
Expand Down Expand Up @@ -388,6 +388,12 @@ export class ContextBuilder {
? `renders <${String(m.via || 'child')}>`
: m.synthesizedBy === 'vue-handler'
? `Vue @${String(m.event || 'event')} handler`
: m.synthesizedBy === 'arkui-state-chain'
? `state chain via ${m.via ? `\`${String(m.via)}\`` : 'method'}${at}`
: m.synthesizedBy === 'arkui-state-dep'
? `reads ${m.decorator ? `\`${String(m.decorator)}\`` : '@State'} ${String(m.property || 'prop')}`
: m.synthesizedBy === 'arkui-event-chain'
? `event ${m.event ? `\`${String(m.event)}\`` : ''} → ${m.handler ? `\`${String(m.handler)}\`` : 'handler'}${at}`
: `event ${m.event ? `\`${String(m.event)}\`` : ''}${at}`;
synthByPair.set(`${e.source}>${e.target}`, label);
}
Expand Down
5 changes: 4 additions & 1 deletion src/extraction/grammars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
lua: 'tree-sitter-lua.wasm',
luau: 'tree-sitter-luau.wasm',
objc: 'tree-sitter-objc.wasm',
arkts: 'tree-sitter-arkts.wasm',
};

/**
Expand All @@ -49,6 +50,7 @@ export const EXTENSION_MAP: Record<string, Language> = {
// ESM/CJS TypeScript module extensions — parsed as TS (no JSX). (#366)
'.mts': 'typescript',
'.cts': 'typescript',
'.ets': 'arkts',
'.js': 'javascript',
'.mjs': 'javascript',
'.cjs': 'javascript',
Expand Down Expand Up @@ -185,7 +187,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
// ABI-13 build that corrupts the shared WASM heap under web-tree-sitter
// 0.25 (drops nested calls/imports on every file after the first); we
// vendor the upstream ABI-15 wasm instead.
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau')
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'arkts')
? path.join(__dirname, 'wasm', wasmFile)
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
const language = await WasmLanguage.load(wasmPath);
Expand Down Expand Up @@ -384,6 +386,7 @@ export function getLanguageDisplayName(language: Language): string {
lua: 'Lua',
luau: 'Luau',
objc: 'Objective-C',
arkts: 'ArkTS',
yaml: 'YAML',
twig: 'Twig',
xml: 'XML',
Expand Down
23 changes: 23 additions & 0 deletions src/extraction/languages/arkts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { LanguageExtractor } from '../tree-sitter-types';
import { typescriptExtractor } from './typescript';

/**
* ArkTS language extractor.
*
* ArkTS is a TypeScript superset used in HarmonyOS/ArkUI development.
* It extends TypeScript with:
* - `struct` keyword for component definitions (@Component struct X { ... })
* - Decorator-first patterns (@State, @Prop, @Link, @Builder, @Styles, etc.)
* - `.ets` file extension
*
* The base TypeScript extractor handles all shared syntax. ArkTS-specific
* constructs (decorators, structs) are recognized through the existing
* decorator and struct extraction paths.
*/
export const arktsExtractor: LanguageExtractor = {
...typescriptExtractor,

// ArkTS uses `struct` keyword for component definitions.
// tree-sitter-arkts grammar parses these as `struct_declaration` nodes.
structTypes: ['struct_declaration'],
};
2 changes: 2 additions & 0 deletions src/extraction/languages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { scalaExtractor } from './scala';
import { luaExtractor } from './lua';
import { luauExtractor } from './luau';
import { objcExtractor } from './objc';
import { arktsExtractor } from './arkts';

export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
typescript: typescriptExtractor,
Expand All @@ -49,4 +50,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
lua: luaExtractor,
luau: luauExtractor,
objc: objcExtractor,
arkts: arktsExtractor,
};
Binary file added src/extraction/wasm/tree-sitter-arkts.wasm
Binary file not shown.
26 changes: 26 additions & 0 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1262,6 +1262,32 @@ export class ToolHandler {
registeredAt,
};
}
if (m?.synthesizedBy === 'arkui-state-chain') {
const via = m.via ? `\`${String(m.via)}\`` : 'sibling method';
return {
label: `ArkUI state chain — ${via} triggers build() re-render (dynamic dispatch)`,
compact: `dynamic: ArkUI state chain via ${via}${at}`,
registeredAt,
};
}
if (m?.synthesizedBy === 'arkui-state-dep') {
const decorator = m.decorator ? `\`${String(m.decorator)}\`` : '@State';
const prop = m.property ? String(m.property) : 'property';
return {
label: `ArkUI state dep — reads ${decorator} ${prop} (dynamic dispatch)`,
compact: `dynamic: ArkUI reads ${decorator} ${prop}${at}`,
registeredAt,
};
}
if (m?.synthesizedBy === 'arkui-event-chain') {
const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
const handler = m.handler ? `\`${String(m.handler)}\`` : 'handler';
return {
label: `ArkUI event chain — .on${String(m.event || 'Event')}() → ${handler} (dynamic dispatch)`,
compact: `dynamic: ArkUI ${ev} → ${handler}${at}`,
registeredAt,
};
}
return null;
}

Expand Down
219 changes: 216 additions & 3 deletions src/resolution/callback-synthesizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1183,11 +1183,218 @@ function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionContext):
return edges;
}

// =============================================================================
// ArkUI (HarmonyOS) synthesis phases
// =============================================================================
// ArkUI structs are tree-sitter-parsed as `class` nodes in .ets files. Each
// struct with a `build()` method is a component; inside it, `@State`/`@Prop`/
// `@Link` properties drive re-renders, `@Builder` methods produce sub-trees,
// and event bindings (`.onClick()`, `.onChange()`) invoke handlers.
// Three phases close the static gap between these declaration-time concepts
// and the runtime flow that tree-sitter can't see.

/** ArkUI file extensions handled by these phases. */
const ARKUI_EXT_RE = /\.ets$/;

/** Regex for ArkUI reactive state decorators: @State / @Prop / @Link / @StorageLink / @Provide / @Consume. */
const ARKUI_STATE_RE = /@(?:State|Prop|Link|StorageLink|StorageProp|Provide|Consume)\s*(?:\([^)]*\))?\s*(\w+)\s*[:=(]/g;

/** Regex for ArkUI event bindings inside build(): .onClick(...), .onChange(...), etc. */
const ARKUI_HANDLER_RE = /\.on(?:Click|Change|Appear|DisAppear|Touch|Gesture|DragStart|DragEnd|DragMove|Drop|DragEnter|DragLeave)\s*\([\s\S]*?this\.(\w+)/g;

// --- Phase A: ArkUI state-chain edges ------------------------------------------

/**
* Phase A: ArkUI state-chain edges.
*
* In ArkUI, `@State`/`@Prop` mutation triggers a re-render of `build()`.
* The framework-internal re-render hop is invisible to static analysis, so
* a flow like "onClick → this.count++ → rebuilt UI" dead-ends at the state
* mutation. Bridge it: for each ArkUI struct (class in .ets) with a `build()`
* method, link every sibling method → `build()`.
*/
export function arkuiStateChainEdges(queries: QueryBuilder, _ctx: ResolutionContext): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
// ArkUI structs are tree-sitter kind 'struct'; TypeScript classes in .ets
// files (libraries, helpers) are kind 'class'. Query both.
for (const kind of ['class', 'struct'] as const) {
for (const cls of queries.getNodesByKind(kind)) {
if (!ARKUI_EXT_RE.test(cls.filePath)) continue;
const children = queries.getOutgoingEdges(cls.id, ['contains'])
.map((e) => queries.getNodeById(e.target))
.filter((n): n is Node => !!n && n.kind === 'method');
const build = children.find((n) => n.name === 'build');
if (!build) continue;
let added = 0;
for (const m of children) {
if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
if (m.id === build.id) continue;
const key = `${m.id}>${build.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: m.id, target: build.id, kind: 'calls', line: m.startLine,
provenance: 'heuristic',
metadata: { synthesizedBy: 'arkui-state-chain', via: m.name, registeredAt: `${build.filePath}:${build.startLine}` },
});
added++;
}
}
}
return edges;
}

// --- Phase B: ArkUI state-dependency edges ------------------------------------

/**
* Phase B: ArkUI state-dependency edges.
*
* `@State`/`@Prop`/`@Link`/`@StorageLink`/`@Provide`/`@Consume` properties
* are the reactive primitives that drive ArkUI re-renders. When a `@Builder`
* method or regular method reads `this.<stateProp>`, link the method → the
* state property so data-flow traces show which state each method depends on.
*/
export function arkuiStateDepEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();

// Build a map: structId/classId → methods for quick lookup.
const classMethods = new Map<string, { methods: Node[] }>();
// ArkUI structs are tree-sitter kind 'struct'; TypeScript classes in .ets
// files (libraries, helpers) are kind 'class'. Query both.
for (const kind of ['class', 'struct'] as const) {
for (const cls of queries.getNodesByKind(kind)) {
if (!ARKUI_EXT_RE.test(cls.filePath)) continue;
const children = queries.getOutgoingEdges(cls.id, ['contains'])
.map((e) => queries.getNodeById(e.target))
.filter((n): n is Node => !!n && n.kind === 'method');
if (children.length > 0) classMethods.set(cls.id, { methods: children });
}
}
if (classMethods.size === 0) return edges;

for (const file of ctx.getAllFiles()) {
if (!ARKUI_EXT_RE.test(file)) continue;
const content = ctx.readFile(file);
if (!content) continue;

const classScopes = ctx.getNodesInFile(file)
.filter((n) => (n.kind === 'class' || n.kind === 'struct') && classMethods.has(n.id))
.map((c) => ({ id: c.id, start: c.startLine, end: c.endLine }));

const safe = stripCommentsForRegex(content, 'typescript');
ARKUI_STATE_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ARKUI_STATE_RE.exec(safe))) {
const propName = m[1]!;
const line = safe.slice(0, m.index).split('\n').length;

// Find which class scope this property belongs to.
let classId: string | null = null;
for (const scope of classScopes) {
if (line >= scope.start && line <= scope.end) {
classId = scope.id;
break;
}
}
if (!classId) continue;

// Find the property node for this @State/@Prop/@Link property.
const propNode = ctx.getNodesInFile(file).find(
(n) => n.kind === 'property' && n.name === propName &&
n.startLine >= line && n.startLine <= line + 2
);
if (!propNode) continue;

const cm = classMethods.get(classId);
if (!cm) continue;

// For each method in this struct, check if it reads this.<propName>.
const refRe = new RegExp(
`this\\.${propName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`
);
for (const method of cm.methods) {
const methodSrc = sliceLines(content, method.startLine, method.endLine);
if (!methodSrc || !refRe.test(methodSrc)) continue;

const key = `${method.id}>${propNode.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: method.id, target: propNode.id, kind: 'calls', line: method.startLine,
provenance: 'heuristic',
metadata: { synthesizedBy: 'arkui-state-dep', decorator: m[0]!.split(/\s+/)[0]!, property: propName },
});
}
}
}
return edges;
}

// --- Phase C: ArkUI event-chain edges -----------------------------------------

/**
* Phase C: ArkUI event-chain edges.
*
* ArkUI `build()` bodies declare event bindings like
* `Button('OK').onClick(() => { this.handleOK() })`. The `handleOK` method
* is reachable only at runtime through the framework event system — no static
* call edge exists. Bridge it: for each `.ets` file, scan the body of every
* `build()` method for `.onXxx(this.handler)` patterns and link
* `build() → handlerMethod`.
*/
export function arkuiEventChainEdges(ctx: ResolutionContext): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();

for (const file of ctx.getAllFiles()) {
if (!ARKUI_EXT_RE.test(file)) continue;
const content = ctx.readFile(file);
if (!content || !/\.on(?:Click|Change|Appear|DisAppear|Touch|Gesture|DragStart|DragEnd|DragMove|Drop|DragEnter|DragLeave)\s*\(/.test(content)) continue;

const nodes = ctx.getNodesInFile(file);
const buildMethods = nodes.filter(
(n) => n.kind === 'method' && n.name === 'build' && ARKUI_EXT_RE.test(n.filePath)
);
if (buildMethods.length === 0) continue;

for (const build of buildMethods) {
const src = sliceLines(content, build.startLine, build.endLine);
if (!src) continue;

ARKUI_HANDLER_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ARKUI_HANDLER_RE.exec(src))) {
const handlerName = m[1]!;
if (handlerName === 'build') continue;

// Resolve handler to a method in the same file.
const handler = nodes.find(
(n) => n.kind === 'method' && n.name === handlerName
);
if (!handler || handler.id === build.id) continue;

const key = `${build.id}>${handler.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: build.id, target: handler.id, kind: 'calls', line: build.startLine,
provenance: 'heuristic',
metadata: { synthesizedBy: 'arkui-event-chain', event: m[0]!.match(/\.on(\w+)/)?.[1] ?? '', handler: handlerName },
});
}
}
}
return edges;
}

/**
* Synthesize dispatcher→callback edges (field observers + EventEmitters +
* React re-render + JSX children + Vue templates + RN event channel +
* Fabric native-impl + MyBatis Java↔XML + Gin middleware chain). Returns the
* count added. Never throws into indexing — callers wrap in try/catch.
* React re-render + JSX children + Vue templates + ArkUI phases + RN event
* channel + Fabric native-impl + MyBatis Java↔XML + Gin middleware chain).
* Returns the count added. Never throws into indexing — callers wrap in
* try/catch.
*/
export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
const fieldEdges = fieldChannelEdges(queries, ctx);
Expand All @@ -1204,6 +1411,9 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
const fabricNativeEdges = fabricNativeImplEdges(ctx);
const mybatisEdges = mybatisJavaXmlEdges(queries);
const ginEdges = ginMiddlewareChainEdges(queries, ctx);
const arkuiStateChainE = arkuiStateChainEdges(queries, ctx);
const arkuiStateE = arkuiStateDepEdges(queries, ctx);
const arkuiEventChainE = arkuiEventChainEdges(ctx);

const merged: Edge[] = [];
const seen = new Set<string>();
Expand All @@ -1222,6 +1432,9 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
...fabricNativeEdges,
...mybatisEdges,
...ginEdges,
...arkuiStateChainE,
...arkuiStateE,
...arkuiEventChainE,
]) {
const key = `${e.source}>${e.target}`;
if (seen.has(key)) continue;
Expand Down
Loading