diff --git a/gitnexus-shared/src/pipeline.ts b/gitnexus-shared/src/pipeline.ts index 5f7e61c57..13ca9dae5 100644 --- a/gitnexus-shared/src/pipeline.ts +++ b/gitnexus-shared/src/pipeline.ts @@ -10,6 +10,7 @@ export type PipelinePhase = | 'imports' | 'calls' | 'heritage' + | 'scopeResolution' | 'communities' | 'processes' | 'enriching' diff --git a/gitnexus-web/src/locales/en/common.json b/gitnexus-web/src/locales/en/common.json index 568978c91..3351323dd 100644 --- a/gitnexus-web/src/locales/en/common.json +++ b/gitnexus-web/src/locales/en/common.json @@ -69,6 +69,7 @@ "imports": "Resolving imports", "calls": "Tracing calls", "heritage": "Extracting inheritance", + "scopeResolution": "Resolving types", "communities": "Detecting communities", "processes": "Detecting processes", "complete": "Pipeline complete", diff --git a/gitnexus-web/src/locales/zh-CN/common.json b/gitnexus-web/src/locales/zh-CN/common.json index 6249db87a..e9bd500c3 100644 --- a/gitnexus-web/src/locales/zh-CN/common.json +++ b/gitnexus-web/src/locales/zh-CN/common.json @@ -69,6 +69,7 @@ "imports": "正在解析导入", "calls": "正在追踪调用", "heritage": "正在提取继承关系", + "scopeResolution": "正在解析类型", "communities": "正在检测社区", "processes": "正在检测流程", "complete": "流水线完成", diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index c9d370f7f..bde83b621 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -33,6 +33,7 @@ import { warnMissingOptionalGrammars } from './optional-grammars.js'; import { glob } from 'glob'; import fs from 'fs/promises'; import { cliError } from './cli-message.js'; +import { formatElapsed } from './format-elapsed.js'; import { isHfDownloadFailure } from '../core/embeddings/hf-env.js'; // Capture stderr.write at module load BEFORE anything (LadybugDB native @@ -916,14 +917,14 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions): phaseStart = Date.now(); } const elapsed = Math.round((Date.now() - phaseStart) / 1000); - const display = elapsed >= 3 ? `${phaseLabel} (${elapsed}s)` : phaseLabel; + const display = elapsed >= 3 ? `${phaseLabel} (${formatElapsed(elapsed)})` : phaseLabel; bar.update(value, { phase: display }); }; const elapsedTimer = setInterval(() => { const elapsed = Math.round((Date.now() - phaseStart) / 1000); if (elapsed >= 3) { - bar.update({ phase: `${lastPhaseLabel} (${elapsed}s)` }); + bar.update({ phase: `${lastPhaseLabel} (${formatElapsed(elapsed)})` }); } }, 1000); diff --git a/gitnexus/src/cli/format-elapsed.ts b/gitnexus/src/cli/format-elapsed.ts new file mode 100644 index 000000000..6e8427d6b --- /dev/null +++ b/gitnexus/src/cli/format-elapsed.ts @@ -0,0 +1,7 @@ +export function formatElapsed(secs: number): string { + if (secs < 60) return `${secs}s`; + if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`; + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + return `${h}h ${m}m`; +} diff --git a/gitnexus/src/core/ingestion/pipeline-phases/communities.ts b/gitnexus/src/core/ingestion/pipeline-phases/communities.ts index 0e29b6cc2..51986458a 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/communities.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/communities.ts @@ -32,13 +32,13 @@ export const communitiesPhase: PipelinePhase = { ctx.onProgress({ phase: 'communities', - percent: 84, + percent: 98, message: 'Detecting code communities...', stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount }, }); const communityResult = await processCommunities(ctx.graph, (message, progress) => { - const communityProgress = 84 + progress * 0.09; + const communityProgress = 98 + progress * 0.01; ctx.onProgress({ phase: 'communities', percent: Math.round(communityProgress), diff --git a/gitnexus/src/core/ingestion/pipeline-phases/mro.ts b/gitnexus/src/core/ingestion/pipeline-phases/mro.ts index c098f2b7b..cdc02bf29 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/mro.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/mro.ts @@ -4,7 +4,7 @@ * Computes Method Resolution Order (MRO) and creates METHOD_OVERRIDES * and METHOD_IMPLEMENTS edges. * - * @deps crossFile + * @deps crossFile, scopeResolution * @reads graph (all nodes and relationships) * @writes graph (METHOD_OVERRIDES, METHOD_IMPLEMENTS edges) */ @@ -25,7 +25,7 @@ export interface MROOutput { export const mroPhase: PipelinePhase = { name: 'mro', - deps: ['crossFile', 'structure'], + deps: ['crossFile', 'scopeResolution', 'structure'], async execute( ctx: PipelineContext, @@ -35,7 +35,7 @@ export const mroPhase: PipelinePhase = { ctx.onProgress({ phase: 'enriching', - percent: 83, + percent: 98, message: 'Computing method resolution order...', stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount }, }); diff --git a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts index 166faea20..c72309568 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts @@ -41,7 +41,7 @@ export const processesPhase: PipelinePhase = { ctx.onProgress({ phase: 'processes', - percent: 94, + percent: 99, message: 'Detecting execution flows...', stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount }, }); @@ -56,7 +56,7 @@ export const processesPhase: PipelinePhase = { ctx.graph, communityResult.memberships, (message, progress) => { - const processProgress = 94 + progress * 0.05; + const processProgress = 99 + progress * 0.01; ctx.onProgress({ phase: 'processes', percent: Math.round(processProgress), diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts index 686d70fc7..5e10750b2 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts @@ -34,7 +34,7 @@ import type { ParseOutput } from '../../pipeline-phases/parse.js'; import { isRegistryPrimary } from '../../registry-primary-flag.js'; import { SupportedLanguages, getLanguageFromFilename } from 'gitnexus-shared'; import { readFileContents } from '../../filesystem-walker.js'; -import { runScopeResolution } from './run.js'; +import { runScopeResolution, type ScopeResolutionSubPhase } from './run.js'; import { SCOPE_RESOLVERS } from './registry.js'; import { isDev, isSemanticModelValidatorEnabled } from '../../utils/env.js'; import type { ResolutionOutcome } from '../resolution-outcome.js'; @@ -130,6 +130,31 @@ export const scopeResolutionPhase: PipelinePhase = { } >(); + // Pre-count files and languages for progress reporting. This avoids + // a frozen progress bar during long scope-resolution runs (#1741). + let totalScopeFiles = 0; + let totalScopeLangs = 0; + for (const [lang] of SCOPE_RESOLVERS) { + if (!isRegistryPrimary(lang)) continue; + const count = scannedFiles.filter((f) => getLanguageFromFilename(f.path) === lang).length; + if (count > 0) { + totalScopeLangs++; + totalScopeFiles += count; + } + } + const SCOPE_PCT_START = 90; + const SCOPE_PCT_RANGE = 8; // 90-98 internal → 54-59% display + let processedScopeFiles = 0; + let currentLangIdx = 0; + + if (totalScopeFiles > 0) { + ctx.onProgress({ + phase: 'scopeResolution', + percent: SCOPE_PCT_START, + message: 'Resolving types', + }); + } + for (const [lang, provider] of SCOPE_RESOLVERS) { if (!isRegistryPrimary(lang)) continue; @@ -153,6 +178,23 @@ export const scopeResolutionPhase: PipelinePhase = { ? await provider.loadResolutionConfig(ctx.repoPath) : undefined; + const langFileCount = files.length; + const langLabel = lang.charAt(0).toUpperCase() + lang.slice(1); + currentLangIdx++; + const langTag = + totalScopeLangs > 1 ? `${langLabel} [${currentLangIdx}/${totalScopeLangs}]` : langLabel; + + if (totalScopeFiles > 0) { + const pct = + SCOPE_PCT_START + Math.round((processedScopeFiles / totalScopeFiles) * SCOPE_PCT_RANGE); + ctx.onProgress({ + phase: 'scopeResolution', + percent: pct, + message: 'Resolving types', + detail: `${langTag}, ${langFileCount.toLocaleString()} files`, + }); + } + const stats = runScopeResolution( { graph: ctx.graph, @@ -169,6 +211,44 @@ export const scopeResolutionPhase: PipelinePhase = { logger.warn(`[scope-resolution:${lang}] ${msg}`); } }, + onProgress: + totalScopeFiles > 0 + ? (subPhase: ScopeResolutionSubPhase, current, total) => { + let langRatio: number; + switch (subPhase) { + case 'extracting': + langRatio = total > 0 ? (current / total) * 0.5 : 0; + break; + case 'analyzing types': + langRatio = 0.5; + break; + case 'resolving references': + langRatio = 0.7; + break; + case 'linking symbols': + langRatio = 0.85; + break; + default: { + const _exhaustive: never = subPhase; + langRatio = 0.85; + } + } + const overallRatio = Math.min( + 1, + (processedScopeFiles + langRatio * langFileCount) / totalScopeFiles, + ); + const pct = SCOPE_PCT_START + Math.round(overallRatio * SCOPE_PCT_RANGE); + ctx.onProgress({ + phase: 'scopeResolution', + percent: pct, + message: 'Resolving types', + detail: + subPhase === 'extracting' + ? `${langTag} — extracting ${current.toLocaleString()}/${total.toLocaleString()} files` + : `${langTag} — ${subPhase}`, + }); + } + : undefined, }, provider, ); @@ -183,6 +263,7 @@ export const scopeResolutionPhase: PipelinePhase = { preExtractedByPath.delete(fp); } + processedScopeFiles += langFileCount; anyRan = true; totalFiles += stats.filesProcessed; totalImports += stats.importsEmitted; @@ -200,6 +281,15 @@ export const scopeResolutionPhase: PipelinePhase = { } } + if (totalScopeFiles > 0 && anyRan) { + ctx.onProgress({ + phase: 'scopeResolution', + percent: SCOPE_PCT_START + SCOPE_PCT_RANGE, + message: 'Resolving types', + detail: 'complete', + }); + } + // Dispose the cross-phase Tree cache — scope-resolution is the // only consumer. Holding Trees past this point is pure memory // pressure: downstream phases (mro, community, csv-generator) diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 777cdb639..21bf45e40 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -116,6 +116,12 @@ function preEmitInheritanceEdges( return handledSites; } +export type ScopeResolutionSubPhase = + | 'extracting' + | 'analyzing types' + | 'resolving references' + | 'linking symbols'; + interface RunScopeResolutionInput { readonly graph: KnowledgeGraph; /** @@ -167,6 +173,16 @@ interface RunScopeResolutionInput { * intentionally suppress an edge; the graph remains unchanged. */ readonly recordResolutionOutcome?: ResolutionOutcomeRecorder; + /** + * Optional progress callback for UI updates during long-running scope + * resolution. Called periodically during the extract loop and at each + * sub-phase boundary (finalize, resolve, emit). + * + * @param subPhase Current sub-phase name for display + * @param current Files processed so far (during extract) or total files (at phase boundaries) + * @param total Total files in this language + */ + readonly onProgress?: (subPhase: ScopeResolutionSubPhase, current: number, total: number) => void; } interface RunScopeResolutionStats { @@ -207,7 +223,10 @@ export function runScopeResolution( const treeCache = input.treeCache; const preExtracted = input.preExtractedParsedFiles; let preExtractedHits = 0; - for (const file of files) { + const progressInterval = files.length > 0 ? Math.max(1, Math.floor(files.length / 50)) : 1; + input.onProgress?.('extracting', 0, files.length); + for (let fileIdx = 0; fileIdx < files.length; fileIdx++) { + const file = files[fileIdx]; let parsed: ParsedFile | undefined; // Fast path: a worker (during the parse phase) already produced a // ParsedFile for this file via `extractParsedFile`. Reuse it @@ -232,6 +251,12 @@ export function runScopeResolution( } provider.populateOwners(parsed); parsedFiles.push(parsed); + if ( + input.onProgress && + ((fileIdx + 1) % progressInterval === 0 || fileIdx === files.length - 1) + ) { + input.onProgress('extracting', fileIdx + 1, files.length); + } } if (PROF && preExtracted !== undefined) { logger.warn(`[scope-resolution prof] pre-extracted hits: ${preExtractedHits}/${files.length}`); @@ -267,6 +292,7 @@ export function runScopeResolution( const tExtract = PROF ? process.hrtime.bigint() : 0n; // ── Phase 2: finalize → ScopeResolutionIndexes ───────────────────────── + input.onProgress?.('analyzing types', files.length, files.length); const allFilePaths = new Set(parsedFiles.map((f) => f.filePath)); const nodeLookup = buildGraphNodeLookup(graph); @@ -350,6 +376,7 @@ export function runScopeResolution( validateBindingsImmutability(indexes, onWarn); // ── Phase 3: resolve references via Registry.lookup ──────────────────── + input.onProgress?.('resolving references', files.length, files.length); const registryProviders: RegistryProviders = { arityCompatibility: provider.arityCompatibility, }; @@ -362,6 +389,7 @@ export function runScopeResolution( const tResolve = PROF ? process.hrtime.bigint() : 0n; // ── Phase 4: emit graph edges (LOAD-BEARING ORDER — see I1) ──────────── + input.onProgress?.('linking symbols', files.length, files.length); const handledSites = new Set(preEmittedInheritanceSites); const receiverExtras = emitReceiverBoundCalls( graph, diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 22405a026..2e42b3c8e 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -163,6 +163,7 @@ export const PHASE_LABELS: Record = { imports: 'Resolving imports', calls: 'Tracing calls', heritage: 'Extracting inheritance', + scopeResolution: 'Resolving types', communities: 'Detecting communities', processes: 'Detecting processes', complete: 'Pipeline complete', diff --git a/gitnexus/test/unit/format-elapsed.test.ts b/gitnexus/test/unit/format-elapsed.test.ts new file mode 100644 index 000000000..17f799299 --- /dev/null +++ b/gitnexus/test/unit/format-elapsed.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { formatElapsed } from '../../src/cli/format-elapsed.js'; + +describe('formatElapsed', () => { + it('formats 0 seconds', () => { + expect(formatElapsed(0)).toBe('0s'); + }); + + it('formats seconds below 60', () => { + expect(formatElapsed(1)).toBe('1s'); + expect(formatElapsed(59)).toBe('59s'); + }); + + it('formats exactly 60 seconds as 1m 0s', () => { + expect(formatElapsed(60)).toBe('1m 0s'); + }); + + it('formats minutes and seconds', () => { + expect(formatElapsed(61)).toBe('1m 1s'); + expect(formatElapsed(125)).toBe('2m 5s'); + }); + + it('formats the last second before an hour', () => { + expect(formatElapsed(3599)).toBe('59m 59s'); + }); + + it('formats exactly 3600 seconds as 1h 0m', () => { + expect(formatElapsed(3600)).toBe('1h 0m'); + }); + + it('formats hours and minutes', () => { + expect(formatElapsed(3661)).toBe('1h 1m'); + expect(formatElapsed(7200)).toBe('2h 0m'); + expect(formatElapsed(7323)).toBe('2h 2m'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/run-progress.test.ts b/gitnexus/test/unit/scope-resolution/run-progress.test.ts new file mode 100644 index 000000000..45bbfe9d7 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/run-progress.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import type { ParsedFile, ScopeId, Scope } from 'gitnexus-shared'; +import { + runScopeResolution, + type ScopeResolutionSubPhase, +} from '../../../src/core/ingestion/scope-resolution/pipeline/run.js'; +import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; +import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; +import type { ScopeResolver } from '../../../src/core/ingestion/scope-resolution/contract/scope-resolver.js'; + +const mkScope = (id: ScopeId, filePath: string): Scope => ({ + id, + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 10, endCol: 0 }, + filePath, + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map(), +}); + +const mkFile = (filePath: string): ParsedFile => ({ + filePath, + moduleScope: `scope:${filePath}#module`, + scopes: [mkScope(`scope:${filePath}#module`, filePath)], + parsedImports: [], + localDefs: [], + referenceSites: [], +}); + +const stubProvider = { + language: 'python' as const, + languageProvider: {} as ScopeResolver['languageProvider'], + importEdgeReason: 'test', + populateOwners: () => {}, + resolveImportTarget: () => null, + mergeBindings: (existing: unknown) => existing, + buildMro: () => new Map(), + propagatesReturnTypesAcrossImports: false, +} as unknown as ScopeResolver; + +describe('runScopeResolution onProgress', () => { + it('emits sub-phases in order for a 3-file input', () => { + const files = [ + { path: 'a.py', content: '' }, + { path: 'b.py', content: '' }, + { path: 'c.py', content: '' }, + ]; + const preExtracted = new Map(); + for (const f of files) preExtracted.set(f.path, mkFile(f.path)); + + const calls: { subPhase: ScopeResolutionSubPhase; current: number; total: number }[] = []; + const onProgress = (subPhase: ScopeResolutionSubPhase, current: number, total: number) => { + calls.push({ subPhase, current, total }); + }; + + runScopeResolution( + { + graph: createKnowledgeGraph(), + model: createSemanticModel(), + files, + preExtractedParsedFiles: preExtracted, + onProgress, + }, + stubProvider, + ); + + const subPhases = calls.map((c) => c.subPhase); + expect(subPhases).toContain('extracting'); + expect(subPhases).toContain('analyzing types'); + expect(subPhases).toContain('resolving references'); + expect(subPhases).toContain('linking symbols'); + + const extractCalls = calls.filter((c) => c.subPhase === 'extracting'); + expect(extractCalls.length).toBeGreaterThan(0); + expect(extractCalls[0].total).toBe(3); + expect(extractCalls[0].current).toBe(0); + expect(extractCalls[extractCalls.length - 1].current).toBe(3); + + const analyzeIdx = subPhases.indexOf('analyzing types'); + const resolveIdx = subPhases.indexOf('resolving references'); + const linkIdx = subPhases.indexOf('linking symbols'); + expect(analyzeIdx).toBeLessThan(resolveIdx); + expect(resolveIdx).toBeLessThan(linkIdx); + }); + + it('emits only extracting (0, 0) then returns early for 0-file input', () => { + const calls: { subPhase: ScopeResolutionSubPhase; current: number; total: number }[] = []; + const onProgress = (subPhase: ScopeResolutionSubPhase, current: number, total: number) => { + calls.push({ subPhase, current, total }); + }; + + const stats = runScopeResolution( + { + graph: createKnowledgeGraph(), + model: createSemanticModel(), + files: [], + onProgress, + }, + stubProvider, + ); + + expect(stats.filesProcessed).toBe(0); + expect(calls).toEqual([{ subPhase: 'extracting', current: 0, total: 0 }]); + }); +});