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
1,489 changes: 1,489 additions & 0 deletions __tests__/arkui-framework.test.ts

Large diffs are not rendered by default.

86 changes: 74 additions & 12 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,52 @@ import { getGlyphs } from '../ui/glyphs';
import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from './node-version-check';
import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime-flags';
import { EXTRACTION_VERSION } from '../extraction/extraction-version';
import type { Edge } from '../types';

/**
* Return a compact edge annotation for heuristic/synthesized edges.
* Returns null for non-heuristic edges (tree-sitter direct calls).
*/
function synthEdgeCompact(edge: Edge): string | null {
if (edge.provenance !== 'heuristic') return null;
const m = edge.metadata as Record<string, unknown> | undefined;
const at = typeof m?.registeredAt === 'string' ? ` @${m.registeredAt}` : '';
const s = m?.synthesizedBy;
if (s === 'arkui-render') {
const widget = m?.widget ? `<${String(m.widget)}>` : 'child';
const extra = (m?.forEach ? ' in list' : '') + (m?.conditional ? ' cond' : '');
return `ArkUI render ${widget}${extra}`;
}
if (s === 'arkui-builder') {
return `ArkUI @Builder ${m?.builder ? String(m.builder) : ''}`;
}
if (s === 'arkui-event-chain') {
const ev = m?.event ? String(m.event) : 'Event';
return `ArkUI ${ev}`;
}
if (s === 'arkui-state-dep') {
const dec = m?.decorator ? String(m.decorator) : '@State';
return `ArkUI ${dec}`;
}
if (s === 'arkui-state-chain') {
const via = m?.via ? String(m.via) : 'method';
return `ArkUI state chain ${via}`;
}
if (s === 'jsx-render') {
const v = m?.via ? `<${String(m.via)}>` : 'child';
return `JSX render ${v}`;
}
if (s === 'react-render') return 'React re-render';
if (s === 'vue-handler') {
const ev = m?.event ? `@${String(m.event)}` : 'event';
return `Vue ${ev}`;
}
if (s === 'callback') return 'callback' + at;
if (s === 'event-emitter') return 'event-emitter' + at;
if (s === 'interface-impl') return 'interface->impl' + at;
if (s === 'closure-collection') return 'closure' + at;
return 'dynamic' + at;
}

// Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast.
async function loadCodeGraph(): Promise<typeof import('../index')> {
Expand Down Expand Up @@ -1224,15 +1270,16 @@ program
}

const seen = new Set<string>();
const allCallers: Array<{ name: string; kind: string; filePath: string; startLine?: number }> = [];
const allCallers: Array<{ name: string; kind: string; filePath: string; startLine?: number; synthesizedBy?: string; edgeCompact?: string }> = [];

for (const match of matches) {
const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`);
if (!exactMatch && matches.length > 1) continue;
for (const c of cg.getCallers(match.node.id)) {
if (!seen.has(c.node.id)) {
seen.add(c.node.id);
allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine });
const ec = synthEdgeCompact(c.edge);
allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine, synthesizedBy: c.edge.metadata?.synthesizedBy as string | undefined, edgeCompact: ec ?? undefined });
}
}
}
Expand All @@ -1242,24 +1289,31 @@ program
for (const c of cg.getCallers(matches[0].node.id)) {
if (!seen.has(c.node.id)) {
seen.add(c.node.id);
allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine });
const ec = synthEdgeCompact(c.edge);
allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine, synthesizedBy: c.edge.metadata?.synthesizedBy as string | undefined, edgeCompact: ec ?? undefined });
}
}
}

const limited = allCallers.slice(0, limit);

if (options.json) {
console.log(JSON.stringify({ symbol, callers: limited }, null, 2));
const callersJson = limited.map(c => ({
name: c.name, kind: c.kind, filePath: c.filePath, startLine: c.startLine,
...(c.synthesizedBy ? { synthesizedBy: c.synthesizedBy } : {}),
}));
console.log(JSON.stringify({ symbol, callers: callersJson }, null, 2));
} else if (limited.length === 0) {
info(`No callers found for "${symbol}"`);
} else {
console.log(chalk.bold(`\nCallers of "${symbol}" (${limited.length}):\n`));
for (const node of limited) {
const loc = node.startLine ? `:${node.startLine}` : '';
const edgeLabel = (node as any).edgeCompact ? chalk.yellow(` [${(node as any).edgeCompact}]`) : '';
console.log(
chalk.cyan(node.kind.padEnd(12)) +
chalk.white(node.name)
chalk.white(node.name) +
edgeLabel
);
console.log(chalk.dim(` ${node.filePath}${loc}`));
console.log();
Expand Down Expand Up @@ -1303,15 +1357,16 @@ program
}

const seen = new Set<string>();
const allCallees: Array<{ name: string; kind: string; filePath: string; startLine?: number }> = [];
const allCallees: Array<{ name: string; kind: string; filePath: string; startLine?: number; synthesizedBy?: string; edgeCompact?: string }> = [];

for (const match of matches) {
const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`);
if (!exactMatch && matches.length > 1) continue;
for (const c of cg.getCallees(match.node.id)) {
if (!seen.has(c.node.id)) {
seen.add(c.node.id);
allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine });
const ec = synthEdgeCompact(c.edge);
allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine, synthesizedBy: c.edge.metadata?.synthesizedBy as string | undefined, edgeCompact: ec ?? undefined });
}
}
}
Expand All @@ -1320,24 +1375,31 @@ program
for (const c of cg.getCallees(matches[0].node.id)) {
if (!seen.has(c.node.id)) {
seen.add(c.node.id);
allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine });
const ec = synthEdgeCompact(c.edge);
allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine, synthesizedBy: c.edge.metadata?.synthesizedBy as string | undefined, edgeCompact: ec ?? undefined });
}
}
}

