gitnexus 1.6.13-rc.7 → 1.6.13-rc.9

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.
Files changed (28) hide show
  1. package/README.md +8 -2
  2. package/dist/cli/auto-sync.js +4 -0
  3. package/dist/core/auto-sync/analysis-worker-launch.d.ts +10 -0
  4. package/dist/core/auto-sync/analysis-worker-launch.js +18 -3
  5. package/dist/core/auto-sync/config.d.ts +2 -0
  6. package/dist/core/auto-sync/config.js +8 -4
  7. package/dist/core/auto-sync/runner.d.ts +3 -0
  8. package/dist/core/auto-sync/runner.js +85 -12
  9. package/dist/core/auto-sync/starter.js +16 -1
  10. package/dist/core/auto-sync/state.d.ts +5 -0
  11. package/dist/core/auto-sync/state.js +1 -0
  12. package/dist/core/ingestion/languages/rust/cargo-module-files.d.ts +9 -0
  13. package/dist/core/ingestion/languages/rust/cargo-module-files.js +319 -0
  14. package/dist/core/ingestion/languages/rust/cargo-targets.d.ts +21 -0
  15. package/dist/core/ingestion/languages/rust/cargo-targets.js +473 -0
  16. package/dist/core/ingestion/languages/rust/import-decomposer.js +10 -5
  17. package/dist/core/ingestion/languages/rust/name-fallback-visibility.d.ts +2 -0
  18. package/dist/core/ingestion/languages/rust/name-fallback-visibility.js +152 -13
  19. package/dist/core/ingestion/languages/rust/scope-resolver.js +2 -0
  20. package/dist/core/ingestion/scope-resolution/contract/scope-resolver.d.ts +2 -0
  21. package/dist/core/ingestion/scope-resolution/passes/free-call-fallback.d.ts +1 -0
  22. package/dist/core/ingestion/scope-resolution/passes/free-call-fallback.js +1 -0
  23. package/dist/core/ingestion/scope-resolution/pipeline/run.js +1 -0
  24. package/dist/core/run-analyze.d.ts +5 -0
  25. package/dist/core/run-analyze.js +23 -0
  26. package/dist/storage/parse-cache.js +7 -1
  27. package/package.json +2 -1
  28. package/scripts/cross-platform-tests.ts +3 -0
package/README.md CHANGED
@@ -325,6 +325,10 @@ analyze_failure_threshold: 3
325
325
  projects:
326
326
  - local_path: /abs/path/to/repos
327
327
  branches: [master, main]
328
+ # pdg: omit = preserve live index mode; true = keep PDG current;
329
+ # false = init default (warns, then strips PDG on the next successful rebuild).
330
+ # Do not paste pdg: false onto an existing watch file unless you intend to drop PDG.
331
+ pdg: false
328
332
  overwrite_local_changes: false
329
333
  remote_urls:
330
334
  - git@github.com:owner/repo.git
@@ -332,7 +336,9 @@ projects:
332
336
  - git@gitee.com:owner/repo.git
