From bdb824cfe4d063b7f5bda9dfdf595ad0cfc9e869 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Fri, 12 Jun 2026 04:53:17 +0100 Subject: [PATCH] feat(cli): add circular import cycle check (#2166) --- .../skills/gitnexus/gitnexus-guide/SKILL.md | 1 + gitnexus/src/cli/help-i18n.ts | 5 + gitnexus/src/cli/i18n/en.ts | 2 + gitnexus/src/cli/i18n/zh-CN.ts | 2 + gitnexus/src/cli/index.ts | 9 ++ gitnexus/src/cli/tool.ts | 42 ++++++- gitnexus/src/core/graph/import-cycles.ts | 110 ++++++++++++++++++ gitnexus/src/mcp/local/local-backend.ts | 41 +++++++ gitnexus/src/mcp/tools.ts | 24 ++++ gitnexus/test/unit/calltool-dispatch.test.ts | 38 ++++++ gitnexus/test/unit/import-cycles.test.ts | 68 +++++++++++ gitnexus/test/unit/tool-direct-cli.test.ts | 55 +++++++++ gitnexus/test/unit/tools.test.ts | 5 +- 13 files changed, 399 insertions(+), 3 deletions(-) create mode 100644 gitnexus/src/core/graph/import-cycles.ts create mode 100644 gitnexus/test/unit/import-cycles.test.ts diff --git a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md index cacc4e886..2a1e76b02 100644 --- a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md @@ -36,6 +36,7 @@ For any task involving code understanding, debugging, impact analysis, or refact | `context` | 360-degree symbol view — categorized refs, processes it participates in | | `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | | `detect_changes` | Git-diff impact — what do your current changes affect | +| `check` | Check graph invariants such as circular imports | | `rename` | Multi-file coordinated rename with confidence-tagged edits | | `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | | `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) | diff --git a/gitnexus/src/cli/help-i18n.ts b/gitnexus/src/cli/help-i18n.ts index 9b4c9a823..993f20bc4 100644 --- a/gitnexus/src/cli/help-i18n.ts +++ b/gitnexus/src/cli/help-i18n.ts @@ -30,6 +30,7 @@ const COMMAND_DESCRIPTION_KEYS = { impact: 'help.command.impact.description', cypher: 'help.command.cypher.description', 'detect-changes': 'help.command.detectChanges.description', + check: 'help.command.check.description', 'eval-server': 'help.command.evalServer.description', group: 'help.command.group.description', 'group create': 'help.command.group.create.description', @@ -121,6 +122,10 @@ const OPTION_DESCRIPTION_KEYS = { 'detect-changes|-b, --base-ref ': 'help.option.detectChanges.baseRef', 'detect-changes|-r, --repo ': 'help.option.repo.target', 'detect-changes|--branch ': 'help.option.branch', + 'check|--cycles': 'help.option.check.cycles', + 'check|--json': 'help.option.json', + 'check|-r, --repo ': 'help.option.repo.target', + 'check|--branch ': 'help.option.branch', 'eval-server|-p, --port ': 'help.option.port', 'eval-server|--host ': 'help.option.evalServer.host', 'eval-server|--idle-timeout ': 'help.option.evalServer.idleTimeout', diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 0be54ebf6..3f4a9e7be 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -143,6 +143,7 @@ export const en = { 'help.command.cypher.description': 'Execute raw Cypher query against the knowledge graph', 'help.command.detectChanges.description': 'Map git diff hunks to indexed symbols and affected execution flows', + 'help.command.check.description': 'Run structural checks against the indexed graph', 'help.command.evalServer.description': 'Start lightweight HTTP server for fast tool calls during evaluation', 'help.command.group.description': 'Manage repository groups for cross-index impact analysis', @@ -241,6 +242,7 @@ export const en = { 'help.option.impact.summaryOnly': 'Return counts and risk only, omit symbol list', 'help.option.detectChanges.scope': 'What to analyze: unstaged, staged, all, or compare', 'help.option.detectChanges.baseRef': 'Branch/commit for compare scope (e.g. main)', + 'help.option.check.cycles': 'Detect circular imports and fail when any are found', 'help.option.evalServer.host': 'Bind address (default: 127.0.0.1, use 0.0.0.0 to expose to all interfaces)', 'help.option.evalServer.idleTimeout': 'Auto-shutdown after N seconds idle (0 = disabled)', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 8b87ee567..9331dc4fc 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -139,6 +139,7 @@ export const zhCN = { 'help.command.impact.description': '影响面分析:修改符号会影响什么', 'help.command.cypher.description': '对知识图谱执行原始 Cypher 查询', 'help.command.detectChanges.description': '将 git diff hunk 映射到已索引符号和受影响执行流程', + 'help.command.check.description': '对已索引图谱运行结构检查', 'help.command.evalServer.description': '启动轻量 HTTP 服务器,用于评测期间的快速工具调用', 'help.command.group.description': '管理仓库组,用于跨索引影响分析', 'help.command.group.create.description': '使用模板 group.yaml 创建新仓库组', @@ -226,6 +227,7 @@ export const zhCN = { 'help.option.impact.summaryOnly': '仅返回计数和风险等级,省略符号列表', 'help.option.detectChanges.scope': '分析范围:unstaged、staged、all 或 compare', 'help.option.detectChanges.baseRef': 'compare 范围的分支/提交(例如 main)', + 'help.option.check.cycles': '检测循环导入,并在发现循环时失败', 'help.option.evalServer.host': '绑定地址(默认:127.0.0.1;用 0.0.0.0 暴露到所有网卡)', 'help.option.evalServer.idleTimeout': '空闲 N 秒后自动关闭(0 = 禁用)', 'help.option.group.create.force': '覆盖现有仓库组', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index ffe6a6483..cd0a2613d 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -281,6 +281,15 @@ program .option('--branch ', 'Scope to a specific branch index (multi-branch repos)') .action(createLbugLazyAction(() => import('./tool.js'), 'detectChangesCommand')); +program + .command('check') + .description('Run structural checks against the indexed graph') + .option('--cycles', 'Detect circular imports and fail when any are found') + .option('--json', 'Emit machine-readable JSON') + .option('-r, --repo ', 'Target repository') + .option('--branch ', 'Scope to a specific branch index (multi-branch repos)') + .action(createLbugLazyAction(() => import('./tool.js'), 'checkCommand')); + // ─── Eval Server (persistent daemon for SWE-bench) ───────────────── program diff --git a/gitnexus/src/cli/tool.ts b/gitnexus/src/cli/tool.ts index 267482382..b892f6e2c 100644 --- a/gitnexus/src/cli/tool.ts +++ b/gitnexus/src/cli/tool.ts @@ -1,7 +1,7 @@ /** * Direct CLI Tool Commands * - * Exposes GitNexus tools (query, context, impact, cypher) as direct CLI commands. + * Exposes GitNexus tools (query, context, impact, cypher, check) as direct CLI commands. * Bypasses MCP entirely — invokes LocalBackend directly for minimal overhead. * * Usage: @@ -226,3 +226,43 @@ export async function detectChangesCommand(options?: { }); output(formatDetectChangesResult(result)); } + +export async function checkCommand(options?: { + cycles?: boolean; + json?: boolean; + repo?: string; + branch?: string; +}): Promise { + if (!options?.cycles) { + process.stderr.write('Usage: gitnexus check --cycles [--json]\n'); + process.exitCode = 1; + return; + } + + try { + const backend = await getBackend(); + const result = await backend.callTool('check', { + cycles: true, + repo: options.repo, + branch: options.branch, + }); + if (result?.error) { + output(result); + process.exitCode = 1; + return; + } + if (options.json) { + output(result); + } else if (result.cycleCount === 0) { + output('No circular imports found.'); + } else { + output( + result.cycles.map((cycle: { files: string[] }) => cycle.files.join(' -> ')).join('\n'), + ); + } + if (result.cycleCount > 0) process.exitCode = 1; + } catch (error) { + output({ error: error instanceof Error ? error.message : String(error) }); + process.exitCode = 1; + } +} diff --git a/gitnexus/src/core/graph/import-cycles.ts b/gitnexus/src/core/graph/import-cycles.ts new file mode 100644 index 000000000..d5dc9eacd --- /dev/null +++ b/gitnexus/src/core/graph/import-cycles.ts @@ -0,0 +1,110 @@ +interface ImportEdge { + source: string; + target: string; +} + +function findCyclePath(component: string[], adjacency: Map): string[] { + const allowed = new Set(component); + const start = component[0]; + const parents = new Map([[start, null]]); + const queue = [start]; + + for (let index = 0; index < queue.length; index += 1) { + const node = queue[index]; + for (const next of adjacency.get(node) ?? []) { + if (!allowed.has(next)) continue; + if (next === start) { + const path: string[] = []; + let cursor: string | null = node; + while (cursor !== null) { + path.push(cursor); + cursor = parents.get(cursor) ?? null; + } + path.reverse(); + return [...path, start]; + } + if (parents.has(next)) continue; + parents.set(next, node); + queue.push(next); + } + } + + throw new Error('Invariant violation: no cycle found through SCC root.'); +} + +/** + * Return one deterministic concrete cycle for every cyclic strongly connected + * component in the file import graph. + */ +export function findImportCycles(edges: ImportEdge[]): string[][] { + const adjacency = new Map>(); + for (const { source, target } of edges) { + if (!source || !target) continue; + const targets = adjacency.get(source) ?? new Set(); + targets.add(target); + adjacency.set(source, targets); + if (!adjacency.has(target)) adjacency.set(target, new Set()); + } + + const sortedAdjacency = new Map( + [...adjacency].map(([node, targets]) => [node, [...targets].sort()] as const), + ); + const reverseAdjacency = new Map(); + for (const node of sortedAdjacency.keys()) reverseAdjacency.set(node, []); + for (const [source, targets] of sortedAdjacency) { + for (const target of targets) reverseAdjacency.get(target)!.push(source); + } + for (const sources of reverseAdjacency.values()) sources.sort(); + + const visited = new Set(); + const finishOrder: string[] = []; + const components: string[][] = []; + + for (const start of [...sortedAdjacency.keys()].sort()) { + if (visited.has(start)) continue; + visited.add(start); + const stack = [{ node: start, nextIndex: 0 }]; + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + const neighbors = sortedAdjacency.get(frame.node) ?? []; + if (frame.nextIndex < neighbors.length) { + const next = neighbors[frame.nextIndex++]; + if (!visited.has(next)) { + visited.add(next); + stack.push({ node: next, nextIndex: 0 }); + } + } else { + finishOrder.push(frame.node); + stack.pop(); + } + } + } + + visited.clear(); + for (let index = finishOrder.length - 1; index >= 0; index -= 1) { + const start = finishOrder[index]; + if (visited.has(start)) continue; + const component: string[] = []; + const stack = [start]; + visited.add(start); + while (stack.length > 0) { + const node = stack.pop()!; + component.push(node); + for (const next of reverseAdjacency.get(node) ?? []) { + if (visited.has(next)) continue; + visited.add(next); + stack.push(next); + } + } + component.sort(); + components.push(component); + } + + return components + .filter( + (component) => + component.length > 1 || (sortedAdjacency.get(component[0]) ?? []).includes(component[0]), + ) + .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) + .map((component) => findCyclePath(component, sortedAdjacency)); +} diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index f2d17b3bd..025f11bb2 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -55,6 +55,7 @@ import { PhaseTimer } from '../../core/search/phase-timer.js'; import { checkStalenessAsync, checkCwdMatch } from '../../core/git-staleness.js'; import { logger } from '../../core/logger.js'; import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT } from '../tools.js'; +import { findImportCycles } from '../../core/graph/import-cycles.js'; // AI context generation is CLI-only (gitnexus analyze) // import { generateAIContextFiles } from '../../cli/ai-context.js'; @@ -1246,6 +1247,8 @@ export class LocalBackend { return this.impact(repo, params); case 'detect_changes': return this.detectChanges(repo, params); + case 'check': + return this.check(repo, params); case 'rename': return this.rename(repo, params); // Legacy aliases for backwards compatibility @@ -1270,6 +1273,44 @@ export class LocalBackend { // ─── Tool Implementations ──────────────────────────────────────── + /** Check repository graph invariants that are suitable for CI gating. */ + private async check(repo: RepoHandle, params?: { cycles?: boolean }): Promise { + if (params?.cycles === false) { + return { error: 'No checks selected. Set "cycles" to true.' }; + } + await this.ensureInitialized(repo); + const rowLimit = 100_001; + const rows = await executeParameterized( + repo.lbugPath, + `MATCH (source:File)-[r:CodeRelation]->(target:File) + WHERE r.type = 'IMPORTS' + AND (r.reason IS NULL OR ( + r.reason <> 'swift-scope: implicit module visibility' + AND r.reason <> 'markdown-link' + )) + RETURN source.filePath AS source, target.filePath AS target + LIMIT ${rowLimit}`, + {}, + ); + if (rows.length === rowLimit) { + return { + error: `Import graph exceeds the ${rowLimit - 1} edge safety limit.`, + truncated: true, + }; + } + const cycles = findImportCycles( + rows.map((row: any) => ({ + source: String(row.source ?? row[0] ?? ''), + target: String(row.target ?? row[1] ?? ''), + })), + ); + return { + status: cycles.length === 0 ? 'clean' : 'cycles_found', + cycleCount: cycles.length, + cycles: cycles.map((files) => ({ files })), + }; + } + /** * Query tool — process-grouped search. * diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index f89d10fc4..065fa37b3 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -316,6 +316,29 @@ Returns: changed symbols, affected processes, and a risk summary.`, required: [], }, }, + { + name: 'check', + description: `Run read-only structural checks against the indexed graph. + +Currently detects directed cycles between File nodes connected by IMPORTS edges. +Returns deterministic cycle paths and a cycle count suitable for CI automation.`, + annotations: READ_ONLY_TOOL_ANNOTATIONS, + inputSchema: { + type: 'object', + properties: { + cycles: { + type: 'boolean', + description: 'Detect circular file imports (default: true).', + default: true, + }, + repo: { + type: 'string', + description: 'Repository name or path. Omit if only one repo is indexed.', + }, + }, + required: [], + }, + }, { name: 'rename', description: `Multi-file coordinated rename using the knowledge graph + text search. @@ -624,6 +647,7 @@ const BRANCH_SCOPED_TOOLS = new Set([ 'cypher', 'context', 'detect_changes', + 'check', 'impact', 'rename', 'route_map', diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index f8c427f72..f95b1e4b8 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -307,6 +307,44 @@ describe('LocalBackend.callTool', () => { expect(result).toHaveProperty('definitions'); }); + it('checks cycles using only non-synthetic import edges', async () => { + (executeParameterized as any).mockResolvedValue([ + { source: 'src/a.ts', target: 'src/b.ts' }, + { source: 'src/b.ts', target: 'src/a.ts' }, + ]); + + const result = await backend.callTool('check', { cycles: true }); + + expect(result).toEqual({ + status: 'cycles_found', + cycleCount: 1, + cycles: [{ files: ['src/a.ts', 'src/b.ts', 'src/a.ts'] }], + }); + const query = (executeParameterized as any).mock.calls.at(-1)[1] as string; + expect(query).toContain("r.reason <> 'swift-scope: implicit module visibility'"); + expect(query).toContain("r.reason <> 'markdown-link'"); + expect(query).toContain('LIMIT 100001'); + }); + + it('uses the advertised cycles default when check arguments are omitted', async () => { + (executeParameterized as any).mockResolvedValue([]); + + await expect(backend.callTool('check', undefined)).resolves.toEqual({ + status: 'clean', + cycleCount: 0, + cycles: [], + }); + }); + + it('fails closed when the import-edge safety limit is reached', async () => { + (executeParameterized as any).mockResolvedValue({ length: 100_001 }); + + await expect(backend.callTool('check', { cycles: true })).resolves.toEqual({ + error: 'Import graph exceeds the 100000 edge safety limit.', + truncated: true, + }); + }); + it('includes FTS-unavailable warning when ftsAvailable is false (#1403)', async () => { const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js'); vi.mocked(searchFTSFromLbug).mockResolvedValueOnce({ results: [], ftsAvailable: false }); diff --git a/gitnexus/test/unit/import-cycles.test.ts b/gitnexus/test/unit/import-cycles.test.ts new file mode 100644 index 000000000..e71135857 --- /dev/null +++ b/gitnexus/test/unit/import-cycles.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { findImportCycles } from '../../src/core/graph/import-cycles.js'; + +describe('findImportCycles', () => { + it('returns no cycles for an acyclic graph', () => { + expect( + findImportCycles([ + { source: 'src/a.ts', target: 'src/b.ts' }, + { source: 'src/b.ts', target: 'src/c.ts' }, + ]), + ).toEqual([]); + }); + + it('returns deterministic concrete paths for cyclic components', () => { + expect( + findImportCycles([ + { source: 'src/b.ts', target: 'src/a.ts' }, + { source: 'src/a.ts', target: 'src/b.ts' }, + { source: 'src/y.ts', target: 'src/z.ts' }, + { source: 'src/z.ts', target: 'src/y.ts' }, + ]), + ).toEqual([ + ['src/a.ts', 'src/b.ts', 'src/a.ts'], + ['src/y.ts', 'src/z.ts', 'src/y.ts'], + ]); + }); + + it('deduplicates edges and reports self-imports', () => { + expect( + findImportCycles([ + { source: 'src/a.ts', target: 'src/a.ts' }, + { source: 'src/a.ts', target: 'src/a.ts' }, + ]), + ).toEqual([['src/a.ts', 'src/a.ts']]); + }); + + it('returns the shortest deterministic path through the component root', () => { + expect( + findImportCycles([ + { source: 'src/a.ts', target: 'src/b.ts' }, + { source: 'src/b.ts', target: 'src/c.ts' }, + { source: 'src/c.ts', target: 'src/d.ts' }, + { source: 'src/d.ts', target: 'src/a.ts' }, + { source: 'src/a.ts', target: 'src/z.ts' }, + { source: 'src/z.ts', target: 'src/a.ts' }, + ]), + ).toEqual([['src/a.ts', 'src/z.ts', 'src/a.ts']]); + }); + + it('finds an edge-connected path when component sort order is not a path', () => { + expect( + findImportCycles([ + { source: 'src/a.ts', target: 'src/c.ts' }, + { source: 'src/c.ts', target: 'src/b.ts' }, + { source: 'src/b.ts', target: 'src/a.ts' }, + ]), + ).toEqual([['src/a.ts', 'src/c.ts', 'src/b.ts', 'src/a.ts']]); + }); + + it('handles deep import graphs without recursive traversal', () => { + const size = 20_000; + const edges = Array.from({ length: size - 1 }, (_, index) => ({ + source: `src/${index}.ts`, + target: `src/${index + 1}.ts`, + })); + expect(findImportCycles(edges)).toEqual([]); + }); +}); diff --git a/gitnexus/test/unit/tool-direct-cli.test.ts b/gitnexus/test/unit/tool-direct-cli.test.ts index 3c5bb3b58..62e5fa1ea 100644 --- a/gitnexus/test/unit/tool-direct-cli.test.ts +++ b/gitnexus/test/unit/tool-direct-cli.test.ts @@ -23,9 +23,64 @@ describe('direct CLI tool commands', () => { initMock.mockReset(); callToolMock.mockReset(); writeSyncMock.mockReset(); + process.exitCode = undefined; initMock.mockResolvedValue(true); }); + it('dispatches circular-import checks and fails CI when cycles exist', async () => { + callToolMock.mockResolvedValue({ + status: 'cycles_found', + cycleCount: 1, + cycles: [{ files: ['src/a.ts', 'src/b.ts', 'src/a.ts'] }], + }); + const { checkCommand } = await import('../../src/cli/tool.js'); + + await checkCommand({ cycles: true, repo: 'gitnexus' }); + + expect(callToolMock).toHaveBeenCalledWith('check', { + cycles: true, + repo: 'gitnexus', + }); + expect(writeSyncMock).toHaveBeenCalledWith( + 1, + expect.stringContaining('src/a.ts -> src/b.ts -> src/a.ts'), + ); + expect(process.exitCode).toBe(1); + }); + + it('emits JSON and succeeds for a clean import graph', async () => { + callToolMock.mockResolvedValue({ status: 'clean', cycleCount: 0, cycles: [] }); + const { checkCommand } = await import('../../src/cli/tool.js'); + + await checkCommand({ cycles: true, json: true }); + + expect(writeSyncMock).toHaveBeenCalledWith(1, expect.stringContaining('"status": "clean"')); + expect(process.exitCode).toBeUndefined(); + }); + + it('fails closed for backend error payloads in JSON mode', async () => { + callToolMock.mockResolvedValue({ error: 'Import graph exceeds the safety limit.' }); + const { checkCommand } = await import('../../src/cli/tool.js'); + + await checkCommand({ cycles: true, json: true }); + + expect(writeSyncMock).toHaveBeenCalledWith( + 1, + expect.stringContaining('Import graph exceeds the safety limit.'), + ); + expect(process.exitCode).toBe(1); + }); + + it('fails closed when the backend throws', async () => { + callToolMock.mockRejectedValue(new Error('unknown branch')); + const { checkCommand } = await import('../../src/cli/tool.js'); + + await checkCommand({ cycles: true }); + + expect(writeSyncMock).toHaveBeenCalledWith(1, expect.stringContaining('unknown branch')); + expect(process.exitCode).toBe(1); + }); + it('dispatches detect_changes with CLI-shaped arguments', async () => { callToolMock.mockResolvedValue({ summary: { diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 7e1543bca..40376961b 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -21,8 +21,8 @@ const MUTATING_TOOLS = new Set(['rename', 'group_sync']); const OPEN_WORLD_READ_ONLY_TOOLS = new Set(['query']); describe('GITNEXUS_TOOLS', () => { - it('exports all tools (7 base + 3 route/tool/shape + 1 api_impact + 2 group)', () => { - expect(GITNEXUS_TOOLS).toHaveLength(13); + it('exports all tools (8 base + 3 route/tool/shape + 1 api_impact + 2 group)', () => { + expect(GITNEXUS_TOOLS).toHaveLength(14); }); it('contains all expected tool names', () => { @@ -34,6 +34,7 @@ describe('GITNEXUS_TOOLS', () => { 'cypher', 'context', 'detect_changes', + 'check', 'rename', 'impact', 'api_impact',