const limited = allCallees.slice(0, limit);

if (options.json) {
console.log(JSON.stringify({ symbol, callees: limited }, null, 2));
const calleesJson = limited.map(c => ({
name: c.name, kind: c.kind, filePath: c.filePath, startLine: c.startLine,
...(c.synthesizedBy ? { synthesizedBy: c.synthesizedBy } : {}),
}));
console.log(JSON.stringify({ symbol, callees: calleesJson }, null, 2));
} else if (limited.length === 0) {
info(`No callees found for "${symbol}"`);
} else {
console.log(chalk.bold(`\nCallees of "${symbol}" (${limited.length}):\n`));
for (const node of limited) {
const loc = node.startLine ? `:${node.startLine}` : '';
const edgeLabel = (node as any).edgeCompact ? chalk.yellow(` [${(node as any).edgeCompact}]`) : '';
console.log(
chalk.cyan(node.kind.padEnd(12)) +
chalk.white(node.name)
chalk.white(node.name) +
edgeLabel
);
console.log(chalk.dim(` ${node.filePath}${loc}`));
console.log();
Expand Down Expand Up @@ -1591,7 +1653,7 @@ program
*/
program
.command('install')
.description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)')
.description('Install codegraph MCP server into one or more agents (Chrys, Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity, Kiro)')
.option('-t, --target <ids>', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt')
.option('-l, --location <where>', 'Install location: "global" or "local". Default: prompt')
.option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on')
Expand Down Expand Up @@ -1658,7 +1720,7 @@ program
*/
program
.command('uninstall')
.description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)')
.description('Remove codegraph from your agents (Chrys, Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity, Kiro)')
.option('-t, --target <ids>', 'Target agent(s): comma-separated ids, or "all". Default: all')
.option('-l, --location <where>', 'Uninstall location: "global" or "local". Default: prompt')
.option('-y, --yes', 'Non-interactive: defaults to --location=global --target=all')
Expand Down
16 changes: 14 additions & 2 deletions 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,18 @@ 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}`
: m.synthesizedBy === 'arkui-render'
? `renders <${String(m.widget || 'widget')}>` +
(m.forEach ? ' (in list)' : '') +
(m.conditional ? ' (conditional)' : '')
: m.synthesizedBy === 'arkui-builder'
? `@Builder ${m.builder ? `\`${String(m.builder)}\`` : 'method'}`
: `event ${m.event ? `\`${String(m.event)}\`` : ''}${at}`;
synthByPair.set(`${e.source}>${e.target}`, label);
}
Expand Down Expand Up @@ -555,7 +567,7 @@ export class ContextBuilder {
: ['file', 'module', 'class', 'struct', 'interface', 'trait', 'protocol',
'function', 'method', 'property', 'field', 'variable', 'constant',
'enum', 'enum_member', 'type_alias', 'namespace', 'export',
'route', 'component'] as NodeKind[];
'route', 'component', 'arkui_page'] as NodeKind[];
for (const term of searchTerms) {
const termResults = this.queries.searchNodes(term, {
limit: opts.searchLimit * 2,
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 @@ -205,7 +207,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
// `class Foo(...)` as an ERROR that swallows the whole class (#237); we
// vendor the upstream ABI-15 tree-sitter-c-sharp 0.23.5 wasm, which parses
// primary constructors natively.
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp')
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'arkts')
? path.join(__dirname, 'wasm', wasmFile)
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
const language = await WasmLanguage.load(wasmPath);
Expand Down Expand Up @@ -409,6 +411,7 @@ export function getLanguageDisplayName(language: Language): string {
lua: 'Lua',
luau: 'Luau',
objc: 'Objective-C',
arkts: 'ArkTS',
yaml: 'YAML',
twig: 'Twig',
xml: 'XML',
Expand Down
34 changes: 34 additions & 0 deletions src/extraction/languages/arkts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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'],

// Override methodTypes: exclude public_field_definition — ArkUI state
// properties (@State count: number = 0) are field declarations, not
// methods. Pure arrow-function class fields are uncommon in ArkTS;
// the ArkUI convention defines event handlers as proper methods.
methodTypes: ['method_definition'],

// Extract ArkUI state properties as 'property' nodes so the state-dep
// edge synthesis (Phase B + Phase E) can find and link them to handler
// methods that read this.<prop>.
propertyTypes: ['public_field_definition'],
};
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,
};
4 changes: 4 additions & 0 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,10 @@ export class TreeSitterExtractor {
// `record struct M(decimal Amount)` which the grammar nests here).
this.extractCsharpPrimaryCtorParamRefs(node, structNode.id);

// Extract decorators on the struct (e.g. ArkUI @Entry, @Component).
// Mirrors extractClass (line ~870) which already does this for classes.
this.extractDecoratorsFor(node, structNode.id);

// Push to stack for field extraction
this.nodeStack.push(structNode.id);
for (let i = 0; i < body.namedChildCount; i++) {
Expand Down
Binary file added src/extraction/wasm/tree-sitter-arkts.wasm
Binary file not shown.
23 changes: 22 additions & 1 deletion src/mcp/server-instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ typically one to a few calls; a grep/read exploration is dozens.
## Tool selection by intent

- **Almost any question — "how does X work", architecture, a bug, "what/where is X", or surveying an area** → \`codegraph_explore\` (PRIMARY — call FIRST; ONE capped call returns the verbatim source of the relevant symbols grouped by file; most often the ONLY call you need)
- **"How does X reach/become Y? / the flow / the path from X to Y"** → \`codegraph_explore\`, naming the symbols that span the flow (e.g. \`mutateElement renderScene\`) — it surfaces the call path among them, including dynamic-dispatch hops (callbacks, React re-render, JSX children) grep can't follow
- **"How does X reach/become Y? / the flow / the path from X to Y"** → \`codegraph_explore\`, naming the symbols that span the flow (e.g. \`mutateElement renderScene\`) — it surfaces the call path among them, including dynamic-dispatch hops (callbacks, React re-render, JSX children, ArkUI render/event/builder/state chains) grep can't follow
- **"What is the symbol named X?" (just its location)** → \`codegraph_search\`
- **"What calls this?" / "What does this call?" / "What would changing this break?"** → \`codegraph_callers\` / \`codegraph_callees\` / \`codegraph_impact\`
- **Reading a source FILE (any time you'd use the \`Read\` tool)** → \`codegraph_node\` with a \`file\` path and no \`symbol\`. It returns the file's **current source with line numbers — the same \`<n>\\t<line>\` shape \`Read\` gives you, safe to \`Edit\` from** — narrowable with \`offset\`/\`limit\` exactly like \`Read\`, PLUS a one-line note of which files depend on it. Same bytes as \`Read\`, faster (served from the index), with the blast radius attached. Use it **instead of \`Read\`** for indexed source files; fall back to \`Read\` only for what codegraph doesn't index (configs, docs). Pass \`symbolsOnly: true\` for just the file's structure.
Expand All @@ -60,6 +60,27 @@ typically one to a few calls; a grep/read exploration is dozens.
- **Refactor planning**: \`codegraph_search\` → \`codegraph_callers\` → \`codegraph_impact\`. The blast-radius answer comes from impact, not from walking callers manually.
- **Debugging a regression**: \`codegraph_callers\` of the suspected symbol; widen with \`codegraph_impact\` if an unexpected call appears.

## ArkUI / HarmonyOS

Codegraph synthesizes ArkUI component relationships that static analysis alone
misses. These edges carry \`[ArkUI …]\` labels in CLI output and flow through
\`codegraph_explore\`, \`codegraph_callers\`, \`codegraph_callees\`, and
\`codegraph_impact\` just like direct calls:

- **Component tree**: \`build()\` renders child components → \`[ArkUI render <Widget>]\`
- **Event binding**: \`.onClick(this.handler)\` creates an edge from \`build()\` → handler
- **State dependency**: a handler reading \`this.count\` links to the \`@State count\` property
- **State chain**: any sibling method in the struct links to \`build()\` (potential re-render)
- **@Builder**: \`this.myBuilder()\` in \`build()\` links to the \`@Builder\` method

### ArkUI project guidance

- **"What components does this struct render?"** → \`codegraph_callees\` on the struct — the \`[ArkUI render]\` edges show the component tree. One \`codegraph_explore\` with the struct + child names shows the full UI path.
- **"What happens when a user clicks this button?"** → \`codegraph_callers\` on the handler — the \`[ArkUI Click]\` edge traces back to \`build()\`'s \`.onClick()\`.
- **"What state drives this UI?"** → \`codegraph_callees\` on the handler → \`[ArkUI @State]\` edges show which reactive properties it reads.
- **"What would changing this @State property break?"** → \`codegraph_impact\` on the property — it follows the state-dep edges to handlers and the state-chain to \`build()\`.
- \`.ets\` files are indexed with the \`arkts\` grammar; structs, methods, and state properties are all first-class nodes.

## Anti-patterns

- **Trust codegraph's results — don't re-verify them with grep.** They come from a full AST parse; re-checking with grep is slower, less accurate, and wastes context.
Expand Down
Loading