333
337
  ```
334
338
 
335
- `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` must use SSH SCP form for github.com, gitlab.com, or gitee.com. `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, defaults to half of `sync_interval_minutes`, and cannot exceed that value; this keeps it within Node's timer range. 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. `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 and commit; a new commit 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`.
339
+ `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` must use SSH SCP form for github.com, gitlab.com, or gitee.com. `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. 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.
340
+
341
+ `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`.
336
342
 
337
343
  GraphQL contract matching is opt-in in the group's `group.yaml`:
338
344
 
@@ -431,7 +437,7 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift,
431
437
  | Kotlin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
432
438
  | C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
433
439
  | Go | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
434
- | Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
440
+ | Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
435
441
  | PHP | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ |
436
442
  | Ruby | ✓ | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ |
437
443
  | Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
@@ -99,6 +99,10 @@ function defaultSyncConfig(localPath) {
99
99
  'projects:',
100
100
  ` - local_path: ${localPath}`,
101
101
  ' branches: [master, main]',
102
+ ' # pdg: omit = preserve live index mode; true = keep PDG current;',
103
+ ' # false = init default (warns, then strips PDG on the next successful rebuild).',
104
+ ' # Do not paste pdg: false onto an existing watch file unless you intend to drop PDG.',
105
+ ' pdg: false',
102
106
  ' overwrite_local_changes: false',
103
107
  ' remote_urls:',
104
108
  ' - git@github.com:owner/repo.git',
@@ -9,6 +9,16 @@ interface AnalysisWorker extends Pick<ChildProcess, 'send' | 'on'> {
9
9
  unref(): void;
10
10
  } | null;
11
11
  }
12
+ export declare class AutoSyncAnalysisError extends Error {
13
+ readonly code?: 'index-lock-timeout';
14
+ readonly retryable?: boolean;
15
+ readonly abandonedWorker: boolean;
16
+ constructor(message: string, options?: {
17
+ code?: 'index-lock-timeout';
18
+ retryable?: boolean;
19
+ abandonedWorker?: boolean;
20
+ });
21
+ }
12
22
  export interface AutoSyncAnalysisLaunchDeps {
13
23
  forkWorker: (workerPath: string, execArgv: string[]) => AnalysisWorker;
14
24
  setTimeoutFn: typeof setTimeout;
@@ -12,6 +12,18 @@ const _require = createRequire(import.meta.url);
12
12
  * past its safe point still turns `watch stop` into a timeout.
13
13
  */
14
14
  const AUTO_SYNC_CANCEL_GRACE_MS = 5_000;
15
+ export class AutoSyncAnalysisError extends Error {
16
+ code;
17
+ retryable;
18
+ abandonedWorker;
19
+ constructor(message, options = {}) {
20
+ super(message);
21
+ this.name = 'AutoSyncAnalysisError';
22
+ this.code = options.code;
23
+ this.retryable = options.retryable;
24
+ this.abandonedWorker = options.abandonedWorker === true;
25
+ }
26
+ }
15
27
  const DEFAULT_DEPS = {
16
28
  forkWorker: (workerPath, execArgv) => fork(workerPath, [], {
17
29
  execArgv,
@@ -111,8 +123,8 @@ export function createAutoSyncAnalysisRunner(overrides = {}) {
111
123
  if (settled)
112
124
  return;
113
125
  releaseChild();
114
- settle(new Error(`${error.message} The analyze worker did not exit within ${deps.cancelGraceMs}ms; ` +
115
- 'it was left running so its native work is not interrupted.'));
126
+ settle(new AutoSyncAnalysisError(`${error.message} The analyze worker did not exit within ${deps.cancelGraceMs}ms; ` +
127
+ 'it was left running so its native work is not interrupted.', { abandonedWorker: true, retryable: true }));
116
128
  }, deps.cancelGraceMs);
117
129
  };
118
130
  const timeout = deps.setTimeoutFn(() => requestCancellation(new Error(`Analysis timed out after ${timeoutMs}ms.`)), timeoutMs);
@@ -148,7 +160,10 @@ export function createAutoSyncAnalysisRunner(overrides = {}) {
148
160
  return;
149
161
  }
150
162
  if (terminalOutcome?.type === 'error') {
151
- settle(new Error(terminalOutcome.message));
163
+ settle(new AutoSyncAnalysisError(terminalOutcome.message, {
164
+ code: terminalOutcome.code,
165
+ retryable: terminalOutcome.retryable,
166
+ }));
152
167
  return;
153
168
  }
154
169
  settle(new Error(`Auto-sync analyze worker exited before completion (${childSignal ?? code ?? 'unknown'}).`));
@@ -3,6 +3,8 @@ export declare const DEFAULT_ANALYZE_FAILURE_THRESHOLD = 3;
3
3
  export interface AutoSyncProjectConfig {
4
4
  localPath: string;
5
5
  groupName?: string;
6
+ /** Explicit opt-in/out. Omitted legacy configs preserve the index's current mode. */
7
+ pdg?: boolean;
6
8
  overwriteLocalChanges: boolean;
7
9
  branches: string[];
8
10
  remoteUrls: string[];
@@ -127,19 +127,19 @@ export function parseAutoSyncConfig(content, configPath) {
127
127
  `sync_interval_minutes); a bare number is interpreted as seconds, so use an explicit ` +
128
128
  `unit such as 600000ms or 10m`);
129
129
  }
130
- const maxAnalyzeTimeoutMs = Number.isInteger(interval) &&
130
+ const defaultAnalyzeTimeoutMs = Number.isInteger(interval) &&
131
131
  interval >= MIN_SYNC_INTERVAL_MINUTES &&
132
132
  interval <= MAX_SYNC_INTERVAL_MINUTES
133
133
  ? interval * 30_000
134
134
  : undefined;
135
135
  const analyzeTimeoutMs = raw.analyze_timeout === undefined
136
- ? (maxAnalyzeTimeoutMs ?? 0)
136
+ ? (defaultAnalyzeTimeoutMs ?? 0)
137
137
  : parseDurationMs(raw.analyze_timeout);
138
138
  if (!Number.isInteger(analyzeTimeoutMs) || analyzeTimeoutMs <= 0) {
139
139
  errors.push('analyze_timeout must be a positive duration such as 30m');
140
140
  }
141
- else if (maxAnalyzeTimeoutMs !== undefined && analyzeTimeoutMs > maxAnalyzeTimeoutMs) {
142
- errors.push(`analyze_timeout must not exceed half of sync_interval_minutes (${maxAnalyzeTimeoutMs / 60_000}m)`);
141
+ else if (analyzeTimeoutMs > MAX_TIMER_DELAY_MS) {
142
+ errors.push(`analyze_timeout must not exceed ${MAX_TIMER_DELAY_MS}ms`);
143
143
  }
144
144
  const analyzeFailureThreshold = raw.analyze_failure_threshold === undefined
145
145
  ? DEFAULT_ANALYZE_FAILURE_THRESHOLD
@@ -210,10 +210,14 @@ export function parseAutoSyncConfig(content, configPath) {
210
210
  if (typeof overwriteLocalChanges !== 'boolean') {
211
211
  errors.push(`projects[${index}].overwrite_local_changes must be a boolean`);
212
212
  }
213
+ if (project.pdg !== undefined && typeof project.pdg !== 'boolean') {
214
+ errors.push(`projects[${index}].pdg must be a boolean`);
215
+ }
213
216
  if (localPath && remoteUrls.length > 0 && branches.length > 0) {
214
217
  projects.push({
215
218
  localPath,
216
219
  groupName,
220
+ ...(typeof project.pdg === 'boolean' ? { pdg: project.pdg } : {}),
217
221
  overwriteLocalChanges: overwriteLocalChanges === true,
218
222
  branches,
219
223
  remoteUrls,
@@ -13,6 +13,7 @@ export interface AutoSyncRunDeps {
13
13
  cloneOrPull: typeof cloneOrPull;
14
14
  getCurrentBranch: (repoPath: string, timeoutMs: number) => Promise<string | undefined>;
15
15
  getCurrentCommit: (repoPath: string, timeoutMs: number) => Promise<string>;
16
+ getExistingPdgMode: (repoPath: string, branch: string) => Promise<boolean>;
16
17
  runAnalysis: AutoSyncAnalysisRunner;
17
18
  registerRepo: typeof registerRepo;
18
19
  resolveBranchPlacement: typeof resolveBranchPlacement;
@@ -29,6 +30,8 @@ export interface AutoSyncRunResult {
29
30
  analyzed: number;
30
31
  skippedAnalysis: number;
31
32
  failed: number;
33
+ /** True when a timed-out analyze worker was left running and may still hold the index lock. */
34
+ abandonedAnalysisWorker?: boolean;
32
35
  }
33
36
  export declare function runAutoSyncOnce(config: AutoSyncConfig, options?: {
34
37
  deps?: Partial<AutoSyncRunDeps>;
@@ -4,13 +4,13 @@ import { createRequire } from 'node:module';
4
4
  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
- import { registerRepo, resolveBranchPlacement } from '../../storage/repo-manager.js';
7
+ import { getStoragePaths, loadMeta, registerRepo, resolveBranchPlacement, } from '../../storage/repo-manager.js';
8
8
  import { extractRepoNameFromRemoteUrl } from './repo.js';
9
9
  import { cloneOrPull, runGit } from '../../server/git-clone.js';
10
10
  import { resolveConfiguredCloneRoot } from './path-security.js';
11
11
  import { buildStateKey, loadAutoSyncState, saveAutoSyncState, shouldAnalyzeCommit, writeProjectCommitInfo, } from './state.js';
12
12
  import { validateAutoSyncRemoteUrl } from './config.js';
13
- import { runAutoSyncAnalysis } from './analysis-worker-launch.js';
13
+ import { AutoSyncAnalysisError, runAutoSyncAnalysis, } from './analysis-worker-launch.js';
14
14
  const _require = createRequire(import.meta.url);
15
15
  const yaml = _require('js-yaml');
16
16
  const DEFAULT_LOGGER = {
@@ -25,6 +25,23 @@ const DEFAULT_DEPS = {
25
25
  return branch || undefined;
26
26
  },
27
27
  getCurrentCommit: async (repoPath, timeoutMs) => (await runGit(['rev-parse', 'HEAD'], repoPath, { timeoutMs })).trim(),
28
+ getExistingPdgMode: async (repoPath, branch) => {
29
+ const placement = await resolveBranchPlacement(repoPath, branch);
30
+ const paths = getStoragePaths(repoPath, placement.branch);
31
+ const meta = await loadMeta(path.dirname(paths.metaPath));
32
+ if (meta)
33
+ return meta.pdg !== undefined;
34
+ try {
35
+ await fs.stat(paths.lbugPath);
36
+ }
37
+ catch (error) {
38
+ const code = error.code;
39
+ if (code === 'ENOENT' || code === 'ENOTDIR')
40
+ return false;
41
+ throw error;
42
+ }
43
+ throw new Error(`Cannot determine whether the existing index at ${paths.lbugPath} contains PDG data because its metadata is missing or unreadable.`);
44
+ },
28
45
  runAnalysis: runAutoSyncAnalysis,
29
46
  registerRepo,
30
47
  resolveBranchPlacement,
@@ -46,6 +63,7 @@ export async function runAutoSyncOnce(config, options = {}) {
46
63
  const groupsToSync = new Set();
47
64
  const groupStateKeys = new Map();
48
65
  const result = { synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 };
66
+ let abandonedAnalysisWorker = false;
49
67
  const commitInfoEntries = [];
50
68
  const actualConcurrency = resolveActualConcurrency(config.maxConcurrency, deps.getAvailableMemoryGB());
51
69
  logger.info(`[auto-sync] Starting sync loop with max_concurrency=${actualConcurrency} analyze_failure_threshold=${config.analyzeFailureThreshold}.`);
@@ -88,27 +106,58 @@ export async function runAutoSyncOnce(config, options = {}) {
88
106
  const currentCommit = await deps.getCurrentCommit(targetDir, config.repoGitTimeoutMs);
89
107
  const stateKey = buildStateKey(targetDir, currentBranch);
90
108
  const previous = state[stateKey];
109
+ let existingPdg;
110
+ try {
111
+ existingPdg = await deps.getExistingPdgMode(targetDir, currentBranch);
112
+ }
113
+ catch (error) {
114
+ if (item.project.pdg === undefined) {
115
+ throw new Error(`${error instanceof Error ? error.message : String(error)} ` +
116
+ 'Refusing to analyze so the live graph is preserved.');
117
+ }
118
+ logger.warn(`[auto-sync] Existing PDG mode could not be determined for ${targetDir}; ` +
119
+ `applying explicit pdg=${item.project.pdg}.`);
120
+ }
121
+ const requestedPdg = item.project.pdg ?? existingPdg;
122
+ const pdgModeChanged = existingPdg === undefined || requestedPdg !== existingPdg;
91
123
  let analyzeStatus = 'skipped';
92
124
  let analyzedCommitId = previous?.analyzedCommitId;
93
125
  let analyzeConsecutiveFailures = previous?.analyzeConsecutiveFailures ?? 0;
94
126
  let lastAnalyzeError = previous?.lastAnalyzeError;
95
127
  const groupSyncPending = previous?.groupSyncPending === true;
96
128
  let stats;
97
- if (previous && previous.codeCommitId !== currentCommit) {
129
+ const previousRequestedPdg = previous?.requestedPdg ?? existingPdg;
130
+ if (previous &&
131
+ (previous.codeCommitId !== currentCommit || previousRequestedPdg !== requestedPdg)) {
98
132
  analyzeConsecutiveFailures = 0;
99
133
  lastAnalyzeError = undefined;
100
134
  }
135
+ if (item.project.pdg === undefined && existingPdg) {
136
+ logger.info(`[auto-sync] Preserving existing PDG mode for ${targetDir}; project configuration does not set pdg.`);
137
+ }
138
+ else if (item.project.pdg === false && existingPdg) {
139
+ logger.warn(`[auto-sync] PDG is explicitly disabled for ${targetDir}; the next successful rebuild will remove existing PDG data.`);
140
+ }
101
141
  if (analyzeConsecutiveFailures >= config.analyzeFailureThreshold) {
102
142
  analyzeStatus = 'threshold_skipped';
103
143
  logger.error(`[auto-sync] Skip analysis for ${targetDir}; analyze consecutive failures ${analyzeConsecutiveFailures}/${config.analyzeFailureThreshold} reached threshold. Fix the repository or clear auto-sync state before retrying.`);
104
144
  }
105
- else if (shouldAnalyzeCommit({
106
- currentCommit,
107
- previousAnalyzedCommit: previous?.analyzedCommitId,
108
- previousStatus: previous?.lastAnalyzeStatus,
109
- })) {
145
+ else if (pdgModeChanged ||
146
+ shouldAnalyzeCommit({
147
+ currentCommit,
148
+ previousAnalyzedCommit: previous?.analyzedCommitId,
149
+ previousStatus: previous?.lastAnalyzeStatus,
150
+ })) {
110
151
  try {
111
- const analysis = await deps.runAnalysis(targetDir, { branch: currentBranch, skipAgentsMd: true, skipSkills: true }, config.analyzeTimeoutMs, options.signal, options.onAnalysisCancellationRequested, analysisParallelism);
152
+ const analysis = await deps.runAnalysis(targetDir, {
153
+ branch: currentBranch,
154
+ skipAgentsMd: true,
155
+ skipSkills: true,
156
+ ...(item.project.pdg === undefined
157
+ ? { preserveExistingPdg: true }
158
+ : { pdg: requestedPdg }),
159
+ atomicIncremental: true,
160
+ }, config.analyzeTimeoutMs, options.signal, options.onAnalysisCancellationRequested, analysisParallelism);
112
161
  throwIfAborted(options.signal);
113
162
  stats = analysis.stats;
114
163
  analyzeStatus = 'success';
@@ -120,13 +169,20 @@ export async function runAutoSyncOnce(config, options = {}) {
120
169
  if (options.signal?.aborted)
121
170
  throw err;
122
171
  analyzeStatus = 'failed';
123
- analyzeConsecutiveFailures += 1;
124
172
  lastAnalyzeError = shortErrorMessage(err);
125
- logger.error(`[auto-sync] Analysis failed for ${targetDir}; consecutive failures ${analyzeConsecutiveFailures}/${config.analyzeFailureThreshold}: ${lastAnalyzeError}`);
173
+ if (isAbandonedAnalyzeWorkerError(err))
174
+ abandonedAnalysisWorker = true;
175
+ if (isRetryableAnalyzeContention(err)) {
176
+ logger.error(`[auto-sync] Analysis failed for ${targetDir}; retryable leftover-worker or index-lock wait (not counted toward threshold): ${lastAnalyzeError}`);
177
+ }
178
+ else {
179
+ analyzeConsecutiveFailures += 1;
180
+ logger.error(`[auto-sync] Analysis failed for ${targetDir}; consecutive failures ${analyzeConsecutiveFailures}/${config.analyzeFailureThreshold}: ${lastAnalyzeError}`);
181
+ }
126
182
  }
127
183
  }
128
184
  else {
129
- logger.info(`[auto-sync] Skip analysis for ${targetDir}; commit unchanged.`);
185
+ logger.info(`[auto-sync] Skip analysis for ${targetDir}; commit and PDG mode unchanged.`);
130
186
  }
131
187
  throwIfAborted(options.signal);
132
188
  return {
@@ -141,6 +197,7 @@ export async function runAutoSyncOnce(config, options = {}) {
141
197
  analyzeStatus,
142
198
  analyzeConsecutiveFailures,
143
199
  lastAnalyzeError,
200
+ requestedPdg,
144
201
  groupSyncPending,
145
202
  stats,
146
203
  stateKey,
@@ -220,6 +277,7 @@ export async function runAutoSyncOnce(config, options = {}) {
220
277
  codeCommitId: repoResult.currentCommit,
221
278
  analyzedCommitId,
222
279
  lastAnalyzeStatus: analyzeStatus,
280
+ requestedPdg: repoResult.requestedPdg,
223
281
  analyzeConsecutiveFailures,
224
282
  lastAnalyzeError,
225
283
  groupSyncPending: repoResult.groupSyncPending,
@@ -287,12 +345,27 @@ export async function runAutoSyncOnce(config, options = {}) {
287
345
  }
288
346
  if (groupStateChanged)
289
347
  await deps.saveState(state);
348
+ if (abandonedAnalysisWorker)
349
+ result.abandonedAnalysisWorker = true;
290
350
  return result;
291
351
  }
292
352
  function shortErrorMessage(err) {
293
353
  const message = err instanceof Error ? err.message : String(err);
294
354
  return message.replace(/\s+/g, ' ').slice(0, 240);
295
355
  }
356
+ const ABANDONED_WORKER_MARKER = 'it was left running so its native work is not interrupted';
357
+ function isAbandonedAnalyzeWorkerError(err) {
358
+ if (err instanceof AutoSyncAnalysisError && err.abandonedWorker)
359
+ return true;
360
+ return err instanceof Error && err.message.includes(ABANDONED_WORKER_MARKER);
361
+ }
362
+ function isRetryableAnalyzeContention(err) {
363
+ if (isAbandonedAnalyzeWorkerError(err))
364
+ return true;
365
+ return (err instanceof AutoSyncAnalysisError &&
366
+ err.code === 'index-lock-timeout' &&
367
+ err.retryable === true);
368
+ }
296
369
  export function getConfiguredRepoPath(project, repoName, remoteUrl) {
297
370
  if (!remoteUrl)
298
371
  return path.resolve(project.localPath, repoName);
@@ -66,6 +66,7 @@ export async function startAutoSyncWatch(options = {}) {
66
66
  let activeRun;
67
67
  let activeAbortController;
68
68
  let stopping = false;
69
+ let rerunRequested = false;
69
70
  let statusWrite = Promise.resolve();
70
71
  const updateStatus = (state, message) => {
71
72
  const write = statusWrite.then(() => writeWatchStatus(paths, {
@@ -86,10 +87,12 @@ export async function startAutoSyncWatch(options = {}) {
86
87
  if (stopping)
87
88
  return;
88
89
  if (activeRun) {
89
- stderr.write('[auto-sync] Previous run is still active; skipping overlapping run.\n');
90
+ rerunRequested = true;
91
+ stderr.write('[auto-sync] Previous run is still active; queued one immediate follow-up.\n');
90
92
  return;
91
93
  }
92
94
  const startedAt = new Date();
95
+ let deferFollowUp = false;
93
96
  stderr.write(`[auto-sync] Watch loop started at ${startedAt.toISOString()}.\n`);
94
97
  const abortController = new AbortController();
95
98
  const run = runOnce(loaded.config, {
@@ -101,6 +104,7 @@ export async function startAutoSyncWatch(options = {}) {
101
104
  },
102
105
  })
103
106
  .then((result) => {
107
+ deferFollowUp = result.abandonedAnalysisWorker === true;
104
108
  stderr.write(`[auto-sync] Watch loop finished: synced=${result.synced} analyzed=${result.analyzed} skipped=${result.skippedAnalysis} failed=${result.failed}.\n`);
105
109
  })
106
110
  .catch((err) => {
@@ -108,6 +112,8 @@ export async function startAutoSyncWatch(options = {}) {
108
112
  stderr.write('[auto-sync] Watch loop finished: failed.\n');
109
113
  })
110
114
  .finally(async () => {
115
+ const runAgain = rerunRequested;
116
+ rerunRequested = false;
111
117
  if (activeRun === run) {
112
118
  activeRun = undefined;
113
119
  activeAbortController = undefined;
@@ -115,6 +121,14 @@ export async function startAutoSyncWatch(options = {}) {
115
121
  if (!stopping) {
116
122
  await updateStatus('running').catch(reportStatusWriteFailure);
117
123
  }
124
+ if (runAgain && !stopping && !activeRun) {
125
+ if (deferFollowUp) {
126
+ stderr.write('[auto-sync] Previous run left an analyze worker running; deferring the coalesced follow-up to the next interval.\n');
127
+ }
128
+ else {
129
+ runSafely();
130
+ }
131
+ }
118
132
  });
119
133
  activeRun = run;
120
134
  activeAbortController = abortController;
@@ -122,6 +136,7 @@ export async function startAutoSyncWatch(options = {}) {
122
136
  let stopPromise;
123
137
  const stop = () => (stopPromise ??= (async () => {
124
138
  stopping = true;
139
+ rerunRequested = false;
125
140
  clearIntervalFn(timer);
126
141
  clearIntervalFn(controlTimer);
127
142
  activeAbortController?.abort();
@@ -3,6 +3,11 @@ export interface AutoSyncCommitStateEntry {
3
3
  codeCommitId: string;
4
4
  analyzedCommitId?: string;
5
5
  lastAnalyzeStatus?: AutoSyncAnalyzeStatus;
6
+ /**
7
+ * PDG mode from the most recent successful sync result. Written even when
8
+ * analysis was threshold-skipped and `runAnalysis` did not run.
9
+ */
10
+ requestedPdg?: boolean;
6
11
  analyzeConsecutiveFailures?: number;
7
12
  lastAnalyzeError?: string;
8
13
  groupSyncPending?: boolean;
@@ -78,6 +78,7 @@ function isAutoSyncCommitStateEntry(value) {
78
78
  entry.lastAnalyzeStatus === 'failed' ||
79
79
  entry.lastAnalyzeStatus === 'skipped' ||
80
80
  entry.lastAnalyzeStatus === 'threshold_skipped') &&
81
+ (entry.requestedPdg === undefined || typeof entry.requestedPdg === 'boolean') &&
81
82
  (entry.analyzeConsecutiveFailures === undefined ||
82
83
  (typeof entry.analyzeConsecutiveFailures === 'number' &&
83
84
  Number.isInteger(entry.analyzeConsecutiveFailures) &&
@@ -0,0 +1,9 @@
1
+ import type Parser from 'tree-sitter';
2
+ /** Public use evidence from the same AST used for membership. Keeps restricted
3
+ * and private globs distinct without changing the shared import/cache shape. */
4
+ export declare function rustPublicUses(root: Parser.SyntaxNode): ReadonlySet<string>;
5
+ /** External modules reachable from one source file; undefined is incomplete. */
6
+ export declare function rustModuleFiles(root: Parser.SyntaxNode, file: string, ownsDirectory: boolean, files: ReadonlySet<string>, missingFiles?: Set<string>, isCrateRoot?: boolean): readonly {
7
+ file: string;
8
+ ownsDirectory: boolean;
9
+ }[] | undefined;