gitnexus 1.6.13-rc.36 → 1.6.13-rc.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli/auto-sync.js +2 -0
- package/dist/cli/i18n/en.d.ts +1 -1
- package/dist/cli/i18n/en.js +1 -1
- package/dist/cli/i18n/resources.d.ts +1 -1
- package/dist/cli/i18n/zh-CN.js +1 -1
- package/dist/core/auto-sync/config.d.ts +6 -0
- package/dist/core/auto-sync/config.js +31 -6
- package/dist/core/auto-sync/index.d.ts +1 -1
- package/dist/core/auto-sync/index.js +1 -1
- package/dist/core/auto-sync/runner.d.ts +1 -1
- package/dist/core/auto-sync/runner.js +5 -6
- package/dist/core/gitnexus-rc-embeddings.d.ts +11 -0
- package/dist/core/gitnexus-rc-embeddings.js +51 -0
- package/dist/core/ingestion/cfg/visitors/kotlin.d.ts +4 -3
- package/dist/core/ingestion/cfg/visitors/python-harvest.d.ts +2 -2
- package/dist/core/ingestion/languages/dart/captures.js +17 -0
- package/dist/core/ingestion/languages/kotlin/captures.js +37 -0
- package/dist/core/ingestion/languages/python/captures.js +13 -0
- package/dist/core/ingestion/languages/ruby/captures.js +35 -0
- package/dist/core/ingestion/languages/swift/captures.js +11 -0
- package/dist/core/ingestion/utils/callable-flow-captures.d.ts +24 -0
- package/dist/core/ingestion/utils/callable-flow-captures.js +184 -20
- package/dist/mcp/local/local-backend.d.ts +6 -0
- package/dist/mcp/local/local-backend.js +37 -7
- package/dist/server/git-clone.d.ts +4 -5
- package/dist/server/git-clone.js +44 -11
- package/dist/storage/parse-cache.js +8 -1
- package/package.json +1 -1
- package/scripts/cross-platform-tests.ts +6 -0
package/README.md
CHANGED
|
@@ -367,7 +367,7 @@ projects:
|
|
|
367
367
|
- git@gitee.com:owner/repo.git
|
|
368
368
|
```
|
|
369
369
|
|
|
370
|
-
`sync_interval_minutes` must be an integer of at least `5`. `local_path` must be an absolute path without traversal; each remote is cloned below it as `host/namespace/repo`, preventing same-basename repositories from colliding. `remote_urls`
|
|
370
|
+
`sync_interval_minutes` must be an integer of at least `5`. `local_path` must be an absolute path without traversal; each remote is cloned below it as `host/namespace/repo`, preventing same-basename repositories from colliding. `remote_urls` may use SSH SCP form (`git@host:owner/repo.git`) or HTTPS (`https://host/owner/repo.git`) for github.com, gitlab.com, or gitee.com. The published CLI image includes `openssh-client` so SSH remotes can clone; mount keys and `known_hosts` yourself. An invalid `watch_config.yml` skips auto-sync immediately with the validation error. `repo_git_timeout` applies to each repo clone/pull and defaults to `10s`; a bare number such as `10` is interpreted as seconds, while `10000ms`, `10s`, and `1m` keep their explicit units. It must not exceed one hour or `sync_interval_minutes`, whichever is smaller — so a bare `600000` is rejected, because it means 600000 seconds rather than milliseconds. `analyze_timeout` applies to each isolated analysis worker and defaults to half of `sync_interval_minutes`, but it is independent of polling and may be longer, up to Node's timer limit (`2147483647ms`). A `5` minute poll with `analyze_timeout: 30m` is valid. Auto-sync analysis honors the cloned repo's `.gitnexusrc` embeddings settings; the CLI image still needs `GITNEXUS_EMBEDDING_URL` or a bind-mounted embedding stack because npm is stripped. A tick that arrives while the previous loop is active never overlaps it: ticks coalesce into one immediate follow-up run, which pulls and analyzes the newest commit. If the parent times out and leaves that worker running, the follow-up is deferred to the next interval so a leftover lock holder is not counted as a hard analyze failure. Timeout and `auto-sync stop` request safe cancellation; a worker already in native work exits after it returns to a JS-visible safe point. While waiting, auto-sync reports `cancelling` or `stopping` and keeps its ownership files so another auto-sync cannot take over. The parent waits up to 5 seconds for the worker to exit; after that it stops waiting, releases its ownership files, and leaves the worker to finish and exit on its own rather than killing it mid-write. `auto-sync stop` uses this same control path on macOS and Windows.
|
|
371
371
|
|
|
372
372
|
`pdg` is configured per project. `pdg: true` builds and maintains the full CFG, control-dependence, reaching-definition, and taint layers on both initial and incremental analyses. Auto-sync requests staged atomic incremental publication where the analyzer supports it: the old graph remains available to readers until the replacement succeeds, and analysis errors are recorded while the old graph remains intact. Unsupported paths retain the analyzer's existing in-place behavior. Untouched configs that omit `pdg` preserve the existing index mode and cannot silently strip PDG data. Do not paste `pdg: false` from this example onto an existing watch file unless you intend to drop PDG. An explicit `pdg: false` disables PDG and emits a warning before a successful rebuild removes those layers. `overwrite_local_changes` defaults to `false`; a dirty local clone is skipped with an error log, while `true` allows branch fallback to replace local changes and additionally discards untracked files and directories in the clone after checkout — ignored paths, including GitNexus's own `.gitnexus/` storage, are preserved. `max_concurrency` defaults to `1` and is capped at runtime by `floor(availableMemoryGB / 2)` with a minimum of `1`; the effective value is printed at the start of each loop. Each analysis worker's heap cap is the machine-wide cap divided by the number of repositories analyzed in parallel, so concurrent workers share one memory budget instead of each claiming the whole machine. `analyze_failure_threshold` defaults to `3`, must be at least `2`, and pauses repeated failures only for the same repo branch, commit, and requested PDG mode; a new commit, a PDG mode change, or `gitnexus auto-sync reset` clears the block and allows analysis again. Repositories are registered and added to groups by their full remote identity (`host/namespace/repo`), so repositories with the same basename remain distinct. Use `branches` to try branches in order; legacy `branch` remains supported, but the two fields cannot be set together. If all branches are unavailable or time out, watch logs an error, records the repo status, and skips that repo for the loop. Leave `group_name` empty or omit it to skip group add/sync for that project; otherwise create the group first with `gitnexus group create <name>`. `$GITNEXUS_HOME/watch/project_commit_info.txt` is for inspection only; GitNexus stores machine state separately in `$GITNEXUS_HOME/watch/auto-sync-state.json`.
|
|
373
373
|
|
package/dist/cli/auto-sync.js
CHANGED
|
@@ -106,6 +106,8 @@ function defaultSyncConfig(localPath) {
|
|
|
106
106
|
' overwrite_local_changes: false',
|
|
107
107
|
' remote_urls:',
|
|
108
108
|
' - git@github.com:owner/repo.git',
|
|
109
|
+
' # HTTPS remotes on github.com, gitlab.com, or gitee.com are also allowed',
|
|
110
|
+
' # - https://github.com/owner/public-repo.git',
|
|
109
111
|
'',
|
|
110
112
|
].join('\n');
|
|
111
113
|
}
|
package/dist/cli/i18n/en.d.ts
CHANGED
|
@@ -170,7 +170,7 @@ export declare const en: {
|
|
|
170
170
|
readonly 'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex, Factory Droid';
|
|
171
171
|
readonly 'help.command.uninstall.description': 'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors';
|
|
172
172
|
readonly 'help.command.autoSync.description': 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml';
|
|
173
|
-
readonly 'help.autoSync.details': '\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs:
|
|
173
|
+
readonly 'help.autoSync.details': '\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: SSH or HTTPS URLs on github.com, gitlab.com, and gitee.com are allowed. Invalid watch_config.yml skips auto-sync immediately.\nRuns once immediately, then repeats on sync_interval_minutes.';
|
|
174
174
|
readonly 'help.command.watch.description': 'Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes';
|
|
175
175
|
readonly 'help.watch.details': '\n`gitnexus watch` does not start a watcher.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n';
|
|
176
176
|
readonly 'error.watch.ambiguous': '`gitnexus watch` is ambiguous.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n';
|
package/dist/cli/i18n/en.js
CHANGED
|
@@ -172,7 +172,7 @@ export const en = {
|
|
|
172
172
|
'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex, Factory Droid',
|
|
173
173
|
'help.command.uninstall.description': 'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
|
|
174
174
|
'help.command.autoSync.description': 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml',
|
|
175
|
-
'help.autoSync.details': '\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs:
|
|
175
|
+
'help.autoSync.details': '\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: SSH or HTTPS URLs on github.com, gitlab.com, and gitee.com are allowed. Invalid watch_config.yml skips auto-sync immediately.\nRuns once immediately, then repeats on sync_interval_minutes.',
|
|
176
176
|
'help.command.watch.description': 'Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes',
|
|
177
177
|
'help.watch.details': '\n`gitnexus watch` does not start a watcher.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n',
|
|
178
178
|
'error.watch.ambiguous': '`gitnexus watch` is ambiguous.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n',
|
|
@@ -171,7 +171,7 @@ export declare const cliResources: {
|
|
|
171
171
|
readonly 'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex, Factory Droid';
|
|
172
172
|
readonly 'help.command.uninstall.description': 'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors';
|
|
173
173
|
readonly 'help.command.autoSync.description': 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml';
|
|
174
|
-
readonly 'help.autoSync.details': '\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs:
|
|
174
|
+
readonly 'help.autoSync.details': '\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: SSH or HTTPS URLs on github.com, gitlab.com, and gitee.com are allowed. Invalid watch_config.yml skips auto-sync immediately.\nRuns once immediately, then repeats on sync_interval_minutes.';
|
|
175
175
|
readonly 'help.command.watch.description': 'Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes';
|
|
176
176
|
readonly 'help.watch.details': '\n`gitnexus watch` does not start a watcher.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n';
|
|
177
177
|
readonly 'error.watch.ambiguous': '`gitnexus watch` is ambiguous.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n';
|
package/dist/cli/i18n/zh-CN.js
CHANGED
|
@@ -170,7 +170,7 @@ export const zhCN = {
|
|
|
170
170
|
'help.command.setup.description': '一次性设置:为 Cursor、Claude Code、Antigravity、OpenCode、CodeBuddy、Qoder、Codex、Factory Droid 配置 MCP',
|
|
171
171
|
'help.command.uninstall.description': '撤销 `setup`:从所有检测到的编辑器中移除 GitNexus 的 MCP 配置、技能和钩子',
|
|
172
172
|
'help.command.autoSync.description': '控制基于 GITNEXUS_HOME/watch_config.yml 的定时 clone/pull 和分析',
|
|
173
|
-
'help.autoSync.details': '\n操作:init、start(默认)、restart、stop、status、reset\n配置:GITNEXUS_HOME/watch_config.yml\n运行时文件:GITNEXUS_HOME/watch/watch.pid、watch.mutex、watch.owner.json、watch.status.json、auto-sync-state.json\n恢复:已验证 owner 退出的 mutex 会自动回收;无效或旧版 mutex 会安全拒绝,确认没有 watch 进程运行后再手动删除。\n写入:GITNEXUS_HOME/watch/project_commit_info.txt\n
|
|
173
|
+
'help.autoSync.details': '\n操作:init、start(默认)、restart、stop、status、reset\n配置:GITNEXUS_HOME/watch_config.yml\n运行时文件:GITNEXUS_HOME/watch/watch.pid、watch.mutex、watch.owner.json、watch.status.json、auto-sync-state.json\n恢复:已验证 owner 退出的 mutex 会自动回收;无效或旧版 mutex 会安全拒绝,确认没有 watch 进程运行后再手动删除。\n写入:GITNEXUS_HOME/watch/project_commit_info.txt\n远程地址:允许 github.com、gitlab.com 和 gitee.com 上的 SSH 或 HTTPS 地址。无效的 watch_config.yml 会立即跳过 auto-sync。\n启动后立即运行一次,之后按 sync_interval_minutes 重复。',
|
|
174
174
|
'help.command.watch.description': '含义不明确:本地文件请用 `analyze --watch`,定时远程同步请用 `auto-sync`',
|
|
175
175
|
'help.watch.details': '\n`gitnexus watch` 不会启动监视器。\n 本地工作区增量索引:gitnexus analyze --watch\n 定时远程 clone/pull 并分析:gitnexus auto-sync start\n',
|
|
176
176
|
'error.watch.ambiguous': '`gitnexus watch` 含义不明确。\n 本地工作区增量索引:gitnexus analyze --watch\n 定时远程 clone/pull 并分析:gitnexus auto-sync start\n',
|
|
@@ -30,6 +30,12 @@ export declare function getAutoSyncConfigPath(gitnexusDir?: string): string;
|
|
|
30
30
|
export declare function parseBranchCandidates(branchValue: unknown): string[];
|
|
31
31
|
export declare function loadAutoSyncConfig(configPath?: string): Promise<AutoSyncConfigLoadResult>;
|
|
32
32
|
export declare function parseAutoSyncConfig(content: string, configPath: string): AutoSyncConfig;
|
|
33
|
+
export declare function parseAutoSyncRemoteIdentity(remoteUrl: string): {
|
|
34
|
+
host: string;
|
|
35
|
+
repoPath: string;
|
|
36
|
+
};
|
|
37
|
+
/** Canonical `host/owner/repo` key. Strips one trailing `.git`. Throws on an invalid remote. */
|
|
38
|
+
export declare function getAutoSyncRepoIdentity(remoteUrl: string): string;
|
|
33
39
|
export declare function validateAutoSyncRemoteUrl(remoteUrl: string): void;
|
|
34
40
|
export declare function validateAutoSyncBranchName(branch: string): void;
|
|
35
41
|
export declare function parseDurationMs(value: unknown): number;
|
|
@@ -237,17 +237,42 @@ export function parseAutoSyncConfig(content, configPath) {
|
|
|
237
237
|
projects,
|
|
238
238
|
};
|
|
239
239
|
}
|
|
240
|
-
export function
|
|
240
|
+
export function parseAutoSyncRemoteIdentity(remoteUrl) {
|
|
241
241
|
const trimmed = remoteUrl.trim();
|
|
242
242
|
if (trimmed.includes('?') || trimmed.includes('#')) {
|
|
243
243
|
throw new Error('must not include query strings or fragments');
|
|
244
244
|
}
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
245
|
+
const sshMatch = /^git@([^:\s/]+):([^\s]+)$/.exec(trimmed);
|
|
246
|
+
const httpsMatch = /^https:\/\/([^/\s]+)\/([^\s]+)$/.exec(trimmed);
|
|
247
|
+
let host;
|
|
248
|
+
let repoPath;
|
|
249
|
+
if (sshMatch) {
|
|
250
|
+
host = sshMatch[1];
|
|
251
|
+
repoPath = sshMatch[2];
|
|
252
|
+
}
|
|
253
|
+
else if (httpsMatch) {
|
|
254
|
+
host = httpsMatch[1];
|
|
255
|
+
repoPath = httpsMatch[2];
|
|
256
|
+
if (host.includes('@') || host.includes(':')) {
|
|
257
|
+
throw new Error('must not include userinfo or a port');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
throw new Error('must use an SSH or HTTPS URL on github.com, gitlab.com, or gitee.com');
|
|
248
262
|
}
|
|
249
|
-
|
|
250
|
-
|
|
263
|
+
host = host.toLowerCase();
|
|
264
|
+
assertAutoSyncRemotePath(host, repoPath);
|
|
265
|
+
return { host, repoPath };
|
|
266
|
+
}
|
|
267
|
+
/** Canonical `host/owner/repo` key. Strips one trailing `.git`. Throws on an invalid remote. */
|
|
268
|
+
export function getAutoSyncRepoIdentity(remoteUrl) {
|
|
269
|
+
const { host, repoPath } = parseAutoSyncRemoteIdentity(remoteUrl);
|
|
270
|
+
return `${host}/${repoPath.replace(/\.git$/i, '')}`;
|
|
271
|
+
}
|
|
272
|
+
export function validateAutoSyncRemoteUrl(remoteUrl) {
|
|
273
|
+
parseAutoSyncRemoteIdentity(remoteUrl);
|
|
274
|
+
}
|
|
275
|
+
function assertAutoSyncRemotePath(host, repoPath) {
|
|
251
276
|
if (!ALLOWED_REMOTE_HOSTS.has(host)) {
|
|
252
277
|
throw new Error('host must be one of github.com, gitlab.com, or gitee.com');
|
|
253
278
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { AUTO_SYNC_CONFIG_FILE, getAutoSyncConfigPath, loadAutoSyncConfig, parseAutoSyncConfig, parseBranchCandidates, parseDurationMs, validateAutoSyncBranchName, validateAutoSyncRemoteUrl, type AutoSyncConfig, type AutoSyncConfigLoadResult, type AutoSyncProjectConfig, } from './config.js';
|
|
1
|
+
export { AUTO_SYNC_CONFIG_FILE, getAutoSyncConfigPath, loadAutoSyncConfig, parseAutoSyncConfig, parseBranchCandidates, parseDurationMs, validateAutoSyncBranchName, validateAutoSyncRemoteUrl, parseAutoSyncRemoteIdentity, type AutoSyncConfig, type AutoSyncConfigLoadResult, type AutoSyncProjectConfig, } from './config.js';
|
|
2
2
|
export { buildStateKey, getAutoSyncMutexPath, getAutoSyncWatchDir, getAutoSyncStatePath, getProjectCommitInfoPath, loadAutoSyncState, resetAutoSyncState, saveAutoSyncState, shouldAnalyzeCommit, writeProjectCommitInfo, type AutoSyncAnalyzeStatus, type AutoSyncCommitState, type AutoSyncCommitStateEntry, type ProjectCommitInfoEntry, } from './state.js';
|
|
3
3
|
export { extractRepoNameFromRemoteUrl } from './repo.js';
|
|
4
4
|
export { normalizeConfiguredCloneRoot, quarantineAutoSyncPartial, resolveConfiguredCloneRoot, type AutoSyncCloneRoot, } from './path-security.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { AUTO_SYNC_CONFIG_FILE, getAutoSyncConfigPath, loadAutoSyncConfig, parseAutoSyncConfig, parseBranchCandidates, parseDurationMs, validateAutoSyncBranchName, validateAutoSyncRemoteUrl, } from './config.js';
|
|
1
|
+
export { AUTO_SYNC_CONFIG_FILE, getAutoSyncConfigPath, loadAutoSyncConfig, parseAutoSyncConfig, parseBranchCandidates, parseDurationMs, validateAutoSyncBranchName, validateAutoSyncRemoteUrl, parseAutoSyncRemoteIdentity, } from './config.js';
|
|
2
2
|
export { buildStateKey, getAutoSyncMutexPath, getAutoSyncWatchDir, getAutoSyncStatePath, getProjectCommitInfoPath, loadAutoSyncState, resetAutoSyncState, saveAutoSyncState, shouldAnalyzeCommit, writeProjectCommitInfo, } from './state.js';
|
|
3
3
|
export { extractRepoNameFromRemoteUrl } from './repo.js';
|
|
4
4
|
export { normalizeConfiguredCloneRoot, quarantineAutoSyncPartial, resolveConfiguredCloneRoot, } from './path-security.js';
|
|
@@ -42,6 +42,6 @@ export declare function runAutoSyncOnce(config: AutoSyncConfig, options?: {
|
|
|
42
42
|
}): Promise<AutoSyncRunResult>;
|
|
43
43
|
export declare function getConfiguredRepoPath(project: Pick<AutoSyncProjectConfig, 'localPath'>, repoName: string, remoteUrl?: string): string;
|
|
44
44
|
export declare function addRepoToGroup(project: Pick<AutoSyncProjectConfig, 'groupName'>, groupPath: string, registryName?: string): Promise<boolean>;
|
|
45
|
-
export
|
|
45
|
+
export { getAutoSyncRepoIdentity } from './config.js';
|
|
46
46
|
export declare function syncGroupByName(groupName: string): Promise<void>;
|
|
47
47
|
export declare function resolveActualConcurrency(configured: number, availableMemoryGB: number): number;
|
|
@@ -5,11 +5,12 @@ import { loadGroupConfig } from '../group/config-parser.js';
|
|
|
5
5
|
import { getDefaultGitnexusDir, getGroupDir } from '../group/storage.js';
|
|
6
6
|
import { syncGroup } from '../group/sync.js';
|
|
7
7
|
import { getStoragePaths, loadMeta, registerRepo, resolveBranchPlacement, } from '../../storage/repo-manager.js';
|
|
8
|
+
import { embeddingsFromGitnexusRc } from '../gitnexus-rc-embeddings.js';
|
|
9
|
+
import { getAutoSyncRepoIdentity } from './config.js';
|
|
8
10
|
import { extractRepoNameFromRemoteUrl } from './repo.js';
|
|
9
11
|
import { cloneOrPull, runGit } from '../../server/git-clone.js';
|
|
10
12
|
import { resolveConfiguredCloneRoot } from './path-security.js';
|
|
11
13
|
import { buildStateKey, loadAutoSyncState, saveAutoSyncState, shouldAnalyzeCommit, writeProjectCommitInfo, } from './state.js';
|
|
12
|
-
import { validateAutoSyncRemoteUrl } from './config.js';
|
|
13
14
|
import { AutoSyncAnalysisError, runAutoSyncAnalysis, } from './analysis-worker-launch.js';
|
|
14
15
|
const _require = createRequire(import.meta.url);
|
|
15
16
|
const yaml = _require('js-yaml');
|
|
@@ -149,6 +150,7 @@ export async function runAutoSyncOnce(config, options = {}) {
|
|
|
149
150
|
previousStatus: previous?.lastAnalyzeStatus,
|
|
150
151
|
})) {
|
|
151
152
|
try {
|
|
153
|
+
const rcEmbeddings = await embeddingsFromGitnexusRc(targetDir);
|
|
152
154
|
const analysis = await deps.runAnalysis(targetDir, {
|
|
153
155
|
branch: currentBranch,
|
|
154
156
|
skipAgentsMd: true,
|
|
@@ -157,6 +159,7 @@ export async function runAutoSyncOnce(config, options = {}) {
|
|
|
157
159
|
? { preserveExistingPdg: true }
|
|
158
160
|
: { pdg: requestedPdg }),
|
|
159
161
|
atomicIncremental: true,
|
|
162
|
+
...rcEmbeddings,
|
|
160
163
|
}, config.analyzeTimeoutMs, options.signal, options.onAnalysisCancellationRequested, analysisParallelism);
|
|
161
164
|
throwIfAborted(options.signal);
|
|
162
165
|
stats = analysis.stats;
|
|
@@ -386,11 +389,7 @@ export async function addRepoToGroup(project, groupPath, registryName = groupPat
|
|
|
386
389
|
await writeGroupConfigAtomic(path.join(groupDir, 'group.yaml'), config);
|
|
387
390
|
return true;
|
|
388
391
|
}
|
|
389
|
-
export
|
|
390
|
-
validateAutoSyncRemoteUrl(remoteUrl);
|
|
391
|
-
const [, host, remotePath] = /^git@([^:\s/]+):([^\s]+)$/.exec(remoteUrl.trim());
|
|
392
|
-
return `${host.toLowerCase()}/${remotePath.replace(/\.git$/i, '')}`;
|
|
393
|
-
}
|
|
392
|
+
export { getAutoSyncRepoIdentity } from './config.js';
|
|
394
393
|
export async function syncGroupByName(groupName) {
|
|
395
394
|
const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName);
|
|
396
395
|
const config = await loadGroupConfig(groupDir);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-sync reads embeddings settings from a cloned repo's `.gitnexusrc`
|
|
3
|
+
* without importing CLI modules (`src/core` must not import `src/cli`).
|
|
4
|
+
* Invalid JSON fails the analyze for that repo the same way CLI analyze fails closed.
|
|
5
|
+
*/
|
|
6
|
+
import type { AnalyzeOptions } from './run-analyze.js';
|
|
7
|
+
export declare const GITNEXUS_RC_FILENAME = ".gitnexusrc";
|
|
8
|
+
export declare class AutoSyncGitnexusRcError extends Error {
|
|
9
|
+
constructor(message: string);
|
|
10
|
+
}
|
|
11
|
+
export declare function embeddingsFromGitnexusRc(repoRoot: string): Promise<Pick<AnalyzeOptions, 'embeddings' | 'embeddingsNodeLimit'>>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readRepoControlFile } from '../config/repo-control-file.js';
|
|
2
|
+
export const GITNEXUS_RC_FILENAME = '.gitnexusrc';
|
|
3
|
+
export class AutoSyncGitnexusRcError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'AutoSyncGitnexusRcError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export async function embeddingsFromGitnexusRc(repoRoot) {
|
|
10
|
+
let raw;
|
|
11
|
+
try {
|
|
12
|
+
raw = await readRepoControlFile(repoRoot, GITNEXUS_RC_FILENAME);
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
throw new AutoSyncGitnexusRcError(`Could not read ${GITNEXUS_RC_FILENAME}: ${err.message}`);
|
|
16
|
+
}
|
|
17
|
+
if (raw === null)
|
|
18
|
+
return {};
|
|
19
|
+
if (raw.charCodeAt(0) === 0xfeff)
|
|
20
|
+
raw = raw.slice(1);
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = JSON.parse(raw);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new AutoSyncGitnexusRcError(`${GITNEXUS_RC_FILENAME} is not valid JSON`);
|
|
27
|
+
}
|
|
28
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
29
|
+
throw new AutoSyncGitnexusRcError(`${GITNEXUS_RC_FILENAME} must contain a JSON object.`);
|
|
30
|
+
}
|
|
31
|
+
const obj = parsed;
|
|
32
|
+
let embeddingsVal = obj.embeddings;
|
|
33
|
+
if (obj.analyze && typeof obj.analyze === 'object' && !Array.isArray(obj.analyze)) {
|
|
34
|
+
const nested = obj.analyze;
|
|
35
|
+
if (Object.prototype.hasOwnProperty.call(nested, 'embeddings')) {
|
|
36
|
+
embeddingsVal = nested.embeddings;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (embeddingsVal === undefined)
|
|
40
|
+
return {};
|
|
41
|
+
if (typeof embeddingsVal === 'boolean') {
|
|
42
|
+
return { embeddings: embeddingsVal };
|
|
43
|
+
}
|
|
44
|
+
if (typeof embeddingsVal === 'number') {
|
|
45
|
+
if (!Number.isInteger(embeddingsVal) || embeddingsVal < 0) {
|
|
46
|
+
throw new AutoSyncGitnexusRcError(`${GITNEXUS_RC_FILENAME} embeddings must be true/false or a non-negative integer`);
|
|
47
|
+
}
|
|
48
|
+
return { embeddings: true, embeddingsNodeLimit: embeddingsVal };
|
|
49
|
+
}
|
|
50
|
+
throw new AutoSyncGitnexusRcError(`${GITNEXUS_RC_FILENAME} embeddings must be a boolean or a non-negative integer`);
|
|
51
|
+
}
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Kotlin's tree-sitter grammar (vendored, NOT an npm package — loaded via
|
|
5
5
|
* `requireVendoredGrammar('tree-sitter-kotlin')`, exactly like tree-sitter-swift)
|
|
6
|
-
* is field-less for control flow
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* is mostly field-less for control flow (`when` / loops / `try` / `elvis_expression`
|
|
7
|
+
* expose no fields), so this visitor navigates purely by child TYPE and position.
|
|
8
|
+
* `if_expression` is the exception: it DOES field `condition` / `consequence` /
|
|
9
|
+
* `alternative` (#3354 — callable-flow capture reads them); position still works.
|
|
9
10
|
* Every node-type literal below was grammar-validated against the vendored
|
|
10
11
|
* tree-sitter-kotlin via the introspection probe before use (mandatory pre-step —
|
|
11
12
|
* the grammar-literal CI gate maps `kotlin.ts → Kotlin` and fails on a wrong
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
* - reads: `attribute` (fields `object`/`attribute`), `subscript` (fields
|
|
47
47
|
* `value`/`subscript`), `call` (fields `function`/`arguments`),
|
|
48
48
|
* `boolean_operator` (fields `left`/`operator`/`right`),
|
|
49
|
-
* `conditional_expression` (ternary: consequent
|
|
50
|
-
*
|
|
49
|
+
* `conditional_expression` (ternary, FIELDLESS: consequent, condition,
|
|
50
|
+
* alternative as positional children), `parenthesized_expression`.
|
|
51
51
|
*
|
|
52
52
|
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the TS / Go harvesters):
|
|
53
53
|
* the CFG walk is NOT source-order, so resolving names against a scope stack
|
|
@@ -109,6 +109,23 @@ const DART_CALLABLE_CAPTURE_OPTIONS = {
|
|
|
109
109
|
extractCallCallee: (node) => dartCallableCallee(node) ?? undefined,
|
|
110
110
|
callSiteNode: (node) => dartCallableCallee(node) ?? undefined,
|
|
111
111
|
callableProtocolMethods: new Set(['call']),
|
|
112
|
+
// tree-sitter-dart spells `a ?? b` as `if_null_expression(first:, second:)`
|
|
113
|
+
// and `c ? a : b` as a `conditional_expression` with `consequence` /
|
|
114
|
+
// `alternative` but NO `condition` field, so neither matches the shared
|
|
115
|
+
// field-based branch rule and only the last operand flowed (#3354).
|
|
116
|
+
valueAlternatives: (node) => {
|
|
117
|
+
if (node.type === 'if_null_expression') {
|
|
118
|
+
const first = node.childForFieldName('first');
|
|
119
|
+
const second = node.childForFieldName('second');
|
|
120
|
+
return first !== null && second !== null ? [first, second] : undefined;
|
|
121
|
+
}
|
|
122
|
+
if (node.type === 'conditional_expression') {
|
|
123
|
+
const consequence = node.childForFieldName('consequence');
|
|
124
|
+
const alternative = node.childForFieldName('alternative');
|
|
125
|
+
return consequence !== null && alternative !== null ? [consequence, alternative] : undefined;
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
},
|
|
112
129
|
};
|
|
113
130
|
function dartLexicalFunctionOwner(input) {
|
|
114
131
|
let node = input;
|
|
@@ -57,7 +57,44 @@ const KOTLIN_CALLABLE_CAPTURE_OPTIONS = {
|
|
|
57
57
|
return destination === undefined || source === undefined ? undefined : { destination, source };
|
|
58
58
|
},
|
|
59
59
|
normalizeQualifiedName: (raw) => raw.replaceAll('::', '.'),
|
|
60
|
+
valueAlternatives: (node) => kotlinValueAlternatives(node),
|
|
60
61
|
};
|
|
62
|
+
const isKotlinComment = (node) => node.type === 'line_comment' || node.type === 'multiline_comment';
|
|
63
|
+
const namedNonComments = (node) => node.namedChildren.filter((child) => child !== null && !isKotlinComment(child));
|
|
64
|
+
/**
|
|
65
|
+
* Branches of a Kotlin value-selecting expression (#3354). `a ?: b` is a
|
|
66
|
+
* FIELDLESS `elvis_expression` (positional operands), so the shared
|
|
67
|
+
* field-based rule never saw it and only the last operand flowed. An
|
|
68
|
+
* `if_expression` fields its branches as `control_structure_body` wrappers:
|
|
69
|
+
* a bare branch (`if (c) ::f`) holds the expression directly, a braced one
|
|
70
|
+
* (`if (c) { ::f }`) nests it one level deeper in a `statements` node. A
|
|
71
|
+
* branch holding exactly one expression is that expression's value; an
|
|
72
|
+
* empty or multi-statement block, or an `if` without `else`, keeps the
|
|
73
|
+
* whole `if` one opaque source.
|
|
74
|
+
*/
|
|
75
|
+
function kotlinValueAlternatives(node) {
|
|
76
|
+
if (node.type === 'elvis_expression') {
|
|
77
|
+
const operands = namedNonComments(node);
|
|
78
|
+
return operands.length === 2 ? operands : undefined;
|
|
79
|
+
}
|
|
80
|
+
if (node.type !== 'if_expression')
|
|
81
|
+
return undefined;
|
|
82
|
+
const branches = [];
|
|
83
|
+
for (const field of ['consequence', 'alternative']) {
|
|
84
|
+
const body = node.childForFieldName(field);
|
|
85
|
+
if (body === null)
|
|
86
|
+
return [node];
|
|
87
|
+
let values = namedNonComments(body);
|
|
88
|
+
const [block] = values;
|
|
89
|
+
if (values.length === 1 && block?.type === 'statements')
|
|
90
|
+
values = namedNonComments(block);
|
|
91
|
+
const [value] = values;
|
|
92
|
+
if (values.length !== 1 || value === undefined)
|
|
93
|
+
return [node];
|
|
94
|
+
branches.push(value);
|
|
95
|
+
}
|
|
96
|
+
return branches;
|
|
97
|
+
}
|
|
61
98
|
export function emitKotlinScopeCaptures(sourceText, filePath, cachedTree) {
|
|
62
99
|
let tree = cachedTree;
|
|
63
100
|
if (tree === undefined) {
|
|
@@ -42,6 +42,19 @@ const PYTHON_CALLABLE_CAPTURE_OPTIONS = {
|
|
|
42
42
|
assignmentNodeTypes: new Set(['assignment', 'named_expression']),
|
|
43
43
|
identifierNodeTypes: new Set(['identifier']),
|
|
44
44
|
functionScopedValueBindings: true,
|
|
45
|
+
// `a if c else b` is a FIELDLESS `conditional_expression` (positional
|
|
46
|
+
// value, condition, value), so the shared condition/consequence/alternative
|
|
47
|
+
// rule never sees its branches and only the last operand flowed (#3354).
|
|
48
|
+
// `a or b` is a fielded `boolean_operator` the shared rule already handles.
|
|
49
|
+
valueAlternatives: (node) => {
|
|
50
|
+
if (node.type !== 'conditional_expression')
|
|
51
|
+
return undefined;
|
|
52
|
+
const named = node.namedChildren.filter((child) => child !== null && child.type !== 'comment');
|
|
53
|
+
const [value, , alternative] = named;
|
|
54
|
+
return named.length === 3 && value !== undefined && alternative !== undefined
|
|
55
|
+
? [value, alternative]
|
|
56
|
+
: undefined;
|
|
57
|
+
},
|
|
45
58
|
};
|
|
46
59
|
export function emitPythonScopeCaptures(sourceText, _filePath, cachedTree) {
|
|
47
60
|
// Skip the parse when the caller (the scope-resolution orchestrator's
|
|
@@ -41,6 +41,7 @@ const RUBY_CALLABLE_CAPTURE_OPTIONS = {
|
|
|
41
41
|
// Ruby (`action = process` stores process's RETURN value) — only explicit
|
|
42
42
|
// reference forms (method(:x), &:x, lambda/proc) reference the callable.
|
|
43
43
|
bareNamesAreCalls: true,
|
|
44
|
+
valueAlternatives: (node) => rubyValueAlternatives(node),
|
|
44
45
|
extractCallableReference: (node) => {
|
|
45
46
|
if (node.type !== 'call')
|
|
46
47
|
return undefined;
|
|
@@ -55,6 +56,40 @@ const RUBY_CALLABLE_CAPTURE_OPTIONS = {
|
|
|
55
56
|
return name.length === 0 ? undefined : { name, anchor: symbol };
|
|
56
57
|
},
|
|
57
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* Branches of a statement-bodied `if` / `unless` / `elsif` (#3354). They field
|
|
61
|
+
* `condition` / `consequence` / `alternative` like a ternary, but their
|
|
62
|
+
* branches are `then` / `else` STATEMENT LISTS, so the shared ternary rule
|
|
63
|
+
* would dig an identifier out of whichever statement it found (`g = h; 0`
|
|
64
|
+
* flowed `h`, although the branch evaluates to `0`). A branch holding one
|
|
65
|
+
* statement is that statement's value. A longer branch is skipped: it adds
|
|
66
|
+
* no alternative, while the other branches still flow. Every alternative
|
|
67
|
+
* emitted is a value the conditional really evaluates to, so skipping cannot
|
|
68
|
+
* add an edge. It only drops the skipped branch's own value. The `elsif`
|
|
69
|
+
* chain is walked here, so one multi-statement `elsif` no longer hides the
|
|
70
|
+
* branches after it. When no branch is a single statement, the whole
|
|
71
|
+
* conditional stays one opaque source. The `c ? a : b` ternary
|
|
72
|
+
* (`conditional`) is left to the shared rule.
|
|
73
|
+
*/
|
|
74
|
+
function rubyValueAlternatives(node) {
|
|
75
|
+
if (node.type !== 'if' && node.type !== 'unless' && node.type !== 'elsif')
|
|
76
|
+
return undefined;
|
|
77
|
+
const branches = [];
|
|
78
|
+
for (let link = node; link !== null;) {
|
|
79
|
+
const consequence = link.childForFieldName('consequence');
|
|
80
|
+
const alternative = link.childForFieldName('alternative');
|
|
81
|
+
link = alternative?.type === 'elsif' ? alternative : null;
|
|
82
|
+
for (const branch of [consequence, link === null ? alternative : null]) {
|
|
83
|
+
if (branch === null)
|
|
84
|
+
continue;
|
|
85
|
+
const statements = branch.namedChildren.filter((child) => child !== null && child.type !== 'comment');
|
|
86
|
+
const [statement] = statements;
|
|
87
|
+
if (statements.length === 1 && statement !== undefined)
|
|
88
|
+
branches.push(statement);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return branches.length > 0 ? branches : [node];
|
|
92
|
+
}
|
|
58
93
|
/**
|
|
59
94
|
* Build the full `.`-joined qualified owner name for a heritage/attr call by
|
|
60
95
|
* walking ALL enclosing class/module ancestors (not just the immediate one),
|
|
@@ -94,6 +94,17 @@ const SWIFT_CALLABLE_CAPTURE_OPTIONS = {
|
|
|
94
94
|
assignmentNodeTypes: new Set(['assignment']),
|
|
95
95
|
identifierNodeTypes: new Set(['simple_identifier', 'type_identifier']),
|
|
96
96
|
extractFunctionParameters: (node) => node.namedChildren.filter((child) => child !== null && child.type === 'parameter'),
|
|
97
|
+
// tree-sitter-swift fields `a ?? b` as `value` / `if_nil` and `c ? a : b` as
|
|
98
|
+
// `if_true` / `if_false`, neither of which the shared field-based branch
|
|
99
|
+
// rule knows, so only the last operand flowed (#3354).
|
|
100
|
+
valueAlternatives: (node) => {
|
|
101
|
+
const [first, second] = node.type === 'nil_coalescing_expression'
|
|
102
|
+
? [node.childForFieldName('value'), node.childForFieldName('if_nil')]
|
|
103
|
+
: node.type === 'ternary_expression'
|
|
104
|
+
? [node.childForFieldName('if_true'), node.childForFieldName('if_false')]
|
|
105
|
+
: [null, null];
|
|
106
|
+
return first !== null && second !== null ? [first, second] : undefined;
|
|
107
|
+
},
|
|
97
108
|
};
|
|
98
109
|
/** tree-sitter-swift node types that carry arity. */
|
|
99
110
|
const FUNCTION_NODE_TYPES = [
|
|
@@ -47,6 +47,12 @@
|
|
|
47
47
|
* `extractAssignment` (Kotlin's `assignment`, Dart's
|
|
48
48
|
* `initialized_identifier`). Returning `undefined` falls back to the shared
|
|
49
49
|
* path, so one callback can handle the odd node and leave the rest alone.
|
|
50
|
+
* - A **fieldless or oddly-fielded** `??`/`?:`/elvis/ternary node is invisible
|
|
51
|
+
* to the shared branch expansion, so only one operand (if any) flows.
|
|
52
|
+
* Supply `valueAlternatives` (Kotlin's `elvis_expression`, Swift's
|
|
53
|
+
* `nil_coalescing_expression`, Dart's `if_null_expression`, Python's
|
|
54
|
+
* `conditional_expression`); return `[node]` to keep a statement-bodied
|
|
55
|
+
* `if` opaque (Ruby).
|
|
50
56
|
* - A binding needs a `SymbolDefinition` for the pass to attach to. Captures
|
|
51
57
|
* alone are not enough: without a `@declaration.*` for the bound name, the
|
|
52
58
|
* seed has no cell to key on.
|
|
@@ -139,6 +145,20 @@ export interface CallableFlowCaptureOptions {
|
|
|
139
145
|
readonly anchor: SyntaxNode;
|
|
140
146
|
readonly qualifiedName?: string;
|
|
141
147
|
} | undefined;
|
|
148
|
+
/**
|
|
149
|
+
* Provider-owned branches of a value-selecting expression (#3354). The
|
|
150
|
+
* shared rule only knows the field shapes `left`/`operator`/`right` (with a
|
|
151
|
+
* `??`/`||`/`or` operator) and `condition`/`consequence`/`alternative`; a
|
|
152
|
+
* grammar that spells the same construct differently (fieldless children,
|
|
153
|
+
* `value`/`if_nil`, `first`/`second`, a ternary without a `condition`
|
|
154
|
+
* field) supplies the branches here. Each returned branch is expanded again,
|
|
155
|
+
* so chains work. Returning `[node]` means "recognized, but opaque": the
|
|
156
|
+
* whole expression stays one source, which is how a provider keeps the
|
|
157
|
+
* shared ternary rule off a statement-bodied `if` whose branches are
|
|
158
|
+
* statement lists, not values. `undefined` falls back to the shared rule
|
|
159
|
+
* (mirrors `extractAssignment`).
|
|
160
|
+
*/
|
|
161
|
+
readonly valueAlternatives?: (node: SyntaxNode) => readonly SyntaxNode[] | undefined;
|
|
142
162
|
}
|
|
143
163
|
/**
|
|
144
164
|
* Emit normalized flow captures in deterministic source order.
|
|
@@ -146,6 +166,10 @@ export interface CallableFlowCaptureOptions {
|
|
|
146
166
|
* One explicit DFS supplies all phases below. Query-backed emitters may still
|
|
147
167
|
* perform their existing query walk; this helper never reparses and remains
|
|
148
168
|
* linear in AST size (the scope-capture benchmark guards the scaling ratio).
|
|
169
|
+
* That includes a value-selecting source: `valueAlternatives` expands a
|
|
170
|
+
* chain iteratively into disjoint leaves, and each leaf's visibility walk
|
|
171
|
+
* reuses the memoized anchor spine instead of re-walking to the root (the
|
|
172
|
+
* `typescript-deep-chain` benchmark case guards that one).
|
|
149
173
|
*/
|
|
150
174
|
export declare function synthesizeCallableFlowCaptures(root: SyntaxNode, options: CallableFlowCaptureOptions): readonly CaptureMatch[];
|
|
151
175
|
/**
|
|
@@ -47,6 +47,12 @@
|
|
|
47
47
|
* `extractAssignment` (Kotlin's `assignment`, Dart's
|
|
48
48
|
* `initialized_identifier`). Returning `undefined` falls back to the shared
|
|
49
49
|
* path, so one callback can handle the odd node and leave the rest alone.
|
|
50
|
+
* - A **fieldless or oddly-fielded** `??`/`?:`/elvis/ternary node is invisible
|
|
51
|
+
* to the shared branch expansion, so only one operand (if any) flows.
|
|
52
|
+
* Supply `valueAlternatives` (Kotlin's `elvis_expression`, Swift's
|
|
53
|
+
* `nil_coalescing_expression`, Dart's `if_null_expression`, Python's
|
|
54
|
+
* `conditional_expression`); return `[node]` to keep a statement-bodied
|
|
55
|
+
* `if` opaque (Ruby).
|
|
50
56
|
* - A binding needs a `SymbolDefinition` for the pass to attach to. Captures
|
|
51
57
|
* alone are not enough: without a `@declaration.*` for the bound name, the
|
|
52
58
|
* seed has no cell to key on.
|
|
@@ -61,16 +67,23 @@ import { nodeToCapture, syntheticCapture } from './ast-helpers.js';
|
|
|
61
67
|
* One explicit DFS supplies all phases below. Query-backed emitters may still
|
|
62
68
|
* perform their existing query walk; this helper never reparses and remains
|
|
63
69
|
* linear in AST size (the scope-capture benchmark guards the scaling ratio).
|
|
70
|
+
* That includes a value-selecting source: `valueAlternatives` expands a
|
|
71
|
+
* chain iteratively into disjoint leaves, and each leaf's visibility walk
|
|
72
|
+
* reuses the memoized anchor spine instead of re-walking to the root (the
|
|
73
|
+
* `typescript-deep-chain` benchmark case guards that one).
|
|
64
74
|
*/
|
|
65
75
|
export function synthesizeCallableFlowCaptures(root, options) {
|
|
66
|
-
const
|
|
76
|
+
const parentById = new Map();
|
|
77
|
+
const nodes = collectNodes(root, parentById);
|
|
67
78
|
const functions = collectFunctions(nodes, options);
|
|
68
79
|
const knownCallableNames = new Set(functions.map((fn) => fn.name));
|
|
69
80
|
const assignments = collectAssignments(nodes, options);
|
|
70
|
-
const valueBindings = buildValueBindingIndex(nodes, assignments, functions, options);
|
|
81
|
+
const valueBindings = buildValueBindingIndex(nodes, parentById, assignments, functions, options);
|
|
71
82
|
const out = [];
|
|
72
83
|
for (const assignment of assignments) {
|
|
73
|
-
|
|
84
|
+
for (const source of valueAlternatives(assignment.source, options)) {
|
|
85
|
+
emitAssignmentFact({ ...assignment, source }, knownCallableNames, valueBindings, options, out);
|
|
86
|
+
}
|
|
74
87
|
}
|
|
75
88
|
for (const fn of functions)
|
|
76
89
|
emitFormalFacts(fn, options, out);
|
|
@@ -83,7 +96,9 @@ export function synthesizeCallableFlowCaptures(root, options) {
|
|
|
83
96
|
out.sort((a, b) => compareCaptures(firstCapture(a), firstCapture(b)));
|
|
84
97
|
return out;
|
|
85
98
|
}
|
|
86
|
-
|
|
99
|
+
/** Every named node in document order; also records each one's parent into
|
|
100
|
+
* `parentById` (see `ValueBindingIndex.parentById`). */
|
|
101
|
+
function collectNodes(root, parentById) {
|
|
87
102
|
const out = [];
|
|
88
103
|
const stack = [root];
|
|
89
104
|
while (stack.length > 0) {
|
|
@@ -92,8 +107,10 @@ function collectNodes(root) {
|
|
|
92
107
|
const children = node.namedChildren;
|
|
93
108
|
for (let i = children.length - 1; i >= 0; i--) {
|
|
94
109
|
const child = children[i];
|
|
95
|
-
if (child
|
|
96
|
-
|
|
110
|
+
if (child === null)
|
|
111
|
+
continue;
|
|
112
|
+
parentById.set(child.id, node);
|
|
113
|
+
stack.push(child);
|
|
97
114
|
}
|
|
98
115
|
}
|
|
99
116
|
return out;
|
|
@@ -163,7 +180,7 @@ function collectAssignments(nodes, options) {
|
|
|
163
180
|
}
|
|
164
181
|
return out;
|
|
165
182
|
}
|
|
166
|
-
function buildValueBindingIndex(nodes, assignments, functions, options) {
|
|
183
|
+
function buildValueBindingIndex(nodes, parentById, assignments, functions, options) {
|
|
167
184
|
const assignmentRegionIdsByName = new Map();
|
|
168
185
|
const memberStoreRegionIdsByName = new Map();
|
|
169
186
|
const formalByOwner = new Map();
|
|
@@ -250,13 +267,59 @@ function buildValueBindingIndex(nodes, assignments, functions, options) {
|
|
|
250
267
|
byRegion.set(functionOwner.id, signature);
|
|
251
268
|
}
|
|
252
269
|
}
|
|
270
|
+
const anchorIds = new Set();
|
|
271
|
+
for (const index of [assignmentRegionIdsByName, memberStoreRegionIdsByName]) {
|
|
272
|
+
for (const regionIds of index.values())
|
|
273
|
+
for (const id of regionIds)
|
|
274
|
+
anchorIds.add(id);
|
|
275
|
+
}
|
|
276
|
+
for (const byRegion of signatureByNameAndRegion.values()) {
|
|
277
|
+
for (const id of byRegion.keys())
|
|
278
|
+
anchorIds.add(id);
|
|
279
|
+
}
|
|
280
|
+
for (const owner of formalByOwner.keys())
|
|
281
|
+
if (owner !== undefined)
|
|
282
|
+
anchorIds.add(owner);
|
|
253
283
|
return {
|
|
254
284
|
assignmentRegionIdsByName,
|
|
255
285
|
memberStoreRegionIdsByName,
|
|
256
286
|
formalByOwner,
|
|
257
287
|
signatureByNameAndRegion,
|
|
288
|
+
anchorIds,
|
|
289
|
+
parentById,
|
|
290
|
+
nearestAnchorById: new Map(),
|
|
258
291
|
};
|
|
259
292
|
}
|
|
293
|
+
/** A node the DFS did not reach (none in practice) falls back to tree-sitter. */
|
|
294
|
+
function parentOf(node, bindings) {
|
|
295
|
+
return bindings.parentById.get(node.id) ?? node.parent;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* The nearest anchor at-or-above `start` (see `ValueBindingIndex.anchorIds`),
|
|
299
|
+
* or null when none is. Every node visited on the way is memoized, so across
|
|
300
|
+
* one file each node's `parent` is taken at most once by these walks.
|
|
301
|
+
*/
|
|
302
|
+
function nearestAnchor(start, bindings) {
|
|
303
|
+
const visited = [];
|
|
304
|
+
let node = start;
|
|
305
|
+
let found = null;
|
|
306
|
+
while (node !== null) {
|
|
307
|
+
const cached = bindings.nearestAnchorById.get(node.id);
|
|
308
|
+
if (cached !== undefined) {
|
|
309
|
+
found = cached;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
visited.push(node.id);
|
|
313
|
+
if (bindings.anchorIds.has(node.id)) {
|
|
314
|
+
found = node;
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
node = parentOf(node, bindings);
|
|
318
|
+
}
|
|
319
|
+
for (const id of visited)
|
|
320
|
+
bindings.nearestAnchorById.set(id, found);
|
|
321
|
+
return found;
|
|
322
|
+
}
|
|
260
323
|
/** True when a pointer/parenthesized declarator sits between the declaration
|
|
261
324
|
* and its binding identifier — the shape of a callable-typed variable, never
|
|
262
325
|
* of a plain prototype. Only C/C++ supply signature-declaration node types,
|
|
@@ -307,15 +370,13 @@ function isVisibleValueBinding(input, name, bindings, options) {
|
|
|
307
370
|
bindings.formalByOwner.get(providerOwner.id)?.has(name) === true)) {
|
|
308
371
|
return true;
|
|
309
372
|
}
|
|
310
|
-
let node = input;
|
|
311
|
-
while (node !== null) {
|
|
373
|
+
for (let node = nearestAnchor(input, bindings); node !== null; node = nearestAnchor(parentOf(node, bindings), bindings)) {
|
|
312
374
|
if (assignmentRegionIds?.has(node.id) === true)
|
|
313
375
|
return true;
|
|
314
376
|
if (options.functionNodeTypes.has(node.type) &&
|
|
315
377
|
bindings.formalByOwner.get(node.id)?.has(name) === true) {
|
|
316
378
|
return true;
|
|
317
379
|
}
|
|
318
|
-
node = node.parent;
|
|
319
380
|
}
|
|
320
381
|
if (bindings.formalByOwner.get(undefined)?.has(name) === true)
|
|
321
382
|
return true;
|
|
@@ -341,11 +402,9 @@ function isVisibleMemberStore(input, name, bindings, options) {
|
|
|
341
402
|
const providerOwner = options.lexicalFunctionOwner?.(input);
|
|
342
403
|
if (providerOwner !== undefined && regionIds.has(providerOwner.id))
|
|
343
404
|
return true;
|
|
344
|
-
let node = input;
|
|
345
|
-
while (node !== null) {
|
|
405
|
+
for (let node = nearestAnchor(input, bindings); node !== null; node = nearestAnchor(parentOf(node, bindings), bindings)) {
|
|
346
406
|
if (regionIds.has(node.id))
|
|
347
407
|
return true;
|
|
348
|
-
node = node.parent;
|
|
349
408
|
}
|
|
350
409
|
}
|
|
351
410
|
return visibleCallableSignature(input, name, bindings, options) !== undefined;
|
|
@@ -360,12 +419,10 @@ function visibleCallableSignature(input, name, bindings, options) {
|
|
|
360
419
|
if (signature !== undefined)
|
|
361
420
|
return signature;
|
|
362
421
|
}
|
|
363
|
-
let node = input;
|
|
364
|
-
while (node !== null) {
|
|
422
|
+
for (let node = nearestAnchor(input, bindings); node !== null; node = nearestAnchor(parentOf(node, bindings), bindings)) {
|
|
365
423
|
const signature = byRegion.get(node.id);
|
|
366
424
|
if (signature !== undefined)
|
|
367
425
|
return signature;
|
|
368
|
-
node = node.parent;
|
|
369
426
|
}
|
|
370
427
|
return undefined;
|
|
371
428
|
}
|
|
@@ -402,6 +459,108 @@ function assignmentParts(node, options) {
|
|
|
402
459
|
return [];
|
|
403
460
|
return [{ container: node, destination, source }];
|
|
404
461
|
}
|
|
462
|
+
/** Operators whose result is one of their operands, not a computed value. */
|
|
463
|
+
const VALUE_SELECTING_OPERATORS = new Set(['??', '||', 'or']);
|
|
464
|
+
/**
|
|
465
|
+
* Operators that yield their left operand when it is falsy and their right
|
|
466
|
+
* operand otherwise. A falsy value is never a callable, so only the RIGHT
|
|
467
|
+
* operand can be the callable that is later invoked. Where `&&` / `and`
|
|
468
|
+
* yields a boolean instead (Java, C#, Go, Rust, C, C++, PHP, Zig), the
|
|
469
|
+
* destination is not callable, so the flow never meets an invoke.
|
|
470
|
+
*/
|
|
471
|
+
const RIGHT_SELECTING_OPERATORS = new Set(['&&', 'and']);
|
|
472
|
+
/**
|
|
473
|
+
* The operands a value-selecting expression can evaluate to (#3354):
|
|
474
|
+
* `a ?? b`, `a || b`, `a or b`, and `c ? a : b` each yield one of their
|
|
475
|
+
* branches, so each branch flows into the destination. `a && b` / `a and b`
|
|
476
|
+
* can only yield a callable through `b`, so `x and f or g` reaches `f` and `g`.
|
|
477
|
+
* Anything else is its own single alternative, which leaves every other
|
|
478
|
+
* source shape untouched. A branch that is itself an operator expression
|
|
479
|
+
* (`x.kind === f || g`) yields a computed value, so it contributes nothing.
|
|
480
|
+
* `options.valueAlternatives` is consulted first for grammars whose shape the
|
|
481
|
+
* field-based rule below cannot see.
|
|
482
|
+
*/
|
|
483
|
+
function valueAlternatives(node, options) {
|
|
484
|
+
// Explicit stack, not recursion: `a || b || …` nests one level per operand,
|
|
485
|
+
// so a generated keyword table thousands of operands long overflowed the
|
|
486
|
+
// call stack (and re-copied every partial result at each level).
|
|
487
|
+
const out = [];
|
|
488
|
+
const pending = [node];
|
|
489
|
+
for (let current = pending.pop(); current !== undefined; current = pending.pop()) {
|
|
490
|
+
const branches = valueBranches(current, options);
|
|
491
|
+
if (branches === undefined) {
|
|
492
|
+
// An operator branch is opaque, as the whole compound source was before
|
|
493
|
+
// the fan-out: emitted alone, `x.kind === Handlers.run` becomes a seed
|
|
494
|
+
// whose qualified text slices to receiver `Handlers`, member `run`.
|
|
495
|
+
if (current === node || !isBinaryOperatorExpression(unwrapParentheses(current))) {
|
|
496
|
+
out.push(current);
|
|
497
|
+
}
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
// Reverse push keeps the left-to-right branch order on output.
|
|
501
|
+
for (let i = branches.length - 1; i >= 0; i--) {
|
|
502
|
+
const branch = branches[i];
|
|
503
|
+
if (branch !== undefined)
|
|
504
|
+
pending.push(branch);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return out;
|
|
508
|
+
}
|
|
509
|
+
/** One level of `valueAlternatives`: the branches `node` selects between, or
|
|
510
|
+
* undefined when it is opaque (its own single alternative). */
|
|
511
|
+
function valueBranches(node, options) {
|
|
512
|
+
const inner = unwrapParentheses(node);
|
|
513
|
+
const provided = options.valueAlternatives?.(inner);
|
|
514
|
+
if (provided !== undefined) {
|
|
515
|
+
return provided.length === 1 && provided[0]?.id === inner.id ? undefined : provided;
|
|
516
|
+
}
|
|
517
|
+
const consequence = inner.childForFieldName('consequence');
|
|
518
|
+
const alternative = inner.childForFieldName('alternative');
|
|
519
|
+
if (consequence !== null && alternative !== null && inner.childForFieldName('condition')) {
|
|
520
|
+
return [consequence, alternative];
|
|
521
|
+
}
|
|
522
|
+
const left = inner.childForFieldName('left');
|
|
523
|
+
const right = inner.childForFieldName('right');
|
|
524
|
+
const operator = inner.childForFieldName('operator')?.type;
|
|
525
|
+
if (left === null || right === null || !operator)
|
|
526
|
+
return undefined;
|
|
527
|
+
if (VALUE_SELECTING_OPERATORS.has(operator))
|
|
528
|
+
return [left, right];
|
|
529
|
+
if (RIGHT_SELECTING_OPERATORS.has(operator))
|
|
530
|
+
return [right];
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
function unwrapParentheses(node) {
|
|
534
|
+
let inner = node;
|
|
535
|
+
while (inner.type.includes('parenthesized') && inner.namedChildCount === 1) {
|
|
536
|
+
const child = inner.namedChild(0);
|
|
537
|
+
if (child === null)
|
|
538
|
+
break;
|
|
539
|
+
inner = child;
|
|
540
|
+
}
|
|
541
|
+
return inner;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* True for an expression that computes a value from two operands (`a === b`,
|
|
545
|
+
* `a + b`, `a is b`), which designates neither operand. Read from the same
|
|
546
|
+
* field vocabulary as `valueBranches` rather than grammar type names: a
|
|
547
|
+
* `left`/`right` pair, or an operator token (`operator`, Python's
|
|
548
|
+
* `operators`, Swift's `op`) that follows the expression's start. A member
|
|
549
|
+
* access that fields its `.` / `->` as `operator` (Ruby `call`, C/C++
|
|
550
|
+
* `field_expression`) also fields its member name, so it stays a designator;
|
|
551
|
+
* a unary `&f` / `*fp` leads with its operator and stays one too.
|
|
552
|
+
*/
|
|
553
|
+
function isBinaryOperatorExpression(node) {
|
|
554
|
+
if (node.childForFieldName('left') !== null && node.childForFieldName('right') !== null) {
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
if (memberNameNode(node) !== null)
|
|
558
|
+
return false;
|
|
559
|
+
const operator = node.childForFieldName('operator') ??
|
|
560
|
+
node.childForFieldName('operators') ??
|
|
561
|
+
node.childForFieldName('op');
|
|
562
|
+
return operator !== null && operator.startIndex > node.startIndex;
|
|
563
|
+
}
|
|
405
564
|
function emitAssignmentFact(assignment, knownCallableNames, valueBindings, options, out) {
|
|
406
565
|
const destination = operandSyntax(assignment.destination, options, options.bindingNodeTypes.has(assignment.container.type));
|
|
407
566
|
const source = operandSyntax(assignment.source, options);
|
|
@@ -699,10 +858,7 @@ function memberParts(input, options) {
|
|
|
699
858
|
// stay unaffected. Without it every `x.f(arg)` in such a grammar collapsed
|
|
700
859
|
// to a DIRECT call named `f` and the flow solver fanned the argument out to
|
|
701
860
|
// every same-named callable.
|
|
702
|
-
const memberNode = node
|
|
703
|
-
node.childForFieldName('field') ??
|
|
704
|
-
node.childForFieldName('method') ??
|
|
705
|
-
node.childForFieldName('member');
|
|
861
|
+
const memberNode = memberNameNode(node);
|
|
706
862
|
if (receiverNode === null || memberNode === null)
|
|
707
863
|
return undefined;
|
|
708
864
|
const receiver = operandSyntax(receiverNode, options);
|
|
@@ -713,6 +869,14 @@ function memberParts(input, options) {
|
|
|
713
869
|
options.memberPointerOperators?.has(child.text) === true);
|
|
714
870
|
return { receiver, member, ...(operator !== undefined ? { operator: operator.text } : {}) };
|
|
715
871
|
}
|
|
872
|
+
/** The member-name child of a member access, under the field names the
|
|
873
|
+
* grammars use for it. */
|
|
874
|
+
function memberNameNode(node) {
|
|
875
|
+
return (node.childForFieldName('property') ??
|
|
876
|
+
node.childForFieldName('field') ??
|
|
877
|
+
node.childForFieldName('method') ??
|
|
878
|
+
node.childForFieldName('member'));
|
|
879
|
+
}
|
|
716
880
|
function operandSyntax(input, options, declarationBinding = false) {
|
|
717
881
|
if (declarationBinding) {
|
|
718
882
|
const binding = bindingIdentifier(input, options);
|
|
@@ -317,6 +317,7 @@ export declare class LocalBackend {
|
|
|
317
317
|
* degradation is visible once instead of silent.
|
|
318
318
|
*/
|
|
319
319
|
private warnedMissingEmbeddingStack;
|
|
320
|
+
private warnedNoEmbeddingVectors;
|
|
320
321
|
/**
|
|
321
322
|
* Width the semantic lane last produced a QUERY vector at for an index, keyed
|
|
322
323
|
* by `lbugPath` (like `lastObservedPoolState`, and for the same reason: branch
|
|
@@ -622,6 +623,11 @@ export declare class LocalBackend {
|
|
|
622
623
|
* BM25 keyword search helper - uses LadybugDB FTS for always-fresh results
|
|
623
624
|
*/
|
|
624
625
|
private bm25Search;
|
|
626
|
+
/**
|
|
627
|
+
* Keyword-only indexes are the default. Do not put this on `query.warning`
|
|
628
|
+
* (that field is reserved for FTS/stack degradation). Log once per backend.
|
|
629
|
+
*/
|
|
630
|
+
private logKeywordOnlyEmbeddings;
|
|
625
631
|
/**
|
|
626
632
|
* Semantic vector search helper
|
|
627
633
|
*/
|
|
@@ -178,6 +178,14 @@ function resolveAliasString(canonical, legacy) {
|
|
|
178
178
|
}
|
|
179
179
|
return undefined;
|
|
180
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* A `*_uid` param as a lookup key: trimmed, or `undefined` when it is blank or
|
|
183
|
+
* not a string (#3354). Strict adapters send `" "`/`""` for an omitted optional
|
|
184
|
+
* string, and the MCP envelope is not type-validated.
|
|
185
|
+
*/
|
|
186
|
+
function nonBlankUid(value) {
|
|
187
|
+
return typeof value === 'string' ? value.trim() || undefined : undefined;
|
|
188
|
+
}
|
|
181
189
|
const TOOL_STRING_ALIASES = {
|
|
182
190
|
impact: [{ canonical: 'target', aliases: ['name', 'symbol'] }],
|
|
183
191
|
context: [{ canonical: 'file_path', aliases: ['file'] }],
|
|
@@ -1060,6 +1068,7 @@ export class LocalBackend {
|
|
|
1060
1068
|
* degradation is visible once instead of silent.
|
|
1061
1069
|
*/
|
|
1062
1070
|
warnedMissingEmbeddingStack = false;
|
|
1071
|
+
warnedNoEmbeddingVectors = false;
|
|
1063
1072
|
/**
|
|
1064
1073
|
* Width the semantic lane last produced a QUERY vector at for an index, keyed
|
|
1065
1074
|
* by `lbugPath` (like `lastObservedPoolState`, and for the same reason: branch
|
|
@@ -3043,6 +3052,16 @@ export class LocalBackend {
|
|
|
3043
3052
|
}
|
|
3044
3053
|
return { results, ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) };
|
|
3045
3054
|
}
|
|
3055
|
+
/**
|
|
3056
|
+
* Keyword-only indexes are the default. Do not put this on `query.warning`
|
|
3057
|
+
* (that field is reserved for FTS/stack degradation). Log once per backend.
|
|
3058
|
+
*/
|
|
3059
|
+
logKeywordOnlyEmbeddings() {
|
|
3060
|
+
if (this.warnedNoEmbeddingVectors)
|
|
3061
|
+
return;
|
|
3062
|
+
this.warnedNoEmbeddingVectors = true;
|
|
3063
|
+
logger.warn('GitNexus [query:vector]: This index has no embedding vectors — results are keyword-only. Enable embeddings in `.gitnexusrc` (auto-sync honors that file) or run `gitnexus analyze --embeddings`.');
|
|
3064
|
+
}
|
|
3046
3065
|
/**
|
|
3047
3066
|
* Semantic vector search helper
|
|
3048
3067
|
*/
|
|
@@ -3057,10 +3076,8 @@ export class LocalBackend {
|
|
|
3057
3076
|
// the count is read, to decide whether to load the embedding model at all.
|
|
3058
3077
|
const tableCheck = await executeQuery(repo.lbugPath, `MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN COUNT(*) AS cnt LIMIT 1`);
|
|
3059
3078
|
if (!tableCheck.length || (tableCheck[0].cnt ?? tableCheck[0][0]) === 0) {
|
|
3060
|
-
// No vectors to search: nothing is embedded below, so drop any width a
|
|
3061
|
-
// previous call recorded rather than let query() warn about a lane that
|
|
3062
|
-
// did not run this time (#2798).
|
|
3063
3079
|
this.lastQueryEmbeddingDims.delete(repo.lbugPath);
|
|
3080
|
+
this.logKeywordOnlyEmbeddings();
|
|
3064
3081
|
return [];
|
|
3065
3082
|
}
|
|
3066
3083
|
const { embedQuery, getEmbeddingDims } = await import('../core/embedder.js');
|
|
@@ -3204,6 +3221,9 @@ export class LocalBackend {
|
|
|
3204
3221
|
if (degraded)
|
|
3205
3222
|
degraded.reason = message;
|
|
3206
3223
|
}
|
|
3224
|
+
else if (isBenignMissingTableError(err)) {
|
|
3225
|
+
this.logKeywordOnlyEmbeddings();
|
|
3226
|
+
}
|
|
3207
3227
|
if (!this.warnedMissingEmbeddingStack && isDegradedVectorError) {
|
|
3208
3228
|
this.warnedMissingEmbeddingStack = true;
|
|
3209
3229
|
logger.warn(`GitNexus [query:vector]: ${message}`);
|
|
@@ -3498,7 +3518,11 @@ export class LocalBackend {
|
|
|
3498
3518
|
* same filePath), the Class wins silently.
|
|
3499
3519
|
*/
|
|
3500
3520
|
async resolveSymbolCandidates(repo, query, hints) {
|
|
3501
|
-
const {
|
|
3521
|
+
const { name, include_content } = query;
|
|
3522
|
+
// A blank or non-string uid is omitted, not a lookup key — fall through to
|
|
3523
|
+
// the name instead of `not_found`, matching normalizeToolParams' impact
|
|
3524
|
+
// target_uid check.
|
|
3525
|
+
const uid = nonBlankUid(query.uid);
|
|
3502
3526
|
const selectClause = `n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}`;
|
|
3503
3527
|
// Direct UID — zero-ambiguity path.
|
|
3504
3528
|
if (uid) {
|
|
@@ -5498,7 +5522,7 @@ export class LocalBackend {
|
|
|
5498
5522
|
if (fromOutcome.kind === 'not_found') {
|
|
5499
5523
|
return {
|
|
5500
5524
|
status: 'not_found',
|
|
5501
|
-
error: `Source symbol '${params.from_uid ?? params.from}' not found.`,
|
|
5525
|
+
error: `Source symbol '${nonBlankUid(params.from_uid) ?? params.from}' not found.`,
|
|
5502
5526
|
suggestion: 'Check the symbol name or use --from-uid for zero-ambiguity.',
|
|
5503
5527
|
};
|
|
5504
5528
|
}
|
|
@@ -5516,7 +5540,7 @@ export class LocalBackend {
|
|
|
5516
5540
|
if (toOutcome.kind === 'not_found') {
|
|
5517
5541
|
return {
|
|
5518
5542
|
status: 'not_found',
|
|
5519
|
-
error: `Target symbol '${params.to_uid ?? params.to}' not found.`,
|
|
5543
|
+
error: `Target symbol '${nonBlankUid(params.to_uid) ?? params.to}' not found.`,
|
|
5520
5544
|
suggestion: 'Check the symbol name or use --to-uid for zero-ambiguity.',
|
|
5521
5545
|
};
|
|
5522
5546
|
}
|
|
@@ -5840,7 +5864,7 @@ export class LocalBackend {
|
|
|
5840
5864
|
// selected silently.
|
|
5841
5865
|
const outcome = await this.resolveSymbolCandidates(repo, { uid: params.target_uid, name: target }, { file_path: params.file_path, kind: params.kind });
|
|
5842
5866
|
if (outcome.kind === 'not_found') {
|
|
5843
|
-
const missing = params.target_uid ?? target;
|
|
5867
|
+
const missing = nonBlankUid(params.target_uid) ?? target;
|
|
5844
5868
|
// not_found = no resolved symbol, so the envelope keeps the partial-but-
|
|
5845
5869
|
// typed target (typed PdgImpactTarget — there is no id/type/filePath yet).
|
|
5846
5870
|
const notFoundTarget = { name: target };
|
|
@@ -6537,6 +6561,12 @@ export class LocalBackend {
|
|
|
6537
6561
|
const safeMinConfidence = Number.isFinite(minConfidence) ? minConfidence : 0;
|
|
6538
6562
|
const confidenceFilter = safeMinConfidence > 0 ? ' AND r.confidence >= $minConfidence' : '';
|
|
6539
6563
|
const symId = sym.id || sym[0];
|
|
6564
|
+
// #3354: a walk with no anchor id cannot say anything about THIS symbol,
|
|
6565
|
+
// yet it still ships a normal-looking `exact` result with the target's
|
|
6566
|
+
// name echoed back. Throw so every caller's catch reports UNKNOWN instead.
|
|
6567
|
+
if (!symId) {
|
|
6568
|
+
throw new Error(`Impact target '${sym.name || sym[1] || '?'}' resolved without a node id; refusing to report a blast radius`);
|
|
6569
|
+
}
|
|
6540
6570
|
// #1858 — kick off the epistemic boundary probe concurrently with the BFS.
|
|
6541
6571
|
// It depends only on symId/symType/symName (all known now) and touches no
|
|
6542
6572
|
// shared state, so its extra round-trip overlaps the traversal instead of
|
|
@@ -109,10 +109,9 @@ export declare function normalizeGitUrlForCompare(url: string): string;
|
|
|
109
109
|
/**
|
|
110
110
|
* Read `remote.origin.url` from an existing clone using `git config --get`.
|
|
111
111
|
*
|
|
112
|
-
* Returns `null`
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* on an existing clone is treated as a refuse-to-pull condition.
|
|
112
|
+
* Returns `null` only when the key is absent (exit 1) or empty. Timeouts and
|
|
113
|
+
* every other git failure throw, so a lock or spawn error is not treated as
|
|
114
|
+
* a missing origin.
|
|
116
115
|
*/
|
|
117
116
|
export declare function getRemoteOriginUrl(cwd: string, timeoutMs?: number): Promise<string | null>;
|
|
118
117
|
/**
|
|
@@ -129,7 +128,7 @@ export declare function getRemoteOriginUrl(cwd: string, timeoutMs?: number): Pro
|
|
|
129
128
|
* Exported so the comparison logic is testable in isolation against any
|
|
130
129
|
* tmpdir-based fixture, without needing to populate CLONE_ROOT.
|
|
131
130
|
*/
|
|
132
|
-
export declare function assertRemoteMatchesRequestedUrl(targetDir: string, requestedUrl: string, timeoutMs?: number): Promise<void>;
|
|
131
|
+
export declare function assertRemoteMatchesRequestedUrl(targetDir: string, requestedUrl: string, timeoutMs?: number, knownOriginUrl?: string): Promise<void>;
|
|
133
132
|
/**
|
|
134
133
|
* Clone or pull a git repository.
|
|
135
134
|
*
|
package/dist/server/git-clone.js
CHANGED
|
@@ -14,7 +14,7 @@ import { branchSlug } from '../storage/branch-index.js';
|
|
|
14
14
|
import { sanitizeRepoName, stripUrlCredentials } from '../storage/git.js';
|
|
15
15
|
import { validateGitUrl } from '../core/net/url-guard.js';
|
|
16
16
|
import { assertDirectoryOwnerAndPermissions, quarantineAutoSyncPartial, } from '../core/auto-sync/path-security.js';
|
|
17
|
-
import { validateAutoSyncRemoteUrl } from '../core/auto-sync/config.js';
|
|
17
|
+
import { getAutoSyncRepoIdentity, validateAutoSyncRemoteUrl } from '../core/auto-sync/config.js';
|
|
18
18
|
export { validateGitUrl };
|
|
19
19
|
/**
|
|
20
20
|
* Root directory for all cloned repositories. Targets must resolve inside this.
|
|
@@ -272,13 +272,21 @@ export function normalizeGitUrlForCompare(url) {
|
|
|
272
272
|
return trimmed.toLowerCase();
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
|
+
/** Same allowlisted repo across SSH and HTTPS, ignoring a trailing `.git`. */
|
|
276
|
+
function sameAllowlistedAutoSyncRepo(left, right) {
|
|
277
|
+
try {
|
|
278
|
+
return getAutoSyncRepoIdentity(left) === getAutoSyncRepoIdentity(right);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
275
284
|
/**
|
|
276
285
|
* Read `remote.origin.url` from an existing clone using `git config --get`.
|
|
277
286
|
*
|
|
278
|
-
* Returns `null`
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
* on an existing clone is treated as a refuse-to-pull condition.
|
|
287
|
+
* Returns `null` only when the key is absent (exit 1) or empty. Timeouts and
|
|
288
|
+
* every other git failure throw, so a lock or spawn error is not treated as
|
|
289
|
+
* a missing origin.
|
|
282
290
|
*/
|
|
283
291
|
export async function getRemoteOriginUrl(cwd, timeoutMs) {
|
|
284
292
|
try {
|
|
@@ -286,9 +294,12 @@ export async function getRemoteOriginUrl(cwd, timeoutMs) {
|
|
|
286
294
|
return stdout.trim() || null;
|
|
287
295
|
}
|
|
288
296
|
catch (error) {
|
|
289
|
-
|
|
297
|
+
const message = error.message ?? '';
|
|
298
|
+
if (message.includes('timed out'))
|
|
290
299
|
throw error;
|
|
291
|
-
|
|
300
|
+
if (message.includes('failed (exit code 1)'))
|
|
301
|
+
return null;
|
|
302
|
+
throw error;
|
|
292
303
|
}
|
|
293
304
|
}
|
|
294
305
|
/**
|
|
@@ -305,8 +316,8 @@ export async function getRemoteOriginUrl(cwd, timeoutMs) {
|
|
|
305
316
|
* Exported so the comparison logic is testable in isolation against any
|
|
306
317
|
* tmpdir-based fixture, without needing to populate CLONE_ROOT.
|
|
307
318
|
*/
|
|
308
|
-
export async function assertRemoteMatchesRequestedUrl(targetDir, requestedUrl, timeoutMs) {
|
|
309
|
-
const remoteUrl = await getRemoteOriginUrl(targetDir, timeoutMs);
|
|
319
|
+
export async function assertRemoteMatchesRequestedUrl(targetDir, requestedUrl, timeoutMs, knownOriginUrl) {
|
|
320
|
+
const remoteUrl = knownOriginUrl ?? (await getRemoteOriginUrl(targetDir, timeoutMs));
|
|
310
321
|
if (remoteUrl === null) {
|
|
311
322
|
throw new Error(`Existing clone at ${targetDir} has no remote.origin — refusing to pull`);
|
|
312
323
|
}
|
|
@@ -477,17 +488,39 @@ export async function cloneOrPull(url, targetDir, onProgress, options) {
|
|
|
477
488
|
await fs.mkdir(path.dirname(safeTarget), { recursive: true });
|
|
478
489
|
await assertNoSymlinkPath(cloneRoot, safeTarget, Boolean(options?.allowedCloneRoot));
|
|
479
490
|
await assertPreRealpathContainment(cloneRoot, safeTarget);
|
|
480
|
-
|
|
491
|
+
let exists = await fs.access(path.join(safeTarget, '.git')).then(() => true, () => false);
|
|
492
|
+
let originUrl = null;
|
|
493
|
+
if (exists && options?.allowAutoSyncSsh) {
|
|
494
|
+
originUrl = await getRemoteOriginUrl(safeTarget, options?.timeoutMs);
|
|
495
|
+
if (!originUrl) {
|
|
496
|
+
if (options.quarantineRoot) {
|
|
497
|
+
await quarantineAutoSyncPartial(safeTarget, options.quarantineRoot);
|
|
498
|
+
exists = false;
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
throw new Error(`Existing clone at ${safeTarget} has no remote.origin — remove ${safeTarget} and retry`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
481
505
|
const targetExists = await fs.access(safeTarget).then(() => true, () => false);
|
|
482
506
|
if (exists) {
|
|
483
507
|
if (options?.allowedCloneRoot) {
|
|
484
508
|
await assertNoSymlinkPath(cloneRoot, path.join(safeTarget, '.git'), true);
|
|
485
509
|
}
|
|
486
510
|
await assertPostRealpathContainment(cloneRoot, safeTarget);
|
|
511
|
+
let originForCompare = originUrl;
|
|
512
|
+
if (originUrl &&
|
|
513
|
+
normalizeGitUrlForCompare(originUrl) !== normalizeGitUrlForCompare(url) &&
|
|
514
|
+
sameAllowlistedAutoSyncRepo(originUrl, url)) {
|
|
515
|
+
await runGit(['remote', 'set-url', 'origin', url], safeTarget, {
|
|
516
|
+
timeoutMs: options?.timeoutMs,
|
|
517
|
+
});
|
|
518
|
+
originForCompare = url;
|
|
519
|
+
}
|
|
487
520
|
// Confirm the existing clone is actually the same repository the caller
|
|
488
521
|
// requested. Without this check, a pull would silently succeed against
|
|
489
522
|
// whatever remote the dir was originally cloned from.
|
|
490
|
-
await assertRemoteMatchesRequestedUrl(safeTarget, url, options?.timeoutMs);
|
|
523
|
+
await assertRemoteMatchesRequestedUrl(safeTarget, url, options?.timeoutMs, originForCompare ?? undefined);
|
|
491
524
|
onProgress?.({ phase: 'pulling', message: 'Pulling latest changes...' });
|
|
492
525
|
const runGitImpl = options?.runGitForTest ?? runGit;
|
|
493
526
|
const gitOpts = {
|
|
@@ -773,7 +773,14 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
|
|
|
773
773
|
// v104 (#3339 review): TS/JS pair-HOC queries now name object-pair
|
|
774
774
|
// `mutation(withAuth(arrow))` handlers. Warm caches replay the pre-fix
|
|
775
775
|
// capture set (anonymous arrows, no Function name), so both stores re-extract.
|
|
776
|
-
|
|
776
|
+
// v112 (#3354): callable-value flow now follows each branch of `a ?? f`,
|
|
777
|
+
// `a || f`, and `c ? f : g`. Warm caches replay the pre-fix flow facts, which
|
|
778
|
+
// have no flow for those assignments, so both stores re-extract. 105-111 are
|
|
779
|
+
// claimed by open PR #3326 (Elixir).
|
|
780
|
+
// Same v112: the `valueAlternatives` provider hook extends it to Kotlin
|
|
781
|
+
// `?:`/`if`, Swift/Dart `??`/`?:`, and Python `x if c else y`, and keeps a
|
|
782
|
+
// Ruby multi-statement `if` one opaque source.
|
|
783
|
+
const SCHEMA_BUMP = 112;
|
|
777
784
|
const GITNEXUS_PKG_VERSION = (() => {
|
|
778
785
|
try {
|
|
779
786
|
// package.json sits at gitnexus/package.json — two levels up from
|
package/package.json
CHANGED
|
@@ -341,6 +341,12 @@ const FILESYSTEM = [
|
|
|
341
341
|
// 4893-file pass — 2.3 s on a slow virtualised filesystem, 0.34 s on a local
|
|
342
342
|
// disk — against a 30 s testTimeout.
|
|
343
343
|
'test/unit/source-control-bytes.test.ts',
|
|
344
|
+
// Auto-sync reads a cloned `.gitnexusrc` through the symlink/hard-link guard,
|
|
345
|
+
// and clone recovery uses real `git` plus temp dirs. Ubuntu coverage alone
|
|
346
|
+
// would never create the Windows file symlink (`type: 'file'`) or run the
|
|
347
|
+
// git-config failure path on windows-latest / macos-latest.
|
|
348
|
+
'test/unit/gitnexus-rc-embeddings.test.ts',
|
|
349
|
+
'test/unit/git-clone.test.ts',
|
|
344
350
|
];
|
|
345
351
|
|
|
346
352
|
const ALL_CROSS_PLATFORM = [
|