diff --git a/.changeset/calm-filters-count.md b/.changeset/calm-filters-count.md new file mode 100644 index 0000000000..cff4326505 --- /dev/null +++ b/.changeset/calm-filters-count.md @@ -0,0 +1,6 @@ +--- +"@codegraphy-dev/core": patch +"@codegraphy-dev/extension": patch +--- + +Persist discovery-time Filter and Git-ignore accounting, restore it from the Graph Cache, and report Filter-excluded workspace files separately from Nodes excluded in the Graph View. diff --git a/packages/core/src/analysis/workspaceAnalyze.ts b/packages/core/src/analysis/workspaceAnalyze.ts index 977c2a6792..a248b66b5c 100644 --- a/packages/core/src/analysis/workspaceAnalyze.ts +++ b/packages/core/src/analysis/workspaceAnalyze.ts @@ -1,5 +1,5 @@ import type { IFileAnalysisResult, IPluginNodeType } from '@codegraphy-dev/plugin-api'; -import type { IDiscoveredFile } from '../discovery/contracts'; +import type { IDiscoveredFile, WorkspaceFilterAccounting } from '../discovery/contracts'; import type { IGraphData } from '../graph/contracts'; import { throwIfWorkspaceAnalysisAborted } from './abort'; import type { IWorkspaceFileAnalysisResult } from './fileAnalysis'; @@ -41,6 +41,7 @@ export interface WorkspacePipelineAnalysisSource { _completeGraphData?: IGraphData; _lastDiscoveredDirectories: string[]; _lastDiscoveredFiles: IDiscoveredFile[]; + _filterAccounting: WorkspaceFilterAccounting; _lastFileAnalysis: Map; _lastFileConnections: Map; _lastGitIgnoredPaths?: string[]; @@ -164,6 +165,7 @@ export async function analyzeWorkspaceWithAnalyzer( source._lastFileConnections = analysisResult.fileConnections; source._lastDiscoveredDirectories = discoveryResult.directories ?? []; source._lastDiscoveredFiles = discoveryResult.files; + source._filterAccounting = discoveryResult.filterAccounting; source._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; source._lastWorkspaceRoot = workspaceRoot; diff --git a/packages/core/src/analysis/workspaceDiscovery.ts b/packages/core/src/analysis/workspaceDiscovery.ts index 45fa7b578c..4a7b934b3a 100644 --- a/packages/core/src/analysis/workspaceDiscovery.ts +++ b/packages/core/src/analysis/workspaceDiscovery.ts @@ -1,3 +1,4 @@ +import type { WorkspaceFilterAccounting } from '../discovery/contracts'; import { DEFAULT_EXCLUDE } from '../discovery/pathMatching'; export interface WorkspacePipelineDiscoveryConfig { @@ -10,6 +11,7 @@ export interface WorkspacePipelineDiscoveryResult { directories?: string[]; durationMs: number; files: TFile[]; + filterAccounting: Extract; gitIgnoredPaths?: string[]; limitReached: boolean; totalFound: number; diff --git a/packages/core/src/discovery/contracts.ts b/packages/core/src/discovery/contracts.ts index 93da533cd0..f2b79d79b4 100644 --- a/packages/core/src/discovery/contracts.ts +++ b/packages/core/src/discovery/contracts.ts @@ -43,12 +43,22 @@ export interface IDiscoveredFile { gitIgnored?: boolean; } +export type WorkspaceFilterAccounting = + | { + kind: 'current'; + excludedFileCount: number; + gitIgnoredPathCount: number; + } + | { kind: 'unavailable' }; + /** * Result of a discovery operation. */ export interface IDiscoveryResult { /** Eligible discovered files, capped by maxFiles */ files: IDiscoveredFile[]; + /** Filter and Git ignored state accounting captured by this discovery. */ + filterAccounting: Extract; /** Discovered directory paths relative to the workspace root */ directories: string[]; /** Discovered file and directory paths reported by Git as ignored */ diff --git a/packages/core/src/discovery/file/service.ts b/packages/core/src/discovery/file/service.ts index cbfe8ef3e3..00f735eac9 100644 --- a/packages/core/src/discovery/file/service.ts +++ b/packages/core/src/discovery/file/service.ts @@ -305,6 +305,12 @@ export class FileDiscovery { directories, ) : new Set(); + const filterExcludedFileCount = candidateFiles + .filter(file => ( + !gitIgnoredPaths.has(file.relativePath) + && matchesAnyPattern(file.relativePath, filterPatterns) + )) + .length; const eligibleFiles = candidateFiles.filter(file => ( !matchesAnyPattern(file.relativePath, filterPatterns) && !gitIgnoredPaths.has(file.relativePath) @@ -339,6 +345,11 @@ export class FileDiscovery { cacheFilePaths, cachePathPrefixes: gitWorkspacePaths?.ignoredPathPrefixes ?? [], directories: eligibleDirectories, + filterAccounting: { + kind: 'current', + excludedFileCount: filterExcludedFileCount, + gitIgnoredPathCount: gitIgnoredPaths.size + (gitWorkspacePaths?.ignoredPathPrefixes.length ?? 0), + }, gitIgnoredPaths: [ ...gitIgnoredPaths, ...(gitWorkspacePaths?.ignoredPathPrefixes ?? []), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 41ab7558c9..07bf427a4f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -111,6 +111,7 @@ export type { IDiscoveredFile, IDiscoveryOptions, IDiscoveryResult, + WorkspaceFilterAccounting, } from './discovery/contracts'; export { createAbortError, throwIfAborted } from './discovery/abort'; export { FileDiscovery } from './discovery/file/service'; diff --git a/packages/core/src/indexing/engineGraph.ts b/packages/core/src/indexing/engineGraph.ts index 3ee4584fa2..d323ec91d8 100644 --- a/packages/core/src/indexing/engineGraph.ts +++ b/packages/core/src/indexing/engineGraph.ts @@ -77,6 +77,7 @@ async function persistMetadata(runtime: WorkspaceEngineRuntime): Promise { registry: state.registry, }); await persistWorkspaceIndexMetadata({ + filterAccounting: state.filterAccounting, pluginBuildSignature: createWorkspaceIndexPluginBuildSignature(state.loadedPackagePlugins), pluginSignature, failedPluginIds: state.failedPluginIds, diff --git a/packages/core/src/indexing/engineSetup.ts b/packages/core/src/indexing/engineSetup.ts index f0187935a9..98a268457b 100644 --- a/packages/core/src/indexing/engineSetup.ts +++ b/packages/core/src/indexing/engineSetup.ts @@ -80,6 +80,7 @@ export async function discoverWorkspaceEngineFiles( }); state.discoveredDirectories = state.discoveryResult.directories ?? []; state.discoveredFiles = state.discoveryResult.files; + state.filterAccounting = state.discoveryResult.filterAccounting; } export function createWorkspaceEngineDisabledPlugins(runtime: WorkspaceEngineRuntime): Set { diff --git a/packages/core/src/indexing/metadata.ts b/packages/core/src/indexing/metadata.ts index 55fb016472..b8930004dd 100644 --- a/packages/core/src/indexing/metadata.ts +++ b/packages/core/src/indexing/metadata.ts @@ -9,6 +9,7 @@ import { createCodeGraphyWorkspaceSettingsSignature, } from '../workspace/signatures'; import type { CodeGraphyWorkspaceSettings } from '../workspace/settings'; +import type { WorkspaceFilterAccounting } from '../discovery/contracts'; import type { IndexCodeGraphyWorkspacePlugin } from './contracts'; function runtimeSignaturePlugins(registry: CorePluginRegistry): IPlugin[] { @@ -70,6 +71,7 @@ export function createWorkspaceIndexPluginBuildSignature( } export async function persistWorkspaceIndexMetadata(input: { + filterAccounting: WorkspaceFilterAccounting; pluginSignature: string | null; pluginBuildSignature: string | null; failedPluginIds: ReadonlySet; @@ -79,6 +81,7 @@ export async function persistWorkspaceIndexMetadata(input: { workspaceRoot: string; }): Promise { await persistCodeGraphyWorkspaceIndexMetadata(input.workspaceRoot, { + filterAccounting: input.filterAccounting, pluginSignature: input.pluginSignature, pluginBuildSignature: input.pluginBuildSignature, failedPluginIds: [...input.failedPluginIds].sort((left, right) => left.localeCompare(right)), diff --git a/packages/core/src/indexing/state.ts b/packages/core/src/indexing/state.ts index f49dd477a6..93b0539bb5 100644 --- a/packages/core/src/indexing/state.ts +++ b/packages/core/src/indexing/state.ts @@ -1,13 +1,14 @@ import { createEmptyWorkspaceAnalysisCache, type IWorkspaceAnalysisCache } from '../analysis/cache'; import type { IProjectedConnection } from '../analysis/projectedConnection'; import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; -import type { IDiscoveredFile } from '../discovery/contracts'; +import type { IDiscoveredFile, WorkspaceFilterAccounting } from '../discovery/contracts'; import type { IGraphData } from '../graph/contracts'; export interface WorkspaceIndexEngineState { cache: IWorkspaceAnalysisCache; discoveredDirectories: string[]; discoveredFiles: IDiscoveredFile[]; + filterAccounting: WorkspaceFilterAccounting; gitIgnoredPaths: string[]; fileAnalysis: Map; fileConnections: Map; @@ -22,6 +23,7 @@ export function createWorkspaceIndexEngineState( cache, discoveredDirectories: [], discoveredFiles: [], + filterAccounting: { kind: 'unavailable' }, gitIgnoredPaths: [], fileAnalysis: new Map(), fileConnections: new Map(), diff --git a/packages/core/src/indexing/workspace.ts b/packages/core/src/indexing/workspace.ts index a4815ea514..c9774954e1 100644 --- a/packages/core/src/indexing/workspace.ts +++ b/packages/core/src/indexing/workspace.ts @@ -383,6 +383,7 @@ export async function indexCodeGraphyWorkspace( }, recovery); } await persistWorkspaceIndexMetadata({ + filterAccounting: discoveryResult.filterAccounting, pluginBuildSignature, pluginSignature, failedPluginIds, diff --git a/packages/core/src/workspace/meta.ts b/packages/core/src/workspace/meta.ts index c5bce63409..fba401e871 100644 --- a/packages/core/src/workspace/meta.ts +++ b/packages/core/src/workspace/meta.ts @@ -3,6 +3,7 @@ import * as path from 'node:path'; import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { WORKSPACE_ANALYSIS_CACHE_VERSION } from '../analysis/cache'; +import type { WorkspaceFilterAccounting } from '../discovery/contracts'; import { looseStringArraySchema } from '../values'; import { getWorkspaceMetaPath } from './paths'; import { getWorkspaceAnalysisDatabasePath } from '../graphCache/database/storage'; @@ -13,7 +14,7 @@ import { } from '../graphCache/database/writeCoordination/model'; export interface CodeGraphyWorkspaceMeta { - version: 1; + version: 2; lastIndexedAt: string | null; lastIndexedCommit?: string | null; pluginSignature: string | null; @@ -22,11 +23,21 @@ export interface CodeGraphyWorkspaceMeta { analysisVersion: string | null; pendingChangedFiles: string[]; failedPluginIds: string[]; + filterAccounting: WorkspaceFilterAccounting; } const optionalNullableStringSchema = z.union([z.string(), z.null()]).optional().catch(undefined); +const filterAccountingSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('current'), + excludedFileCount: z.number().int().nonnegative(), + gitIgnoredPathCount: z.number().int().nonnegative(), + }), + z.object({ kind: z.literal('unavailable') }), +]); const codeGraphyWorkspaceMetaSchema = z.looseObject({ + version: z.literal(2), analysisVersion: optionalNullableStringSchema, lastIndexedAt: optionalNullableStringSchema, lastIndexedCommit: optionalNullableStringSchema, @@ -35,6 +46,7 @@ const codeGraphyWorkspaceMetaSchema = z.looseObject({ pluginSignature: optionalNullableStringSchema, pluginBuildSignature: optionalNullableStringSchema, settingsSignature: optionalNullableStringSchema, + filterAccounting: filterAccountingSchema, }).transform((meta): CodeGraphyWorkspaceMeta => ({ ...createDefaultCodeGraphyWorkspaceMeta(), ...(meta.analysisVersion !== undefined ? { analysisVersion: meta.analysisVersion } : {}), @@ -45,12 +57,13 @@ const codeGraphyWorkspaceMetaSchema = z.looseObject({ ...(meta.settingsSignature !== undefined ? { settingsSignature: meta.settingsSignature } : {}), pendingChangedFiles: meta.pendingChangedFiles, failedPluginIds: meta.failedPluginIds, - version: 1, + filterAccounting: meta.filterAccounting, + version: 2, })); export function createDefaultCodeGraphyWorkspaceMeta(): CodeGraphyWorkspaceMeta { return { - version: 1, + version: 2, lastIndexedAt: null, lastIndexedCommit: null, pluginSignature: null, @@ -59,6 +72,7 @@ export function createDefaultCodeGraphyWorkspaceMeta(): CodeGraphyWorkspaceMeta analysisVersion: WORKSPACE_ANALYSIS_CACHE_VERSION, pendingChangedFiles: [], failedPluginIds: [], + filterAccounting: { kind: 'unavailable' }, }; } @@ -148,6 +162,7 @@ export async function persistCodeGraphyWorkspaceIndexMetadata( pluginBuildSignature?: string | null; settingsSignature: string; failedPluginIds?: readonly string[]; + filterAccounting: WorkspaceFilterAccounting; resolvedChangedFilePaths?: readonly string[]; }, ): Promise { @@ -171,5 +186,6 @@ export async function persistCodeGraphyWorkspaceIndexMetadata( failedPluginIds: metadata.failedPluginIds === undefined ? previous.failedPluginIds : [...metadata.failedPluginIds], + filterAccounting: metadata.filterAccounting, })); } diff --git a/packages/core/tests/analysis/workspaceAnalyze.test.ts b/packages/core/tests/analysis/workspaceAnalyze.test.ts index 360efb3f3e..5aaf662659 100644 --- a/packages/core/tests/analysis/workspaceAnalyze.test.ts +++ b/packages/core/tests/analysis/workspaceAnalyze.test.ts @@ -36,6 +36,7 @@ function createSource() { _completeGraphData: undefined as IGraphData | undefined, _lastDiscoveredDirectories: [] as string[], _lastDiscoveredFiles: [] as IDiscoveredFile[], + _filterAccounting: { kind: 'unavailable' as const }, _lastFileAnalysis: new Map(), _lastFileConnections: new Map(), _lastGitIgnoredPaths: [] as string[], @@ -51,6 +52,7 @@ function createDependencies() { directories: [] as string[], durationMs: 3, files: [] as IDiscoveredFile[], + filterAccounting: { kind: 'current' as const, excludedFileCount: 0, gitIgnoredPathCount: 0 }, gitIgnoredPaths: [] as string[], limitReached: false, totalFound: 0, @@ -134,6 +136,7 @@ describe('pipeline/analysis/analyze', () => { directories: ['src/new-folder'], durationMs: 4, files, + filterAccounting: { kind: 'current', excludedFileCount: 1, gitIgnoredPathCount: 1 }, gitIgnoredPaths: ['src/index.ts'], limitReached: false, totalFound: 1, @@ -185,6 +188,11 @@ describe('pipeline/analysis/analyze', () => { new Set(['plugin.python']), ); expect(source._lastDiscoveredFiles).toEqual(files); + expect(source._filterAccounting).toEqual({ + kind: 'current', + excludedFileCount: 1, + gitIgnoredPathCount: 1, + }); expect(source._lastDiscoveredDirectories).toEqual(['src/new-folder']); expect(source._lastGitIgnoredPaths).toEqual(['src/index.ts']); expect(source._lastFileAnalysis).toBe(fileAnalysis); @@ -254,6 +262,7 @@ describe('pipeline/analysis/analyze', () => { directories: [], durationMs: 5, files: [] as IDiscoveredFile[], + filterAccounting: { kind: 'current', excludedFileCount: 0, gitIgnoredPathCount: 0 }, limitReached: true, totalFound: 27, }); @@ -276,6 +285,7 @@ describe('pipeline/analysis/analyze', () => { directories: [], durationMs: 4, files, + filterAccounting: { kind: 'current', excludedFileCount: 0, gitIgnoredPathCount: 0 }, limitReached: false, totalFound: 1, }); @@ -330,6 +340,7 @@ describe('pipeline/analysis/analyze', () => { directories: [], durationMs: 4, files: [] as IDiscoveredFile[], + filterAccounting: { kind: 'current' as const, excludedFileCount: 0, gitIgnoredPathCount: 0 }, limitReached: false, totalFound: 0, }; @@ -358,6 +369,7 @@ describe('pipeline/analysis/analyze', () => { directories: [], durationMs: 2, files: [] as IDiscoveredFile[], + filterAccounting: { kind: 'current', excludedFileCount: 0, gitIgnoredPathCount: 0 }, limitReached: false, totalFound: 0, }); diff --git a/packages/core/tests/analysis/workspaceDiscovery.test.ts b/packages/core/tests/analysis/workspaceDiscovery.test.ts index d946107ff7..fff4db6035 100644 --- a/packages/core/tests/analysis/workspaceDiscovery.test.ts +++ b/packages/core/tests/analysis/workspaceDiscovery.test.ts @@ -10,6 +10,7 @@ describe('pipeline/discovery', () => { const discover = vi.fn(async () => ({ durationMs: 2, files: ['src/index.ts'], + filterAccounting: { kind: 'current' as const, excludedFileCount: 0, gitIgnoredPathCount: 0 }, limitReached: false, totalFound: 1, })); diff --git a/packages/core/tests/discovery/file/discovery.discover.test.ts b/packages/core/tests/discovery/file/discovery.discover.test.ts index 72e5597236..c2374735c0 100644 --- a/packages/core/tests/discovery/file/discovery.discover.test.ts +++ b/packages/core/tests/discovery/file/discovery.discover.test.ts @@ -83,9 +83,45 @@ describe('FileDiscovery discover', () => { }); expect(result.files.map(file => file.relativePath)).toEqual([path.join('src', 'app.ts')]); + expect(result.filterAccounting).toEqual({ + kind: 'current', + excludedFileCount: 1, + gitIgnoredPathCount: 27, + }); expect(onProgress.mock.calls.map(([progress]) => progress.current)).toEqual([1]); }); + it('accounts for mixed TypeScript, Godot, and Unity plugin Filters separately from Git ignored state', async () => { + initGitRepo(); + createFile('.gitignore', 'ignored/**\n'); + createFile('src/app.ts'); + createFile('dist/app.js'); + createFile('.godot/editor/project_metadata.cfg'); + createFile('Assets/Player.prefab'); + createFile('Assets/Player.prefab.meta'); + createFile('ProjectSettings/ProjectSettings.asset'); + createFile('ignored/generated.ts'); + + const result = await discovery.discover({ + rootPath: tempDir, + filter: [ + '**/dist/**', + '**/.godot/**', + '**/*.meta', + '**/[Pp]roject[Ss]ettings/**', + ], + }); + + expect(result.filterAccounting).toEqual({ + kind: 'current', + excludedFileCount: 3, + gitIgnoredPathCount: 2, + }); + expect(result.files.map(file => file.relativePath)).toContain('src/app.ts'); + expect(result.files.map(file => file.relativePath)).toContain('Assets/Player.prefab'); + expect(result.gitIgnoredPaths).toContain('ignored'); + }); + it('uses Git paths without recursively reading ignored or filtered workspace trees', async () => { initGitRepo(); createFile('.gitignore', 'ignored/**\n'); diff --git a/packages/core/tests/indexing/analysis.test.ts b/packages/core/tests/indexing/analysis.test.ts index 0d8d2ab1eb..3d6e4b5fbb 100644 --- a/packages/core/tests/indexing/analysis.test.ts +++ b/packages/core/tests/indexing/analysis.test.ts @@ -51,6 +51,7 @@ describe('indexing/analysis', () => { discoveryResult: { durationMs: 1, files, + filterAccounting: { kind: 'current', excludedFileCount: 0, gitIgnoredPathCount: 0 }, directories: [], gitIgnoredPaths: [], limitReached: false, diff --git a/packages/core/tests/workspace/meta.concurrent.test.ts b/packages/core/tests/workspace/meta.concurrent.test.ts index d1d1ccf185..debc2134d6 100644 --- a/packages/core/tests/workspace/meta.concurrent.test.ts +++ b/packages/core/tests/workspace/meta.concurrent.test.ts @@ -88,6 +88,7 @@ describe('workspace metadata concurrency', () => { reportOwnership?.(); await ownedCommitGate; await persistCodeGraphyWorkspaceIndexMetadata(workspaceRoot, { + filterAccounting: { kind: 'current', excludedFileCount: 2, gitIgnoredPathCount: 1 }, pluginSignature: 'plugins-sha', settingsSignature: 'settings-sha', resolvedChangedFilePaths: ['src/resolved.ts'], diff --git a/packages/core/tests/workspace/meta.test.ts b/packages/core/tests/workspace/meta.test.ts index 4a9f7f050f..f4ccb2cbd5 100644 --- a/packages/core/tests/workspace/meta.test.ts +++ b/packages/core/tests/workspace/meta.test.ts @@ -42,7 +42,7 @@ describe('workspace/meta', () => { it('writes and reads workspace metadata', () => { const workspaceRoot = createTempWorkspace(); const meta = { - version: 1 as const, + version: 2 as const, lastIndexedAt: '2026-04-08T19:00:00.000Z', lastIndexedCommit: 'abc123', pluginSignature: 'codegraphy.markdown@1.0.0', @@ -51,6 +51,7 @@ describe('workspace/meta', () => { analysisVersion: null, pendingChangedFiles: ['src/index.ts'], failedPluginIds: ['acme.failed'], + filterAccounting: { kind: 'current' as const, excludedFileCount: 3, gitIgnoredPathCount: 2 }, }; writeWorkspaceMetaFixture(workspaceRoot, meta); @@ -110,6 +111,7 @@ describe('workspace/meta', () => { }); await persistCodeGraphyWorkspaceIndexMetadata(workspaceRoot, { + filterAccounting: { kind: 'current', excludedFileCount: 2, gitIgnoredPathCount: 1 }, pluginSignature: 'plugins-sha', settingsSignature: 'settings-sha', resolvedChangedFilePaths: ['src/resolved.ts'], @@ -122,7 +124,7 @@ describe('workspace/meta', () => { }); }); - it('filters invalid persisted metadata fields through the workspace meta schema', () => { + it('requires re-indexing when persisted metadata cannot provide current accounting', () => { const workspaceRoot = createTempWorkspace(); const metaPath = getWorkspaceMetaPath(workspaceRoot); @@ -141,14 +143,7 @@ describe('workspace/meta', () => { 'utf8', ); - expect(readCodeGraphyWorkspaceMeta(workspaceRoot)).toEqual({ - ...createDefaultCodeGraphyWorkspaceMeta(), - analysisVersion: null, - pluginSignature: 'plugins-sha', - pendingChangedFiles: ['src/app.ts', 'src/index.ts'], - failedPluginIds: ['acme.failed'], - version: 1, - }); + expect(readCodeGraphyWorkspaceMeta(workspaceRoot)).toEqual(createDefaultCodeGraphyWorkspaceMeta()); }); it('falls back to defaults when metadata JSON is invalid', () => { diff --git a/packages/extension/src/extension/graphView/analysis/execution.ts b/packages/extension/src/extension/graphView/analysis/execution.ts index 2a3010d660..3538d30d76 100644 --- a/packages/extension/src/extension/graphView/analysis/execution.ts +++ b/packages/extension/src/extension/graphView/analysis/execution.ts @@ -1,5 +1,9 @@ import type { IGraphData } from '../../../shared/graph/contracts'; -import type { AnalysisCacheTier, DiagnosticEventInput } from '@codegraphy-dev/core'; +import type { + AnalysisCacheTier, + DiagnosticEventInput, + WorkspaceFilterAccounting, +} from '@codegraphy-dev/core'; import { publishAnalysisFailure } from './execution/publish'; import { prepareGraphViewAnalysis } from './execution/prepare'; import { runGraphViewAnalysis } from './execution/run'; @@ -12,6 +16,7 @@ interface GraphViewAnalyzerLike { initialize(): Promise; syncWorkspacePlugins(): Promise; hasIndex(): boolean; + getFilterAccounting(): WorkspaceFilterAccounting; getIndexStatus?(): { freshness: CodeGraphyIndexFreshness; detail: string; diff --git a/packages/extension/src/extension/graphView/controls/send.ts b/packages/extension/src/extension/graphView/controls/send.ts index bfd011084d..cfe7fe81c8 100644 --- a/packages/extension/src/extension/graphView/controls/send.ts +++ b/packages/extension/src/extension/graphView/controls/send.ts @@ -55,6 +55,9 @@ export function sendGraphControlsUpdated( disabledPlugins: ReadonlySet = new Set(), ): void { const registry = analyzer?.registry; + const filterAccounting = analyzer + ? analyzer.getFilterAccounting() + : { kind: 'unavailable' as const }; const controlsGraphData = filterDisabledPluginEdgesForControls(graphData, disabledPlugins); const filePaths = controlsGraphData.nodes .filter(isFileNode) @@ -67,6 +70,7 @@ export function sendGraphControlsUpdated( controlsGraphData, readNodeTypes(registry, disabledPlugins), readEdgeTypes(registry, disabledPlugins), + filterAccounting, readGraphScopeCapabilities(registry, filePaths, disabledPlugins), ), ), diff --git a/packages/extension/src/extension/graphView/controls/send/definitions/contracts.ts b/packages/extension/src/extension/graphView/controls/send/definitions/contracts.ts index 80b3136b0a..ecd2bcd719 100644 --- a/packages/extension/src/extension/graphView/controls/send/definitions/contracts.ts +++ b/packages/extension/src/extension/graphView/controls/send/definitions/contracts.ts @@ -1,4 +1,5 @@ import type { GraphEdgeKind } from '../../../../../shared/graph/contracts'; +import type { WorkspaceFilterAccounting } from '@codegraphy-dev/core'; export interface GraphNodeTypeLike { id: string; @@ -29,6 +30,7 @@ export interface GraphTypeDescriptionLike { export interface GraphControlsAnalyzerLike { registry?: unknown; + getFilterAccounting(): WorkspaceFilterAccounting; } export interface GraphControlsConfigurationLike { diff --git a/packages/extension/src/extension/graphView/controls/send/definitions/snapshot.ts b/packages/extension/src/extension/graphView/controls/send/definitions/snapshot.ts index de29a0878c..122a40fd00 100644 --- a/packages/extension/src/extension/graphView/controls/send/definitions/snapshot.ts +++ b/packages/extension/src/extension/graphView/controls/send/definitions/snapshot.ts @@ -1,5 +1,6 @@ import type { IGraphData } from '../../../../../shared/graph/contracts'; import type { IGraphControlsSnapshot } from '../../../../../shared/graphControls/contracts'; +import type { WorkspaceFilterAccounting } from '@codegraphy-dev/core'; import { mergeEdgeTypes, mergeNodeTypes } from './merge'; import { resolveNodeColors, @@ -17,6 +18,7 @@ export function captureGraphControlsSnapshot( graphData: IGraphData, pluginNodeTypes: GraphNodeTypeLike[], pluginEdgeTypes: GraphEdgeTypeLike[], + filterAccounting: WorkspaceFilterAccounting, graphScopeCapabilities: GraphScopeCapabilitiesLike = { nodeTypes: [], edgeTypes: [] }, ): IGraphControlsSnapshot { const configuredNodeColors = config.get>('nodeColors', {}) ?? {}; @@ -31,6 +33,7 @@ export function captureGraphControlsSnapshot( const edgeTypes = mergeEdgeTypes(graphData, pluginEdgeTypes, graphScopeCapabilities.edgeTypes); return { + filterAccounting, nodeTypes, edgeTypes, nodeColors: resolveNodeColors(nodeTypes, configuredNodeColors), diff --git a/packages/extension/src/extension/graphView/provider/analysis/methods.ts b/packages/extension/src/extension/graphView/provider/analysis/methods.ts index 82e1955763..effdbceae6 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/methods.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/methods.ts @@ -1,4 +1,5 @@ import type { IGraphData } from '../../../../shared/graph/contracts'; +import type { WorkspaceFilterAccounting } from '@codegraphy-dev/core'; import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/extensionToWebview'; import type { GraphViewIndexingProgress } from '../../analysis/execution'; import type { GraphViewProviderAnalysisState } from '../../analysis/lifecycle'; @@ -31,6 +32,7 @@ export interface GraphViewProviderWorkspaceReadyRegistryLike { interface GraphViewProviderAnalysisAnalyzerLike { registry?: GraphViewProviderWorkspaceReadyRegistryLike; + getFilterAccounting(): WorkspaceFilterAccounting; } export interface GraphViewProviderAnalysisMethodsSource { diff --git a/packages/extension/src/extension/graphView/provider/plugin/methods.ts b/packages/extension/src/extension/graphView/provider/plugin/methods.ts index 94e5585ace..fb643b4f15 100644 --- a/packages/extension/src/extension/graphView/provider/plugin/methods.ts +++ b/packages/extension/src/extension/graphView/provider/plugin/methods.ts @@ -4,6 +4,7 @@ import type { ViewRegistry } from '../../../../core/views/registry'; import type { IGraphData } from '../../../../shared/graph/contracts'; import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/extensionToWebview'; import type { IGroup } from '../../../../shared/settings/groups'; +import type { WorkspaceFilterAccounting } from '@codegraphy-dev/core'; import { sendGraphViewPluginWebviewInjections } from '../../webview/plugins/contributionDispatch'; import { sendGraphViewDecorations, @@ -19,7 +20,8 @@ const DEFAULT_DEPTH_LIMIT = 1; type GraphViewPluginAnalyzerLike = NonNullable[0]> - & NonNullable[0]>; + & NonNullable[0]> + & { getFilterAccounting(): WorkspaceFilterAccounting }; type GraphViewDecorationManagerLike = Parameters[0]; diff --git a/packages/extension/src/extension/graphView/provider/settingsState.ts b/packages/extension/src/extension/graphView/provider/settingsState.ts index 05fce6810b..920886d55e 100644 --- a/packages/extension/src/extension/graphView/provider/settingsState.ts +++ b/packages/extension/src/extension/graphView/provider/settingsState.ts @@ -13,9 +13,11 @@ import { captureGraphViewSettingsSnapshot } from '../settings/snapshot'; import { sendGraphViewProviderAllSettings, sendGraphViewProviderSettings } from '../settings/lifecycle'; import { sendGraphControlsUpdated } from '../controls/send'; import type { IPluginFilterPatternGroup } from '../../../shared/protocol/extensionToWebview'; +import type { WorkspaceFilterAccounting } from '@codegraphy-dev/core'; import { resolveCssSnippetStylesheets } from '../cssSnippets/resolve'; interface GraphViewProviderSettingsAnalyzerLike { + getFilterAccounting(): WorkspaceFilterAccounting; getPluginFilterPatterns(): string[]; getPluginFilterGroups?(disabledPlugins?: ReadonlySet): IPluginFilterPatternGroup[]; registry?: unknown; diff --git a/packages/extension/src/extension/pipeline/analysis/run.ts b/packages/extension/src/extension/pipeline/analysis/run.ts index 833a9b2064..edd7de08ba 100644 --- a/packages/extension/src/extension/pipeline/analysis/run.ts +++ b/packages/extension/src/extension/pipeline/analysis/run.ts @@ -35,6 +35,7 @@ export function runWorkspacePipelineAnalysis( directories: result.directories, durationMs: result.durationMs, files: result.files, + filterAccounting: result.filterAccounting, gitIgnoredPaths: result.gitIgnoredPaths, limitReached: result.limitReached, totalFound: result.totalFound ?? result.files.length, diff --git a/packages/extension/src/extension/pipeline/analysisSource.ts b/packages/extension/src/extension/pipeline/analysisSource.ts index 54a386d164..b58b98a3ff 100644 --- a/packages/extension/src/extension/pipeline/analysisSource.ts +++ b/packages/extension/src/extension/pipeline/analysisSource.ts @@ -2,7 +2,7 @@ import type { IProjectedConnection, IFileAnalysisResult, } from '../../core/plugins/types/contracts'; -import type { IDiscoveredFile } from '@codegraphy-dev/core'; +import type { IDiscoveredFile, WorkspaceFilterAccounting } from '@codegraphy-dev/core'; import type { EventBus } from '../events/bus'; import type { PluginRegistry } from '../../core/plugins/registry/manager'; import type { FileDiscovery } from '@codegraphy-dev/core'; @@ -46,6 +46,7 @@ export interface WorkspacePipelineSourceOwner { _completeGraphData: IGraphData; _lastDiscoveredDirectories: string[]; _lastDiscoveredFiles: IDiscoveredFile[]; + _filterAccounting: WorkspaceFilterAccounting; _lastFileAnalysis: Map; _lastFileConnections: Map; _lastGitIgnoredPaths: string[]; @@ -135,6 +136,12 @@ export function createWorkspacePipelineAnalysisSource( owner._lastDiscoveredDirectories = directories; }, }, + _filterAccounting: { + get: () => owner._filterAccounting, + set: (accounting: WorkspaceFilterAccounting) => { + owner._filterAccounting = accounting; + }, + }, _lastFileConnections: { get: () => owner._lastFileConnections, set: (fileConnections: Map) => { diff --git a/packages/extension/src/extension/pipeline/indexingState/model.ts b/packages/extension/src/extension/pipeline/indexingState/model.ts index abf0d72bb4..db02ce43cc 100644 --- a/packages/extension/src/extension/pipeline/indexingState/model.ts +++ b/packages/extension/src/extension/pipeline/indexingState/model.ts @@ -3,6 +3,7 @@ import { createWorkspaceIndexEngineState, FileDiscovery, type IDiscoveredFile, + type WorkspaceFilterAccounting, type WorkspaceIndexEngineState, } from '@codegraphy-dev/core'; import type { IFileAnalysisResult, IProjectedConnection } from '../../../core/plugins/types/contracts'; @@ -58,6 +59,12 @@ export abstract class WorkspacePipelineEngineStateBase { protected set _lastDiscoveredFiles(value: IDiscoveredFile[]) { this._engineState.discoveredFiles = value; } + protected get _filterAccounting(): WorkspaceFilterAccounting { + return this._engineState.filterAccounting; + } + protected set _filterAccounting(value: WorkspaceFilterAccounting) { + this._engineState.filterAccounting = value; + } protected get _lastGitIgnoredPaths(): string[] { return this._engineState.gitIgnoredPaths; } protected set _lastGitIgnoredPaths(value: string[]) { this._engineState.gitIgnoredPaths = value; } protected get _lastWorkspaceRoot(): string { return this._engineState.workspaceRoot; } diff --git a/packages/extension/src/extension/pipeline/service/base/internal.ts b/packages/extension/src/extension/pipeline/service/base/internal.ts index f33ac49e1a..6b373a2030 100644 --- a/packages/extension/src/extension/pipeline/service/base/internal.ts +++ b/packages/extension/src/extension/pipeline/service/base/internal.ts @@ -247,6 +247,7 @@ export abstract class WorkspacePipelineInternalBase extends WorkspacePipelineSta ): Promise { const workspaceRoot = this._getWorkspaceRoot(); const dependencies = { + getFilterAccounting: () => this._filterAccounting, getCurrentCommitSha: () => workspaceRoot ? this._getCurrentCommitShaSync(workspaceRoot) : null, getPluginSignature: () => this._getPluginSignature(), diff --git a/packages/extension/src/extension/pipeline/service/cache/index.ts b/packages/extension/src/extension/pipeline/service/cache/index.ts index 494fa3bbd3..ab5708e410 100644 --- a/packages/extension/src/extension/pipeline/service/cache/index.ts +++ b/packages/extension/src/extension/pipeline/service/cache/index.ts @@ -11,6 +11,7 @@ interface WorkspacePipelineSignatureDependencies { interface WorkspacePipelinePersistIndexDependencies extends WorkspacePipelineSignatureDependencies { + getFilterAccounting(): Parameters[1]['filterAccounting']; getCurrentCommitSha?: () => Promise | string | null; persistIndexMetadata?: ( workspaceRoot: string, @@ -48,6 +49,7 @@ export async function persistWorkspacePipelineIndexMetadata( try { const currentCommitSha = await dependencies.getCurrentCommitSha?.(); await (dependencies.persistIndexMetadata ?? persistCodeGraphyWorkspaceIndexMetadata)(workspaceRoot, { + filterAccounting: dependencies.getFilterAccounting(), ...(dependencies.getCurrentCommitSha ? { lastIndexedCommit: currentCommitSha ?? null } : {}), diff --git a/packages/extension/src/extension/pipeline/service/cache/invalidation.ts b/packages/extension/src/extension/pipeline/service/cache/invalidation.ts index 2b4f59591a..90a0bcbe82 100644 --- a/packages/extension/src/extension/pipeline/service/cache/invalidation.ts +++ b/packages/extension/src/extension/pipeline/service/cache/invalidation.ts @@ -20,6 +20,7 @@ export function invalidateWorkspacePipelineFiles( cache: state.cache, discoveredDirectories: [], discoveredFiles: [], + filterAccounting: { kind: 'unavailable' }, gitIgnoredPaths: [], fileAnalysis: state.lastFileAnalysis, fileConnections: state.lastFileConnections, diff --git a/packages/extension/src/extension/pipeline/service/cachedGraph.ts b/packages/extension/src/extension/pipeline/service/cachedGraph.ts index 195625355e..266dcc09f1 100644 --- a/packages/extension/src/extension/pipeline/service/cachedGraph.ts +++ b/packages/extension/src/extension/pipeline/service/cachedGraph.ts @@ -9,6 +9,7 @@ import { projectFileAnalysisConnections, throwIfWorkspaceAnalysisAborted, } from '@codegraphy-dev/core'; +import { readCodeGraphyWorkspaceMeta } from '@codegraphy-dev/core'; import type { IGraphData } from '../../../shared/graph/contracts'; import { collectCachedDirectoryPaths, @@ -55,11 +56,12 @@ export abstract class WorkspacePipelineCachedGraphFacade extends WorkspacePipeli cachedFilePaths, ); + this._filterAccounting = readCodeGraphyWorkspaceMeta(workspaceRoot).filterAccounting; + const gitIgnoredPaths = new Set(cachedDiscovery.gitIgnoredPaths); const activeFilterPatterns = [ ...this._getEffectiveCustomFilterPatterns(filterPatterns), ...this._getEffectivePluginFilterPatterns(disabledPlugins), ]; - const gitIgnoredPaths = new Set(cachedDiscovery.gitIgnoredPaths); const eligibleFiles = cachedDiscovery.files.filter(file => ( !gitIgnoredPaths.has(file.relativePath) && !matchesAnyPattern(file.relativePath, activeFilterPatterns) diff --git a/packages/extension/src/extension/pipeline/service/graphDiscovery.ts b/packages/extension/src/extension/pipeline/service/graphDiscovery.ts index 3cdefcb11f..560965aa8c 100644 --- a/packages/extension/src/extension/pipeline/service/graphDiscovery.ts +++ b/packages/extension/src/extension/pipeline/service/graphDiscovery.ts @@ -37,6 +37,7 @@ export abstract class WorkspacePipelineGraphDiscoveryFacade extends WorkspacePip this._lastDiscoveredDirectories = discoveryResult.directories ?? []; this._lastDiscoveredFiles = discoveryResult.files; + this._filterAccounting = discoveryResult.filterAccounting; this._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; this._lastFileAnalysis = new Map(); this._lastFileConnections = fileConnections; diff --git a/packages/extension/src/extension/pipeline/service/pluginFacade.ts b/packages/extension/src/extension/pipeline/service/pluginFacade.ts index d42363477f..8337f9019c 100644 --- a/packages/extension/src/extension/pipeline/service/pluginFacade.ts +++ b/packages/extension/src/extension/pipeline/service/pluginFacade.ts @@ -1,4 +1,5 @@ import type { IPluginFilterPatternGroup } from '../../../shared/protocol/extensionToWebview'; +import type { WorkspaceFilterAccounting } from '@codegraphy-dev/core'; import { hasWorkspacePipelineIndex } from './cache/index'; import { WorkspacePipelineInternalBase } from './base/internal'; import { getWorkspacePipelineIndexStatus } from './indexStatus'; @@ -79,6 +80,10 @@ export abstract class WorkspacePipelinePluginFacade extends WorkspacePipelineInt return getPipelinePluginFilterGroups(this._registry, disabledPlugins); } + getFilterAccounting(): WorkspaceFilterAccounting { + return this._filterAccounting; + } + protected _getEffectiveCustomFilterPatterns(filterPatterns: string[]): string[] { return getEffectiveCustomFilterPatterns(this._config, filterPatterns); } diff --git a/packages/extension/src/extension/pipeline/service/refresh/context.ts b/packages/extension/src/extension/pipeline/service/refresh/context.ts index 950fd8af7e..169e0eb8cb 100644 --- a/packages/extension/src/extension/pipeline/service/refresh/context.ts +++ b/packages/extension/src/extension/pipeline/service/refresh/context.ts @@ -1,4 +1,8 @@ -import type { AnalysisCacheTier, FileDiscovery } from '@codegraphy-dev/core'; +import type { + AnalysisCacheTier, + FileDiscovery, + WorkspaceFilterAccounting, +} from '@codegraphy-dev/core'; import type { Configuration } from '../../../config/reader'; import type { PluginRegistry } from '../../../../core/plugins/registry/manager'; import type { IGraphData } from '../../../../shared/graph/contracts'; @@ -27,6 +31,7 @@ export interface RefreshFacadeContext _captureRefreshState(): WorkspacePipelineRefreshState; _getWorkspaceRoot(): string | undefined; _lastGitIgnoredPaths: string[]; + _filterAccounting: WorkspaceFilterAccounting; _persistCache(): void; _persistCachePatch(patch: WorkspacePipelineCachePatch): Promise; _persistIndexMetadata(resolvedChangedFilePaths?: readonly string[]): Promise; diff --git a/packages/extension/src/extension/pipeline/service/refresh/modes/analysisScope.ts b/packages/extension/src/extension/pipeline/service/refresh/modes/analysisScope.ts index ef7f05308c..deff12fba2 100644 --- a/packages/extension/src/extension/pipeline/service/refresh/modes/analysisScope.ts +++ b/packages/extension/src/extension/pipeline/service/refresh/modes/analysisScope.ts @@ -34,6 +34,7 @@ export async function refreshAnalysisScopeForFacade( signal: input.signal, workspaceRoot, }); + facade._filterAccounting = discoveryResult.filterAccounting; facade._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; if (canReuseCurrentAnalysisForScope({ diff --git a/packages/extension/src/extension/pipeline/service/refresh/modes/changedFiles.ts b/packages/extension/src/extension/pipeline/service/refresh/modes/changedFiles.ts index 05838aba56..6ba69aa765 100644 --- a/packages/extension/src/extension/pipeline/service/refresh/modes/changedFiles.ts +++ b/packages/extension/src/extension/pipeline/service/refresh/modes/changedFiles.ts @@ -136,6 +136,7 @@ async function getChangedFileDiscoveryState( files: discovered.discoveryResult.files, limitReached: discovered.discoveryResult.limitReached, }; + facade._filterAccounting = discovered.discoveryResult.filterAccounting; facade._lastGitIgnoredPaths = discovered.discoveryResult.gitIgnoredPaths ?? []; return discoveryResult; } diff --git a/packages/extension/src/extension/pipeline/service/refresh/modes/gitignoreMetadata.ts b/packages/extension/src/extension/pipeline/service/refresh/modes/gitignoreMetadata.ts index 64b991aca5..c85937ca37 100644 --- a/packages/extension/src/extension/pipeline/service/refresh/modes/gitignoreMetadata.ts +++ b/packages/extension/src/extension/pipeline/service/refresh/modes/gitignoreMetadata.ts @@ -31,12 +31,11 @@ export async function refreshGitignoreMetadataForFacade( facade._lastDiscoveredDirectories = discoveryResult.directories ?? []; facade._lastDiscoveredFiles = discoveryResult.files; + facade._filterAccounting = discoveryResult.filterAccounting; facade._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; facade._lastWorkspaceRoot = workspaceRoot; - void facade._persistIndexMetadata().catch(error => { - console.warn('[CodeGraphy] Failed to persist gitignore metadata refresh.', error); - }); + await facade._persistIndexMetadata(); const eligibleFilePaths = new Set( discoveryResult.files.map(file => file.relativePath), diff --git a/packages/extension/src/extension/pipeline/service/refresh/modes/pluginFiles.ts b/packages/extension/src/extension/pipeline/service/refresh/modes/pluginFiles.ts index 4c797515d6..5e357c1ee1 100644 --- a/packages/extension/src/extension/pipeline/service/refresh/modes/pluginFiles.ts +++ b/packages/extension/src/extension/pipeline/service/refresh/modes/pluginFiles.ts @@ -31,6 +31,7 @@ export async function refreshPluginFilesForFacade( signal: input.signal, workspaceRoot, }); + facade._filterAccounting = discoveryResult.filterAccounting; facade._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; return refreshWorkspacePipelinePluginFiles(createWorkspaceIndexRefreshSource( diff --git a/packages/extension/src/extension/pipeline/service/runtime/discovery.ts b/packages/extension/src/extension/pipeline/service/runtime/discovery.ts index f2dc975e4f..7f2e3b3c6d 100644 --- a/packages/extension/src/extension/pipeline/service/runtime/discovery.ts +++ b/packages/extension/src/extension/pipeline/service/runtime/discovery.ts @@ -18,6 +18,7 @@ export function createWorkspacePipelineDiscoveryDependencies( directories: result.directories, durationMs: result.durationMs, files: result.files, + filterAccounting: result.filterAccounting, gitIgnoredPaths: result.gitIgnoredPaths, limitReached: result.limitReached, totalFound: result.totalFound ?? result.files.length, diff --git a/packages/extension/src/extension/repoSettings/meta.ts b/packages/extension/src/extension/repoSettings/meta.ts index 7921a8ec6a..623c5c0cb1 100644 --- a/packages/extension/src/extension/repoSettings/meta.ts +++ b/packages/extension/src/extension/repoSettings/meta.ts @@ -2,16 +2,18 @@ import * as path from 'node:path'; import { createDefaultCodeGraphyWorkspaceMeta, readCodeGraphyWorkspaceMeta, + type WorkspaceFilterAccounting, } from '@codegraphy-dev/core'; export interface ICodeGraphyRepoMeta { - version: 1; + version: 2; lastIndexedAt: string | null; lastIndexedCommit: string | null; pluginBuildSignature: string | null; pluginSignature: string | null; settingsSignature: string | null; pendingChangedFiles: string[]; + filterAccounting: WorkspaceFilterAccounting; } export function createDefaultCodeGraphyRepoMeta(): ICodeGraphyRepoMeta { @@ -28,12 +30,13 @@ export function readCodeGraphyRepoMeta(workspaceRoot: string): ICodeGraphyRepoMe function toRepoMeta(meta: ReturnType): ICodeGraphyRepoMeta { return { - version: 1, + version: 2, lastIndexedAt: meta.lastIndexedAt, lastIndexedCommit: meta.lastIndexedCommit ?? null, pluginBuildSignature: meta.pluginBuildSignature, pluginSignature: meta.pluginSignature, settingsSignature: meta.settingsSignature, pendingChangedFiles: [...meta.pendingChangedFiles], + filterAccounting: meta.filterAccounting, }; } diff --git a/packages/extension/src/shared/graphControls/contracts.ts b/packages/extension/src/shared/graphControls/contracts.ts index 4c4608c4a4..78710385ab 100644 --- a/packages/extension/src/shared/graphControls/contracts.ts +++ b/packages/extension/src/shared/graphControls/contracts.ts @@ -2,6 +2,7 @@ import type { GraphEdgeKind, NodeType, } from '../graph/contracts'; +import type { WorkspaceFilterAccounting } from '@codegraphy-dev/core'; export interface IGraphNodeTypeDefinition { id: NodeType; @@ -38,6 +39,7 @@ export interface IGraphTypeDescription { } export interface IGraphControlsSnapshot { + filterAccounting: WorkspaceFilterAccounting; nodeTypes: IGraphNodeTypeDefinition[]; edgeTypes: IGraphEdgeTypeDefinition[]; nodeColors: Record; diff --git a/packages/extension/src/webview/app/shell/storeSelectors.ts b/packages/extension/src/webview/app/shell/storeSelectors.ts index 9c98064a7f..bede3542dc 100644 --- a/packages/extension/src/webview/app/shell/storeSelectors.ts +++ b/packages/extension/src/webview/app/shell/storeSelectors.ts @@ -15,6 +15,7 @@ export function useAppState() { const searchOptions = useGraphStore(s => s.searchOptions); const legends = useGraphStore(s => s.legends); const filterPatterns = useGraphStore(s => s.filterPatterns); + const filterAccounting = useGraphStore(s => s.filterAccounting); const pluginFilterPatterns = useGraphStore(s => s.pluginFilterPatterns); const pluginFilterGroups = useGraphStore(s => s.pluginFilterGroups); const disabledCustomFilterPatterns = useGraphStore(s => s.disabledCustomFilterPatterns); @@ -40,6 +41,7 @@ export function useAppState() { searchOptions, legends, filterPatterns, + filterAccounting, pluginFilterPatterns, pluginFilterGroups, disabledCustomFilterPatterns, diff --git a/packages/extension/src/webview/app/shell/view.tsx b/packages/extension/src/webview/app/shell/view.tsx index ac2dc4316b..ca1873965e 100644 --- a/packages/extension/src/webview/app/shell/view.tsx +++ b/packages/extension/src/webview/app/shell/view.tsx @@ -44,6 +44,7 @@ export default function App({ graphPhysicsPreparation }: AppShellProps): React.R searchOptions, legends, filterPatterns, + filterAccounting, pluginFilterPatterns, pluginFilterGroups, disabledCustomFilterPatterns, @@ -205,7 +206,8 @@ export default function App({ graphPhysicsPreparation }: AppShellProps): React.R customPatterns: filterPatterns, disabledCustomPatterns: disabledCustomFilterPatterns, disabledPluginPatterns: disabledPluginFilterPatterns, - excludedCount, + filterAccounting, + excludedNodeCount: excludedCount, onDisabledCustomPatternsChange: setDisabledCustomFilterPatterns, onDisabledPluginPatternsChange: setDisabledPluginFilterPatterns, buttonRef: filterButtonRef, diff --git a/packages/extension/src/webview/components/searchBar/field/model.ts b/packages/extension/src/webview/components/searchBar/field/model.ts index c93144d7d3..2d1154d373 100644 --- a/packages/extension/src/webview/components/searchBar/field/model.ts +++ b/packages/extension/src/webview/components/searchBar/field/model.ts @@ -5,6 +5,7 @@ import type React from 'react'; import type { IPluginFilterPatternGroup } from '../../../../shared/protocol/extensionToWebview'; +import type { WorkspaceFilterAccounting } from '@codegraphy-dev/core'; export interface SearchOptions { matchCase: boolean; @@ -28,7 +29,8 @@ export interface SearchBarProps { disabledCustomPatterns: string[]; disabledPluginPatterns: string[]; customPatterns: string[]; - excludedCount: number; + filterAccounting: WorkspaceFilterAccounting; + excludedNodeCount: number; onDisabledCustomPatternsChange: (patterns: string[]) => void; onDisabledPluginPatternsChange: (patterns: string[]) => void; onOpenChange: (open: boolean) => void; diff --git a/packages/extension/src/webview/components/searchBar/filters/countState.ts b/packages/extension/src/webview/components/searchBar/filters/countState.ts index 506845f41b..afab6c86b6 100644 --- a/packages/extension/src/webview/components/searchBar/filters/countState.ts +++ b/packages/extension/src/webview/components/searchBar/filters/countState.ts @@ -1,3 +1,5 @@ +import type { WorkspaceFilterAccounting } from '@codegraphy-dev/core'; + export interface FilterCountInput { excludedCount: number; filterVisibleCount: number; @@ -43,10 +45,23 @@ export function getFilterCountState({ return { kind: 'idle', label: null }; } -export function formatExcludedCount(count: number): string { +export function formatExcludedFileCount(accounting: WorkspaceFilterAccounting): string { + if (accounting.kind === 'unavailable') { + return 'Before analysis: re-index to calculate excluded workspace files'; + } + + const count = accounting.excludedFileCount; + if (count === 1) { + return 'Before analysis: 1 workspace file excluded'; + } + + return `Before analysis: ${count} workspace files excluded`; +} + +export function formatExcludedNodeCount(count: number): string { if (count === 1) { - return '1 excluded from graph'; + return 'In Graph View: 1 Node excluded'; } - return `${count} excluded from graph`; + return `In Graph View: ${count} Nodes excluded`; } diff --git a/packages/extension/src/webview/components/searchBar/filters/popover.tsx b/packages/extension/src/webview/components/searchBar/filters/popover.tsx index 1e6de2ca42..fee19dbeda 100644 --- a/packages/extension/src/webview/components/searchBar/filters/popover.tsx +++ b/packages/extension/src/webview/components/searchBar/filters/popover.tsx @@ -10,7 +10,7 @@ import { getDisabledFilterPatterns, getEnabledFilterCount, } from './model'; -import { formatExcludedCount } from './countState'; +import { formatExcludedFileCount, formatExcludedNodeCount } from './countState'; import { handleAddPatterns } from './popover/actions'; import { CustomFiltersSection } from './popover/customSection'; import { useFilterDraftState } from './popover/draftState'; @@ -23,7 +23,8 @@ export function FilterPopover({ disabledCustomPatterns, disabledPluginPatterns, customPatterns, - excludedCount, + filterAccounting, + excludedNodeCount, onDisabledCustomPatternsChange, onDisabledPluginPatternsChange, onOpenChange, @@ -106,7 +107,7 @@ export function FilterPopover({ className="h-7 px-2 text-xs" aria-expanded={isOpen} aria-label={`Filters, ${enabledCount} enabled`} - title={formatExcludedCount(excludedCount)} + title={`${formatExcludedFileCount(filterAccounting)}\n${formatExcludedNodeCount(excludedNodeCount)}`} onClick={() => setOpen(!isOpen)} > @@ -123,7 +124,8 @@ export function FilterPopover({

Filters

{enabledCount} enabled -

{formatExcludedCount(excludedCount)}

+

{formatExcludedFileCount(filterAccounting)}

+

{formatExcludedNodeCount(excludedNodeCount)}

diff --git a/packages/extension/src/webview/components/searchBar/filters/popover/types.ts b/packages/extension/src/webview/components/searchBar/filters/popover/types.ts index a180ab7cb0..4d318ff39b 100644 --- a/packages/extension/src/webview/components/searchBar/filters/popover/types.ts +++ b/packages/extension/src/webview/components/searchBar/filters/popover/types.ts @@ -1,12 +1,14 @@ import type React from 'react'; import type { IPluginFilterPatternGroup } from '../../../../../shared/protocol/extensionToWebview'; +import type { WorkspaceFilterAccounting } from '@codegraphy-dev/core'; export interface FilterPopoverProps { buttonRef?: React.Ref; disabledCustomPatterns: string[]; disabledPluginPatterns: string[]; customPatterns: string[]; - excludedCount: number; + filterAccounting: WorkspaceFilterAccounting; + excludedNodeCount: number; onDisabledCustomPatternsChange: (patterns: string[]) => void; onDisabledPluginPatternsChange: (patterns: string[]) => void; onOpenChange?: (open: boolean) => void; diff --git a/packages/extension/src/webview/store/initialState.ts b/packages/extension/src/webview/store/initialState.ts index 232fe4a887..7362933e2a 100644 --- a/packages/extension/src/webview/store/initialState.ts +++ b/packages/extension/src/webview/store/initialState.ts @@ -37,6 +37,7 @@ export const INITIAL_STATE: GraphStateFields = { optimisticLegendUpdates: {}, optimisticUserLegends: null, filterPatterns: [], + filterAccounting: { kind: 'unavailable' as const }, pluginFilterPatterns: [], pluginFilterGroups: [], disabledCustomFilterPatterns: [], diff --git a/packages/extension/src/webview/store/messageHandlers/graphControls.ts b/packages/extension/src/webview/store/messageHandlers/graphControls.ts index fe02944193..6518b2d7ea 100644 --- a/packages/extension/src/webview/store/messageHandlers/graphControls.ts +++ b/packages/extension/src/webview/store/messageHandlers/graphControls.ts @@ -35,6 +35,7 @@ export function handleGraphControlsUpdated( if (!state) { return { graphNodeTypes: message.payload.nodeTypes, + filterAccounting: message.payload.filterAccounting, graphEdgeTypes: message.payload.edgeTypes, nodeColors: message.payload.nodeColors, nodeVisibility: message.payload.nodeVisibility, diff --git a/packages/extension/src/webview/store/messageHandlers/graphControls/patch.ts b/packages/extension/src/webview/store/messageHandlers/graphControls/patch.ts index ad58f76774..6b10843b2f 100644 --- a/packages/extension/src/webview/store/messageHandlers/graphControls/patch.ts +++ b/packages/extension/src/webview/store/messageHandlers/graphControls/patch.ts @@ -13,6 +13,7 @@ export function createGraphControlsStatePatch( ): PartialState { const next: PartialState = {}; + assignChangedGraphControl(next, 'filterAccounting', state.filterAccounting, payload.filterAccounting); assignChangedGraphControl(next, 'graphNodeTypes', state.graphNodeTypes, payload.nodeTypes); assignChangedGraphControl(next, 'graphEdgeTypes', state.graphEdgeTypes, payload.edgeTypes); assignChangedGraphControl(next, 'nodeColors', state.nodeColors, payload.nodeColors); diff --git a/packages/extension/src/webview/store/messageTypes.ts b/packages/extension/src/webview/store/messageTypes.ts index da8c21b6b9..d4b1c7acfa 100644 --- a/packages/extension/src/webview/store/messageTypes.ts +++ b/packages/extension/src/webview/store/messageTypes.ts @@ -12,6 +12,7 @@ import type { import type { IGroup } from '../../shared/settings/groups'; import type { BidirectionalEdgeMode, DirectionMode, NodeSizeMode } from '../../shared/settings/modes'; import type { IPhysicsSettings } from '../../shared/settings/physics'; +import type { WorkspaceFilterAccounting } from '@codegraphy-dev/core'; import type { PendingGroupUpdates, PendingUserGroupsUpdate, @@ -52,6 +53,7 @@ export interface IStoreFields { optimisticLegendUpdates: PendingGroupUpdates; optimisticUserLegends: PendingUserGroupsUpdate | null; filterPatterns: string[]; + filterAccounting: WorkspaceFilterAccounting; pluginFilterPatterns: string[]; pluginFilterGroups: IPluginFilterPatternGroup[]; disabledCustomFilterPatterns: string[]; diff --git a/packages/extension/src/webview/store/state.ts b/packages/extension/src/webview/store/state.ts index ea04729a56..6c86444a2b 100644 --- a/packages/extension/src/webview/store/state.ts +++ b/packages/extension/src/webview/store/state.ts @@ -17,6 +17,7 @@ import type { import type { IGroup } from '../../shared/settings/groups'; import type { BidirectionalEdgeMode, DirectionMode, NodeSizeMode } from '../../shared/settings/modes'; import type { IPhysicsSettings } from '../../shared/settings/physics'; +import type { WorkspaceFilterAccounting } from '@codegraphy-dev/core'; import type { PendingGroupUpdates, PendingUserGroupsUpdate, @@ -56,6 +57,7 @@ export interface GraphState { optimisticLegendUpdates: PendingGroupUpdates; optimisticUserLegends: PendingUserGroupsUpdate | null; filterPatterns: string[]; + filterAccounting: WorkspaceFilterAccounting; pluginFilterPatterns: string[]; pluginFilterGroups: IPluginFilterPatternGroup[]; disabledCustomFilterPatterns: string[]; diff --git a/packages/extension/tests/extension/graphView/analysis/execution/fixtures.ts b/packages/extension/tests/extension/graphView/analysis/execution/fixtures.ts index bde935fef0..4946bda518 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/fixtures.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/fixtures.ts @@ -26,6 +26,7 @@ export function createExecutionAnalyzer( initialize: vi.fn(async () => undefined), syncWorkspacePlugins: vi.fn(async () => undefined), hasIndex: vi.fn(() => true), + getFilterAccounting: vi.fn(() => ({ kind: 'unavailable' as const })), hasRecoverableGraphState: vi.fn(() => true), analyze: vi.fn(async () => ({ nodes: [], edges: [] })), refreshIndex: vi.fn(async () => ({ nodes: [], edges: [] })), diff --git a/packages/extension/tests/extension/graphView/analysis/execution/incremental.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/incremental.test.ts index f112955607..8fa294ddc6 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/incremental.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/incremental.test.ts @@ -13,6 +13,7 @@ function createState( analyzer: { analyze: vi.fn(), hasIndex: vi.fn(() => true), + getFilterAccounting: vi.fn(() => ({ kind: 'unavailable' as const })), hasRecoverableGraphState: vi.fn(() => true), initialize: vi.fn(), loadCachedGraph: vi.fn(async () => ({ nodes: [], edges: [] })), diff --git a/packages/extension/tests/extension/graphView/analysis/lifecycle.test.ts b/packages/extension/tests/extension/graphView/analysis/lifecycle.test.ts index f0f6a55786..7af5397dd7 100644 --- a/packages/extension/tests/extension/graphView/analysis/lifecycle.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/lifecycle.test.ts @@ -31,6 +31,7 @@ function createAnalyzer(overrides: Partial undefined), syncWorkspacePlugins: vi.fn(async () => undefined), hasIndex: vi.fn(() => true), + getFilterAccounting: vi.fn(() => ({ kind: 'unavailable' as const })), hasRecoverableGraphState: vi.fn(() => true), analyze: vi.fn(async () => ({ nodes: [], edges: [] })), registry: { diff --git a/packages/extension/tests/extension/graphView/controls/definitions/snapshot.test.ts b/packages/extension/tests/extension/graphView/controls/definitions/snapshot.test.ts index 66619f9a96..7489f9b233 100644 --- a/packages/extension/tests/extension/graphView/controls/definitions/snapshot.test.ts +++ b/packages/extension/tests/extension/graphView/controls/definitions/snapshot.test.ts @@ -16,6 +16,7 @@ describe('extension/graphView/controls/snapshot', () => { }, [], [], + { kind: 'unavailable' }, { nodeTypes: [], edgeTypes: [] }, ); @@ -56,6 +57,7 @@ describe('extension/graphView/controls/snapshot', () => { }, [], [], + { kind: 'unavailable' }, { nodeTypes: ['symbol:function', 'symbol:struct', 'symbol:enum', 'symbol:constant'], edgeTypes: [], @@ -108,6 +110,7 @@ describe('extension/graphView/controls/snapshot', () => { }, [], [], + { kind: 'unavailable' }, { nodeTypes: [], edgeTypes: ['import', 'reexport'] }, ); @@ -140,6 +143,7 @@ describe('extension/graphView/controls/snapshot', () => { }, [], [], + { kind: 'unavailable' }, { nodeTypes: ['symbol:function'], edgeTypes: [] }, ); @@ -182,6 +186,7 @@ describe('extension/graphView/controls/snapshot', () => { }, [], [], + { kind: 'unavailable' }, { nodeTypes: [ 'symbol:function', @@ -254,6 +259,7 @@ describe('extension/graphView/controls/snapshot', () => { defaultVisible: true, }, ], + { kind: 'unavailable' }, { nodeTypes: [], edgeTypes: ['import', 'plugin:route'] }, ); @@ -289,6 +295,7 @@ describe('extension/graphView/controls/snapshot', () => { }, [], [], + { kind: 'unavailable' }, { nodeTypes: [], edgeTypes: ['reference'] }, ); @@ -317,6 +324,7 @@ describe('extension/graphView/controls/snapshot', () => { }, [], [], + { kind: 'unavailable' }, ); expect(snapshot.edgeTypes.map((edgeType) => edgeType.id)).toEqual([ @@ -347,6 +355,7 @@ describe('extension/graphView/controls/snapshot', () => { }, [], [], + { kind: 'unavailable' }, { nodeTypes: [], edgeTypes: ['include', 'call', 'contains', 'inherit', 'overrides'] }, ); @@ -382,6 +391,7 @@ describe('extension/graphView/controls/snapshot', () => { }, [], [], + { kind: 'unavailable' }, { nodeTypes: [], edgeTypes: ['using', 'type', 'call', 'inherit', 'implements', 'contains'] }, ); @@ -421,6 +431,7 @@ describe('extension/graphView/controls/snapshot', () => { }, [], [], + { kind: 'unavailable' }, { nodeTypes: [], edgeTypes: [] }, ); @@ -482,6 +493,7 @@ describe('extension/graphView/controls/snapshot', () => { }, }, ], + { kind: 'unavailable' }, { nodeTypes: ['route'], edgeTypes: ['plugin:route'] }, ); @@ -541,6 +553,7 @@ describe('extension/graphView/controls/snapshot', () => { }, [], [], + { kind: 'unavailable' }, { nodeTypes: [], edgeTypes: [] }, ); diff --git a/packages/extension/tests/extension/graphView/controls/send.test.ts b/packages/extension/tests/extension/graphView/controls/send.test.ts index fd36cafe61..071c1b487d 100644 --- a/packages/extension/tests/extension/graphView/controls/send.test.ts +++ b/packages/extension/tests/extension/graphView/controls/send.test.ts @@ -20,6 +20,7 @@ describe('extension/graphView/controls/send', () => { ], }, { + getFilterAccounting: () => ({ kind: 'current', excludedFileCount: 3, gitIgnoredPathCount: 2 }), registry: { listNodeTypes: () => [ null, @@ -55,6 +56,11 @@ describe('extension/graphView/controls/send', () => { const payload = sendMessage.mock.calls[0][0].payload; expect(payload).toBeDefined(); + expect(payload.filterAccounting).toEqual({ + kind: 'current', + excludedFileCount: 3, + gitIgnoredPathCount: 2, + }); expect(payload.nodeTypes.some((nodeType: { id: string }) => nodeType.id === 'bad-node')).toBe(false); expect(payload.edgeTypes.some((edgeType: { id: string }) => edgeType.id === 'bad-edge')).toBe(false); }); @@ -68,6 +74,7 @@ describe('extension/graphView/controls/send', () => { edges: [{ id: 'src/app.ts->src/lib.ts#import', from: 'src/app.ts', to: 'src/lib.ts', kind: 'import', sources: [] }], }, { + getFilterAccounting: () => ({ kind: 'unavailable' }), registry: { listNodeTypes: 'not-a-function', listEdgeTypes: () => 'not-an-array', @@ -92,6 +99,7 @@ describe('extension/graphView/controls/send', () => { edges: [], }, { + getFilterAccounting: () => ({ kind: 'unavailable' }), registry: { listEdgeTypes: () => [ { id: 'plugin:route', label: 'Route', defaultColor: '#10B981', defaultVisible: true }, @@ -137,6 +145,7 @@ describe('extension/graphView/controls/send', () => { ], }, { + getFilterAccounting: () => ({ kind: 'unavailable' }), registry: { listEdgeTypes: () => [], listGraphScopeCapabilities: () => ({ @@ -181,7 +190,7 @@ describe('extension/graphView/controls/send', () => { nodes: [{ id: 'game/player.gd', label: 'Player', color: '#111111', nodeType: 'file' }], edges: [], }, - { registry }, + { getFilterAccounting: () => ({ kind: 'unavailable' }), registry }, sendMessage, { get: (_key: string, defaultValue: T): T => defaultValue }, disabledPlugins, @@ -212,6 +221,7 @@ describe('extension/graphView/controls/send', () => { ], }, { + getFilterAccounting: () => ({ kind: 'unavailable' }), registry: { listEdgeTypes: () => [], listGraphScopeCapabilities: () => ({ nodeTypes: [], edgeTypes: [] }), @@ -246,12 +256,14 @@ describe('extension/graphView/controls/send', () => { const payload = sendMessage.mock.calls[0][0].payload; expect(payload).toBeDefined(); + expect(payload.filterAccounting).toEqual({ kind: 'unavailable' }); expect(payload.nodeTypes.map((nodeType: { id: string }) => nodeType.id)).toEqual(STRUCTURAL_NODE_TYPE_IDS); expect(payload.edgeTypes.some((edgeType: { id: string }) => edgeType.id === 'plugin:route')).toBe(false); }); it('keeps the snapshot payload intact when building the outbound message', () => { const snapshot: IGraphControlsSnapshot = { + filterAccounting: { kind: 'current', excludedFileCount: 3, gitIgnoredPathCount: 2 }, nodeTypes: [{ id: 'file', label: 'File', defaultColor: '#A1A1AA', defaultVisible: true }], edgeTypes: [{ id: 'import', label: 'Import', defaultColor: '#60A5FA', defaultVisible: true }], nodeColors: { file: '#A1A1AA' }, diff --git a/packages/extension/tests/extension/graphView/provider/analysis/handlers.test.ts b/packages/extension/tests/extension/graphView/provider/analysis/handlers.test.ts index 47c9c531af..52fcd6f878 100644 --- a/packages/extension/tests/extension/graphView/provider/analysis/handlers.test.ts +++ b/packages/extension/tests/extension/graphView/provider/analysis/handlers.test.ts @@ -73,7 +73,13 @@ describe('graphView/provider/analysis/handlers', () => { }); it('builds execution handlers that update provider state and delegate callbacks', () => { - const source = createSource(); + const source = createSource({ + _analyzer: { getFilterAccounting: vi.fn(() => ({ + kind: 'current', + excludedFileCount: 3, + gitIgnoredPathCount: 2, + })) } as never, + }); const dependencies = createDependencies(); const callbacks = { isAnalysisStale: vi.fn(() => true), @@ -151,6 +157,7 @@ describe('graphView/provider/analysis/handlers', () => { sendMessage({ type: 'GRAPH_CONTROLS_UPDATED', payload: { + filterAccounting: { kind: 'current', excludedFileCount: 3, gitIgnoredPathCount: 2 }, nodeTypes: [], edgeTypes: [], nodeColors: {}, @@ -179,6 +186,7 @@ describe('graphView/provider/analysis/handlers', () => { expect(source._sendMessage).toHaveBeenCalledWith({ type: 'GRAPH_CONTROLS_UPDATED', payload: { + filterAccounting: { kind: 'current', excludedFileCount: 3, gitIgnoredPathCount: 2 }, nodeTypes: [], edgeTypes: [], nodeColors: {}, @@ -189,6 +197,7 @@ describe('graphView/provider/analysis/handlers', () => { expect(source._sendMessage).toHaveBeenNthCalledWith(1, { type: 'GRAPH_CONTROLS_UPDATED', payload: { + filterAccounting: { kind: 'current', excludedFileCount: 3, gitIgnoredPathCount: 2 }, nodeTypes: [], edgeTypes: [], nodeColors: {}, diff --git a/packages/extension/tests/extension/graphView/provider/settingsState.test.ts b/packages/extension/tests/extension/graphView/provider/settingsState.test.ts index 6f14db84a6..4f4e6eaa0b 100644 --- a/packages/extension/tests/extension/graphView/provider/settingsState.test.ts +++ b/packages/extension/tests/extension/graphView/provider/settingsState.test.ts @@ -265,7 +265,14 @@ describe('graphView/provider/settingsState', () => { it('sends all settings with the current state, analyzer filters, and side-effect callbacks', () => { const source = createSource({ - _analyzer: { getPluginFilterPatterns: vi.fn(() => ['plugin/**']) }, + _analyzer: { + getFilterAccounting: vi.fn(() => ({ + kind: 'current' as const, + excludedFileCount: 4, + gitIgnoredPathCount: 2, + })), + getPluginFilterPatterns: vi.fn(() => ['plugin/**']), + }, }); const snapshot = { snapshot: true } as never; const message = { @@ -303,6 +310,16 @@ describe('graphView/provider/settingsState', () => { expect(source._computeMergedGroups).toHaveBeenCalledOnce(); expect(source._sendGroupsUpdated).toHaveBeenCalledOnce(); expect(source._sendMessage).toHaveBeenCalledWith(message); + expect(source._sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + type: 'GRAPH_CONTROLS_UPDATED', + payload: expect.objectContaining({ + filterAccounting: { + kind: 'current', + excludedFileCount: 4, + gitIgnoredPathCount: 2, + }, + }), + })); expect(source._userGroups).toEqual([{ id: 'group.updated' }]); expect(source._filterPatterns).toEqual(['updated/**']); }); @@ -311,7 +328,10 @@ describe('graphView/provider/settingsState', () => { const source = createSource({ _userGroups: [{ id: 'group.current' } as never], _filterPatterns: ['current/**'], - _analyzer: { getPluginFilterPatterns: vi.fn(() => ['plugin/**']) }, + _analyzer: { + getFilterAccounting: vi.fn(() => ({ kind: 'unavailable' as const })), + getPluginFilterPatterns: vi.fn(() => ['plugin/**']), + }, }); const snapshotGroups = [{ id: 'group.snapshot' } as never]; const snapshotFilterPatterns = ['snapshot/**']; diff --git a/packages/extension/tests/extension/graphView/provider/settingsStateDefaultDependencies.test.ts b/packages/extension/tests/extension/graphView/provider/settingsStateDefaultDependencies.test.ts index 8cdadbd23e..a4fb744390 100644 --- a/packages/extension/tests/extension/graphView/provider/settingsStateDefaultDependencies.test.ts +++ b/packages/extension/tests/extension/graphView/provider/settingsStateDefaultDependencies.test.ts @@ -198,7 +198,10 @@ describe('graphView/provider/settingsState default dependencies', () => { it('sends settings through the default provider message bridge', () => { const source = createSource({ - _analyzer: { getPluginFilterPatterns: vi.fn(() => ['plugin/**']) }, + _analyzer: { + getFilterAccounting: vi.fn(() => ({ kind: 'unavailable' as const })), + getPluginFilterPatterns: vi.fn(() => ['plugin/**']), + }, }); const settingsMessage = { type: 'SETTINGS_UPDATED', @@ -253,7 +256,10 @@ describe('graphView/provider/settingsState default dependencies', () => { it('sends all settings through the default snapshot bridge', () => { const source = createSource({ - _analyzer: { getPluginFilterPatterns: vi.fn(() => ['plugin/**']) }, + _analyzer: { + getFilterAccounting: vi.fn(() => ({ kind: 'unavailable' as const })), + getPluginFilterPatterns: vi.fn(() => ['plugin/**']), + }, }); const allSettingsMessage = { type: 'ALL_SETTINGS_UPDATED', diff --git a/packages/extension/tests/extension/graphViewProvider.errorHandling.test.ts b/packages/extension/tests/extension/graphViewProvider.errorHandling.test.ts index 3f61d24de0..f144a20674 100644 --- a/packages/extension/tests/extension/graphViewProvider.errorHandling.test.ts +++ b/packages/extension/tests/extension/graphViewProvider.errorHandling.test.ts @@ -47,6 +47,7 @@ describe('GraphViewProvider error handling', () => { hasIndex(): boolean; initialize(): Promise; discoverGraph(): Promise; + getFilterAccounting(): { kind: 'unavailable' }; registry: { notifyPostAnalyze(graph: IGraphData): void }; }; })._analyzer = { @@ -54,6 +55,7 @@ describe('GraphViewProvider error handling', () => { hasIndex: () => true, initialize: vi.fn().mockResolvedValue(undefined), discoverGraph: vi.fn().mockResolvedValue({ nodes: [], edges: [] }), + getFilterAccounting: vi.fn(() => ({ kind: 'unavailable' as const })), registry: { notifyPostAnalyze: () => {}, }, @@ -78,6 +80,7 @@ describe('GraphViewProvider error handling', () => { (provider as unknown as { _analyzer: { analyze: () => Promise; + getFilterAccounting(): { kind: 'unavailable' }; registry: { extensionPlugins: { list(): unknown[]; @@ -89,6 +92,7 @@ describe('GraphViewProvider error handling', () => { }; })._analyzer = { analyze: vi.fn().mockRejectedValueOnce(new Error('analysis failed')), + getFilterAccounting: vi.fn(() => ({ kind: 'unavailable' as const })), registry: { extensionPlugins: { list: () => [], diff --git a/packages/extension/tests/extension/graphViewProvider.lifecycle.test.ts b/packages/extension/tests/extension/graphViewProvider.lifecycle.test.ts index b3093d7937..4fd9ff38c6 100644 --- a/packages/extension/tests/extension/graphViewProvider.lifecycle.test.ts +++ b/packages/extension/tests/extension/graphViewProvider.lifecycle.test.ts @@ -139,8 +139,16 @@ describe('GraphViewProvider lifecycle', () => { vi.spyOn(internals._fileInfoMethods, '_sendFavorites').mockImplementation(() => {}); (provider as unknown as { - _analyzer: { clearCache: () => void; hasIndex: () => boolean }; - })._analyzer = { clearCache, hasIndex: () => true }; + _analyzer: { + clearCache: () => void; + getFilterAccounting(): { kind: 'unavailable' }; + hasIndex: () => boolean; + }; + })._analyzer = { + clearCache, + getFilterAccounting: vi.fn(() => ({ kind: 'unavailable' as const })), + hasIndex: () => true, + }; await provider.clearCacheAndRefresh(); @@ -229,9 +237,15 @@ describe('GraphViewProvider lifecycle', () => { vi.spyOn(internals._pluginMethods, '_sendGroupsUpdated').mockImplementation(() => {}); (provider as unknown as { - _analyzer: { analyze: () => Promise }; + _analyzer: { + analyze: () => Promise; + getFilterAccounting(): { kind: 'unavailable' }; + }; _analyzerInitialized: boolean; - })._analyzer = { analyze: vi.fn() }; + })._analyzer = { + analyze: vi.fn(), + getFilterAccounting: vi.fn(() => ({ kind: 'unavailable' as const })), + }; (provider as unknown as { _analyzerInitialized: boolean })._analyzerInitialized = true; (provider as unknown as { _analysisRequestId: number })._analysisRequestId = 1; diff --git a/packages/extension/tests/extension/graphViewProvider.viewState.test.ts b/packages/extension/tests/extension/graphViewProvider.viewState.test.ts index c680a46a64..d908fd2c39 100644 --- a/packages/extension/tests/extension/graphViewProvider.viewState.test.ts +++ b/packages/extension/tests/extension/graphViewProvider.viewState.test.ts @@ -292,6 +292,7 @@ describe('GraphViewProvider view state and internal helpers', () => { const notifyGraphRebuild = vi.fn(); (provider as unknown as { _analyzer: { + getFilterAccounting(): { kind: 'unavailable' }; rebuildGraph: typeof rebuildGraph; registry: { list(): []; @@ -302,6 +303,7 @@ describe('GraphViewProvider view state and internal helpers', () => { }; }; })._analyzer = { + getFilterAccounting: vi.fn(() => ({ kind: 'unavailable' as const })), rebuildGraph, registry: { list: () => [], diff --git a/packages/extension/tests/extension/pipeline/lifecycle.test.ts b/packages/extension/tests/extension/pipeline/lifecycle.test.ts index 9916f9be9e..809ef0e6f4 100644 --- a/packages/extension/tests/extension/pipeline/lifecycle.test.ts +++ b/packages/extension/tests/extension/pipeline/lifecycle.test.ts @@ -367,13 +367,14 @@ describe('WorkspacePipeline lifecycle', { timeout: 30000 }, () => { } as unknown as vscode.ExtensionContext); vi.spyOn(repoMetaModule, 'readCodeGraphyRepoMeta').mockReturnValue({ - version: 1, + version: 2, lastIndexedAt: '2026-04-08T00:00:00.000Z', lastIndexedCommit: 'abc123', pluginBuildSignature: 'plugin-build-signature', pluginSignature: 'plugin-signature', settingsSignature: 'settings-signature', pendingChangedFiles: [], + filterAccounting: { kind: 'current', excludedFileCount: 2, gitIgnoredPathCount: 1 }, }); vi.spyOn( analyzer as unknown as { @@ -396,13 +397,14 @@ describe('WorkspacePipeline lifecycle', { timeout: 30000 }, () => { expect(analyzer.hasIndex()).toBe(true); vi.spyOn(repoMetaModule, 'readCodeGraphyRepoMeta').mockReturnValue({ - version: 1, + version: 2, lastIndexedAt: null, lastIndexedCommit: null, pluginBuildSignature: null, pluginSignature: null, settingsSignature: null, pendingChangedFiles: [], + filterAccounting: { kind: 'unavailable' }, }); expect(analyzer.hasIndex()).toBe(false); }); @@ -543,7 +545,7 @@ describe('WorkspacePipeline lifecycle', { timeout: 30000 }, () => { }, } as unknown as vscode.ExtensionContext); writeWorkspaceMetaFixture(workspaceRoot, { - version: 1, + version: 2, lastIndexedAt: '2026-04-08T00:00:00.000Z', pluginBuildSignature: null, pluginSignature: 'old-plugin-signature', @@ -551,6 +553,7 @@ describe('WorkspacePipeline lifecycle', { timeout: 30000 }, () => { analysisVersion: WORKSPACE_ANALYSIS_CACHE_VERSION, pendingChangedFiles: [], failedPluginIds: [], + filterAccounting: { kind: 'current', excludedFileCount: 2, gitIgnoredPathCount: 1 }, }); vi.spyOn( analyzer as unknown as { @@ -584,7 +587,7 @@ describe('WorkspacePipeline lifecycle', { timeout: 30000 }, () => { }, } as unknown as vscode.ExtensionContext); writeWorkspaceMetaFixture(workspaceRoot, { - version: 1, + version: 2, lastIndexedAt: '2026-04-08T00:00:00.000Z', pluginBuildSignature: null, pluginSignature: 'plugin-signature', @@ -592,6 +595,7 @@ describe('WorkspacePipeline lifecycle', { timeout: 30000 }, () => { analysisVersion: WORKSPACE_ANALYSIS_CACHE_VERSION, pendingChangedFiles: [], failedPluginIds: [], + filterAccounting: { kind: 'current', excludedFileCount: 2, gitIgnoredPathCount: 1 }, }); vi.spyOn( analyzer as unknown as { @@ -625,7 +629,7 @@ describe('WorkspacePipeline lifecycle', { timeout: 30000 }, () => { }, } as unknown as vscode.ExtensionContext); writeWorkspaceMetaFixture(workspaceRoot, { - version: 1, + version: 2, lastIndexedAt: '2026-04-08T00:00:00.000Z', pluginBuildSignature: null, pluginSignature: 'plugin-signature', @@ -633,6 +637,7 @@ describe('WorkspacePipeline lifecycle', { timeout: 30000 }, () => { analysisVersion: 'old-analysis-version', pendingChangedFiles: [], failedPluginIds: [], + filterAccounting: { kind: 'current', excludedFileCount: 2, gitIgnoredPathCount: 1 }, }); vi.spyOn( analyzer as unknown as { diff --git a/packages/extension/tests/extension/pipeline/pluginFilterAccounting.integration.test.ts b/packages/extension/tests/extension/pipeline/pluginFilterAccounting.integration.test.ts new file mode 100644 index 0000000000..52605996c4 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/pluginFilterAccounting.integration.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { z } from 'zod'; +import { FileDiscovery, parseCodeGraphyPluginPackageManifest } from '@codegraphy-dev/core'; +import { getWorkspacePipelinePluginFilterPatterns } from '../../../src/extension/pipeline/plugins/bootstrap/filters'; +import { unknownRecordSchema } from '../../../src/shared/values'; + +interface FilterPluginInfo { + plugin: { + id: string; + name: string; + defaultFilters: string[]; + }; +} + +const tempDirectories: string[] = []; + +function createMixedWorkspace(): string { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraphy-filter-accounting-')); + tempDirectories.push(workspaceRoot); + execFileSync('git', ['init', '-q'], { cwd: workspaceRoot }); + + const files: Record = { + '.gitignore': 'ignored/**\n', + 'src/app.ts': 'export const app = true;\n', + '.next/app.js': 'export const app = true;\n', + 'scripts/player.gd': 'extends Node\n', + 'scripts/player.gd.uid': 'uid://player\n', + 'Assets/Player.prefab': '%YAML 1.1\n', + 'Assets/Player.prefab.meta': 'guid: player\n', + 'ignored/ignored.meta': 'guid: ignored\n', + }; + + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(workspaceRoot, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); + } + + return workspaceRoot; +} + +function readCorePlugin(packageName: string): FilterPluginInfo { + const packageJsonPath = path.resolve(__dirname, `../../../../${packageName}/package.json`); + const manifest = parseCodeGraphyPluginPackageManifest( + JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')), + ); + if (!manifest) throw new Error(`Invalid plugin package: ${packageName}`); + const descriptor = manifest.plugins.find(plugin => plugin.host === 'core'); + if (!descriptor) throw new Error(`Core plugin descriptor not found: ${packageName}`); + const metadata = unknownRecordSchema.parse(descriptor.data); + const defaultFilters = z.array(z.string()).parse(metadata.defaultFilters); + if (!descriptor.id) throw new Error(`Core plugin ID not found: ${packageName}`); + if (!descriptor.name) throw new Error(`Core plugin name not found: ${packageName}`); + + return { + plugin: { + id: descriptor.id, + name: descriptor.name, + defaultFilters, + }, + }; +} + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('plugin Filter accounting', () => { + it('tracks shipped TypeScript, Godot, and Unity defaults as plugins become active', async () => { + const workspaceRoot = createMixedWorkspace(); + const plugins = [ + readCorePlugin('plugin-typescript'), + readCorePlugin('plugin-godot'), + readCorePlugin('plugin-unity'), + ]; + const source = { list: () => plugins }; + const discovery = new FileDiscovery(); + + const typescriptOnly = await discovery.discover({ + rootPath: workspaceRoot, + filter: getWorkspacePipelinePluginFilterPatterns( + source, + new Set(['codegraphy.gdscript', 'codegraphy.unity']), + ), + }); + expect(typescriptOnly.filterAccounting).toEqual({ + kind: 'current', + excludedFileCount: 1, + gitIgnoredPathCount: 2, + }); + + const allPlugins = await discovery.discover({ + rootPath: workspaceRoot, + filter: getWorkspacePipelinePluginFilterPatterns(source), + }); + expect(allPlugins.filterAccounting).toEqual({ + kind: 'current', + excludedFileCount: 3, + gitIgnoredPathCount: 2, + }); + expect(allPlugins.files.map(file => file.relativePath)).toEqual(expect.arrayContaining([ + 'Assets/Player.prefab', + 'scripts/player.gd', + 'src/app.ts', + ])); + expect(allPlugins.gitIgnoredPaths).toContain('ignored'); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/cache/index.test.ts b/packages/extension/tests/extension/pipeline/service/cache/index.test.ts index 3a9d059fe6..5c516b9132 100644 --- a/packages/extension/tests/extension/pipeline/service/cache/index.test.ts +++ b/packages/extension/tests/extension/pipeline/service/cache/index.test.ts @@ -17,13 +17,14 @@ describe('pipeline/service/cache/index', () => { const tempRoots = new Set(); const meta = (overrides: Partial = {}): ICodeGraphyRepoMeta => ({ - version: 1, + version: 2, lastIndexedAt: '2026-01-01T00:00:00.000Z', lastIndexedCommit: 'abc123', pluginBuildSignature: null, pluginSignature: 'plugin-signature', settingsSignature: 'settings-signature', pendingChangedFiles: [], + filterAccounting: { kind: 'unavailable' }, ...overrides, }); @@ -120,6 +121,7 @@ describe('pipeline/service/cache/index', () => { const warn = vi.fn(); const persistIndexMetadata = vi.fn(); const dependencies = { + getFilterAccounting: vi.fn(() => ({ kind: 'current' as const, excludedFileCount: 3, gitIgnoredPathCount: 2 })), getPluginBuildSignature: vi.fn(() => 'next-plugin-build-signature'), getPluginSignature: vi.fn(() => 'next-plugin-signature'), getSettingsSignature: vi.fn(() => 'next-settings-signature'), @@ -130,6 +132,7 @@ describe('pipeline/service/cache/index', () => { await persistWorkspacePipelineIndexMetadata('/workspace', dependencies); expect(persistIndexMetadata).toHaveBeenCalledWith('/workspace', { + filterAccounting: { kind: 'current', excludedFileCount: 3, gitIgnoredPathCount: 2 }, pluginBuildSignature: 'next-plugin-build-signature', pluginSignature: 'next-plugin-signature', settingsSignature: 'next-settings-signature', @@ -140,6 +143,7 @@ describe('pipeline/service/cache/index', () => { it('records the current commit when index metadata is persisted', async () => { const persistIndexMetadata = vi.fn(); const dependencies = { + getFilterAccounting: vi.fn(() => ({ kind: 'current' as const, excludedFileCount: 3, gitIgnoredPathCount: 2 })), getCurrentCommitSha: vi.fn(() => 'def456'), getPluginBuildSignature: vi.fn(() => 'next-plugin-build-signature'), getPluginSignature: vi.fn(() => 'next-plugin-signature'), @@ -151,6 +155,7 @@ describe('pipeline/service/cache/index', () => { await persistWorkspacePipelineIndexMetadata('/workspace', dependencies); expect(persistIndexMetadata).toHaveBeenCalledWith('/workspace', { + filterAccounting: { kind: 'current', excludedFileCount: 3, gitIgnoredPathCount: 2 }, lastIndexedCommit: 'def456', pluginBuildSignature: 'next-plugin-build-signature', pluginSignature: 'next-plugin-signature', @@ -166,6 +171,7 @@ describe('pipeline/service/cache/index', () => { })); const persistIndexMetadata = vi.fn(); const dependencies = { + getFilterAccounting: vi.fn(() => ({ kind: 'current' as const, excludedFileCount: 3, gitIgnoredPathCount: 2 })), getPluginBuildSignature: vi.fn(() => 'plugin-build-signature'), getPluginSignature: vi.fn(() => 'plugin-signature'), getSettingsSignature: vi.fn(() => 'settings-signature'), @@ -176,6 +182,7 @@ describe('pipeline/service/cache/index', () => { await persistWorkspacePipelineIndexMetadata('/workspace', dependencies); expect(persistIndexMetadata).toHaveBeenCalledWith('/workspace', { + filterAccounting: { kind: 'current', excludedFileCount: 3, gitIgnoredPathCount: 2 }, pluginBuildSignature: 'plugin-build-signature', pluginSignature: 'plugin-signature', settingsSignature: 'settings-signature', @@ -189,6 +196,7 @@ describe('pipeline/service/cache/index', () => { throw error; }); const dependencies = { + getFilterAccounting: vi.fn(() => ({ kind: 'unavailable' as const })), getPluginBuildSignature: vi.fn(() => 'next-plugin-build-signature'), getPluginSignature: vi.fn(() => 'next-plugin-signature'), getSettingsSignature: vi.fn(() => 'next-settings-signature'), @@ -214,6 +222,7 @@ describe('pipeline/service/cache/index', () => { const warn = vi.fn(); await expect(persistWorkspacePipelineIndexMetadata('/workspace', { + getFilterAccounting: vi.fn(() => ({ kind: 'unavailable' as const })), getPluginBuildSignature: vi.fn(() => null), getPluginSignature: vi.fn(() => null), getSettingsSignature: vi.fn(() => 'settings-signature'), diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts index a32ac54d87..fc85c74dfc 100644 --- a/packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts +++ b/packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts @@ -3,6 +3,7 @@ import { type AnalysisCacheTier, hasRequiredAnalysisCacheTiers, projectFileAnalysisConnections, + readCodeGraphyWorkspaceMeta, throwIfWorkspaceAnalysisAborted, type FileDiscovery, type IDiscoveredFile, @@ -19,6 +20,7 @@ vi.mock('@codegraphy-dev/core', async (importOriginal) => { ...actual, hasRequiredAnalysisCacheTiers: vi.fn(), projectFileAnalysisConnections: vi.fn(), + readCodeGraphyWorkspaceMeta: vi.fn(actual.readCodeGraphyWorkspaceMeta), throwIfWorkspaceAnalysisAborted: vi.fn(), }; }); @@ -146,6 +148,7 @@ interface CachedGraphState { _lastFileConnections: Map; _lastGitIgnoredPaths: string[]; _lastWorkspaceRoot: string; + _filterAccounting: import('@codegraphy-dev/core').WorkspaceFilterAccounting; } function cachedGraphState(facade: TestCachedGraphFacade): CachedGraphState { @@ -175,6 +178,17 @@ describe('extension/pipeline/service/cachedGraph', () => { vi.clearAllMocks(); vi.mocked(hasWorkspacePipelineIndex).mockReturnValue(false); vi.mocked(hasRequiredAnalysisCacheTiers).mockReturnValue(true); + vi.mocked(readCodeGraphyWorkspaceMeta).mockReturnValue({ + version: 2, + lastIndexedAt: null, + pluginSignature: null, + pluginBuildSignature: null, + settingsSignature: null, + analysisVersion: null, + pendingChangedFiles: [], + failedPluginIds: [], + filterAccounting: { kind: 'unavailable' }, + }); setupCachedDiscovery(); }); @@ -232,6 +246,33 @@ describe('extension/pipeline/service/cachedGraph', () => { }); + it('restores authoritative discovery accounting when excluded files never entered the cache', async () => { + vi.mocked(readCodeGraphyWorkspaceMeta).mockReturnValue({ + version: 2, + lastIndexedAt: '2026-08-06T12:00:00.000Z', + pluginSignature: 'plugins', + pluginBuildSignature: 'build', + settingsSignature: 'settings', + analysisVersion: 'analysis', + pendingChangedFiles: [], + failedPluginIds: [], + filterAccounting: { kind: 'current', excludedFileCount: 4, gitIgnoredPathCount: 2 }, + }); + const restartedFacade = new TestCachedGraphFacade(); + + await restartedFacade.loadCachedGraph(); + + expect(cachedGraphState(restartedFacade)._filterAccounting).toEqual({ + kind: 'current', + excludedFileCount: 4, + gitIgnoredPathCount: 2, + }); + expect(createCachedWorkspaceDiscoveryState).toHaveBeenCalledWith( + '/workspace', + ['src/cached.ts'], + ); + }); + it('marks replayed state recoverable only when it came from a persisted index', async () => { const facade = new TestCachedGraphFacade(); diff --git a/packages/extension/tests/extension/pipeline/service/refresh/modes/gitignoreMetadata.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/modes/gitignoreMetadata.test.ts index 8e692aed92..6583c11b93 100644 --- a/packages/extension/tests/extension/pipeline/service/refresh/modes/gitignoreMetadata.test.ts +++ b/packages/extension/tests/extension/pipeline/service/refresh/modes/gitignoreMetadata.test.ts @@ -52,6 +52,7 @@ function createFacade( _lastDiscoveredFiles: [createFile('old.ts')], _lastFileAnalysis: new Map([['src/a.ts', { filePath: '/workspace/src/a.ts' }]]), _lastFileConnections: new Map([['src/a.ts', [{ from: 'src/a.ts', to: 'src/b.ts' }]]]), + _filterAccounting: { kind: 'current', excludedFileCount: 1, gitIgnoredPathCount: 1 }, _lastGitIgnoredPaths: ['old-ignore'], _lastGraphData: createGraph('last'), _lastWorkspaceRoot: '/old-workspace', @@ -95,19 +96,15 @@ describe('extension/pipeline/service/refresh/modes/gitignoreMetadata', () => { const files = [createFile('src/new.ts')]; const disabledPlugins = new Set(['plugin.disabled']); const signal = new AbortController().signal; - const persistError = new Error('metadata failed'); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const facade = createFacade({ _buildGraphDataFromAnalysis: vi.fn(() => graph), - _persistIndexMetadata: vi.fn(async () => { - throw persistError; - }), }); vi.mocked(discoverRefreshWorkspaceFiles).mockResolvedValue({ config: { showOrphans: false }, discoveryResult: { directories: undefined, files, + filterAccounting: { kind: 'current', excludedFileCount: 3, gitIgnoredPathCount: 2 }, gitIgnoredPaths: undefined, }, } as never); @@ -119,7 +116,6 @@ describe('extension/pipeline/service/refresh/modes/gitignoreMetadata', () => { signal, }), ).resolves.toBe(graph); - await Promise.resolve(); expect(discoverRefreshWorkspaceFiles).toHaveBeenCalledWith({ configReader: facade._config, @@ -135,21 +131,21 @@ describe('extension/pipeline/service/refresh/modes/gitignoreMetadata', () => { expect(facade.getPluginFilterPatterns).toHaveBeenCalledWith(disabledPlugins); expect(facade._lastDiscoveredDirectories).toEqual([]); expect(facade._lastDiscoveredFiles).toBe(files); + expect(facade._filterAccounting).toEqual({ + kind: 'current', + excludedFileCount: 3, + gitIgnoredPathCount: 2, + }); expect(facade._lastGitIgnoredPaths).toEqual([]); expect(facade._lastWorkspaceRoot).toBe('/workspace'); expect(facade._lastFileAnalysis).toEqual(new Map()); expect(facade._lastFileConnections).toEqual(new Map()); expect(facade._persistIndexMetadata).toHaveBeenCalledOnce(); - expect(warn).toHaveBeenCalledWith( - '[CodeGraphy] Failed to persist gitignore metadata refresh.', - persistError, - ); expect(facade._buildGraphDataFromAnalysis).toHaveBeenCalledWith( new Map(), '/workspace', false, disabledPlugins, ); - warn.mockRestore(); }); }); diff --git a/packages/extension/tests/extension/pipeline/service/refresh/modes/pluginFiles.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/modes/pluginFiles.test.ts index 503a1241e9..308c946a29 100644 --- a/packages/extension/tests/extension/pipeline/service/refresh/modes/pluginFiles.test.ts +++ b/packages/extension/tests/extension/pipeline/service/refresh/modes/pluginFiles.test.ts @@ -54,6 +54,7 @@ function createFacade( _lastDiscoveredFiles: [], _lastFileAnalysis: new Map(), _lastFileConnections: new Map(), + _filterAccounting: { kind: 'current', excludedFileCount: 1, gitIgnoredPathCount: 1 }, _lastGitIgnoredPaths: ['old-ignore'], _lastGraphData: createGraph('last'), _lastWorkspaceRoot: '/workspace', @@ -122,6 +123,7 @@ describe('extension/pipeline/service/refresh/modes/pluginFiles', () => { discoveryResult: { directories: undefined, files, + filterAccounting: { kind: 'current', excludedFileCount: 1, gitIgnoredPathCount: 0 }, gitIgnoredPaths: undefined, }, } as never); @@ -149,6 +151,11 @@ describe('extension/pipeline/service/refresh/modes/pluginFiles', () => { const getPluginFilterPatterns = vi.mocked(discoverRefreshWorkspaceFiles).mock.calls[0][0].getPluginFilterPatterns; expect(getPluginFilterPatterns(disabledPlugins)).toEqual(['plugin/**']); expect(facade.getPluginFilterPatterns).toHaveBeenCalledWith(disabledPlugins); + expect(facade._filterAccounting).toEqual({ + kind: 'current', + excludedFileCount: 1, + gitIgnoredPathCount: 0, + }); expect(facade._lastGitIgnoredPaths).toEqual([]); expect(createWorkspaceIndexRefreshSource).toHaveBeenCalledWith(facade, disabledPlugins); expect(facade._registry.list).toHaveBeenCalledOnce(); diff --git a/packages/extension/tests/extension/pipeline/service/runtime/discovery.test.ts b/packages/extension/tests/extension/pipeline/service/runtime/discovery.test.ts index aac42bab0c..6d0ab8692b 100644 --- a/packages/extension/tests/extension/pipeline/service/runtime/discovery.test.ts +++ b/packages/extension/tests/extension/pipeline/service/runtime/discovery.test.ts @@ -25,6 +25,7 @@ describe('pipeline/service/discovery', () => { durationMs: 12, files: [{ relativePath: 'src/a.ts' }], cacheFilePaths: ['src/a.ts', 'example-python/app.py'], + filterAccounting: { kind: 'current' as const, excludedFileCount: 1, gitIgnoredPathCount: 1 }, gitIgnoredPaths: ['example-python/app.py'], limitReached: false, totalFound: undefined, @@ -39,6 +40,7 @@ describe('pipeline/service/discovery', () => { directories: ['src/new-folder'], durationMs: 12, files: [{ relativePath: 'src/a.ts' }], + filterAccounting: { kind: 'current', excludedFileCount: 1, gitIgnoredPathCount: 1 }, gitIgnoredPaths: ['example-python/app.py'], limitReached: false, totalFound: 1, diff --git a/packages/extension/tests/extension/repoSettings/freshness/index.test.ts b/packages/extension/tests/extension/repoSettings/freshness/index.test.ts index e7bea6042c..7e52d63d9c 100644 --- a/packages/extension/tests/extension/repoSettings/freshness/index.test.ts +++ b/packages/extension/tests/extension/repoSettings/freshness/index.test.ts @@ -6,13 +6,14 @@ import { evaluateCodeGraphyIndexStatus } from '../../../../src/extension/repoSet import type { ICodeGraphyRepoMeta } from '../../../../src/extension/repoSettings/meta'; const indexedMeta: ICodeGraphyRepoMeta = { - version: 1, + version: 2, lastIndexedAt: '2026-04-27T12:00:00.000Z', lastIndexedCommit: 'abc123', pluginBuildSignature: 'plugin-build', pluginSignature: 'plugins', settingsSignature: 'settings', pendingChangedFiles: [], + filterAccounting: { kind: 'current', excludedFileCount: 2, gitIgnoredPathCount: 1 }, }; describe('repoSettings/freshness/index', () => { diff --git a/packages/extension/tests/extension/repoSettings/freshness/reasons.test.ts b/packages/extension/tests/extension/repoSettings/freshness/reasons.test.ts index aa6eabd6ac..380e7d5182 100644 --- a/packages/extension/tests/extension/repoSettings/freshness/reasons.test.ts +++ b/packages/extension/tests/extension/repoSettings/freshness/reasons.test.ts @@ -3,13 +3,14 @@ import { collectStaleReasons } from '../../../../src/extension/repoSettings/fres import type { ICodeGraphyRepoMeta } from '../../../../src/extension/repoSettings/meta'; const indexedMeta: ICodeGraphyRepoMeta = { - version: 1, + version: 2, lastIndexedAt: '2026-04-27T12:00:00.000Z', lastIndexedCommit: 'abc123', pluginBuildSignature: 'plugin-build', pluginSignature: 'plugins', settingsSignature: 'settings', pendingChangedFiles: [], + filterAccounting: { kind: 'current', excludedFileCount: 2, gitIgnoredPathCount: 1 }, }; describe('repoSettings/freshness/reasons', () => { diff --git a/packages/extension/tests/extension/repoSettings/meta.test.ts b/packages/extension/tests/extension/repoSettings/meta.test.ts index f02120d27c..b836f3a1bc 100644 --- a/packages/extension/tests/extension/repoSettings/meta.test.ts +++ b/packages/extension/tests/extension/repoSettings/meta.test.ts @@ -47,7 +47,7 @@ describe('extension/repoSettings/meta', () => { expect(path.basename(getCodeGraphyRepoMetaPath('/workspace/project'))).toBe('meta.json'); }); - it('merges partial persisted meta with the default fields', () => { + it('resets incomplete persisted metadata to the current default snapshot', () => { const workspaceRoot = createTempWorkspace(); tempDirectories.push(workspaceRoot); const metaPath = getCodeGraphyRepoMetaPath(workspaceRoot); @@ -63,13 +63,14 @@ describe('extension/repoSettings/meta', () => { ); expect(readCodeGraphyRepoMeta(workspaceRoot)).toEqual({ - version: 1, + version: 2, lastIndexedAt: null, - lastIndexedCommit: 'abc123', + lastIndexedCommit: null, pluginBuildSignature: null, pluginSignature: null, settingsSignature: null, - pendingChangedFiles: ['src/app.ts'], + pendingChangedFiles: [], + filterAccounting: { kind: 'unavailable' }, }); }); @@ -82,7 +83,9 @@ describe('extension/repoSettings/meta', () => { fs.writeFileSync( metaPath, JSON.stringify({ + version: 2, lastIndexedCommit: 'abc123', + filterAccounting: { kind: 'unavailable' }, }, null, 2), 'utf8', ); @@ -110,13 +113,14 @@ describe('extension/repoSettings/meta', () => { ); expect(readCodeGraphyRepoMeta(workspaceRoot)).toEqual({ - version: 1, + version: 2, lastIndexedAt: null, - lastIndexedCommit: 'abc123', + lastIndexedCommit: null, pluginBuildSignature: null, pluginSignature: null, settingsSignature: null, - pendingChangedFiles: ['src/app.ts', 'src/index.ts'], + pendingChangedFiles: [], + filterAccounting: { kind: 'unavailable' }, }); }); diff --git a/packages/extension/tests/playwright-vscode/filter-accounting.spec.ts b/packages/extension/tests/playwright-vscode/filter-accounting.spec.ts new file mode 100644 index 0000000000..1877cd1511 --- /dev/null +++ b/packages/extension/tests/playwright-vscode/filter-accounting.spec.ts @@ -0,0 +1,75 @@ +import { expect, test } from '@playwright/test'; +import fs from 'node:fs'; +import path from 'node:path'; +import { clickToolbarButton, getGraphCounts } from '../acceptance/graphView/canvas'; +import { + copyExampleWorkspace, + createWorkspaceTempRoot, +} from '../acceptance/graphView/workspace'; +import { + cleanupVSCode, + launchVSCodeWithWorkspace, + openGraphView, + waitForGraphFrame, +} from '../acceptance/graphView/vscode'; + +test('reports plugin default Filter effects in an Extension Development Host', async ({}, testInfo) => { + const workspaceTempRoot = createWorkspaceTempRoot(); + const workspacePath = copyExampleWorkspace(workspaceTempRoot, 'example-typescript', { + pluginPackages: [], + }); + const filteredFilePath = path.join(workspacePath, '.next', 'generated.js'); + fs.mkdirSync(path.dirname(filteredFilePath), { recursive: true }); + fs.writeFileSync(filteredFilePath, 'export const generated = true;\n'); + const proofDirectory = process.env.CODEGRAPHY_VISUAL_PROOF_DIR ?? testInfo.outputDir; + fs.mkdirSync(proofDirectory, { recursive: true }); + const vscode = await launchVSCodeWithWorkspace(workspacePath, { + pluginPackageRelativePaths: ['packages/plugin-typescript'], + }); + + try { + await openGraphView(vscode.page); + const frame = await waitForGraphFrame(vscode.page); + await indexWorkspace(frame); + const beforeCounts = await getGraphCounts(frame); + + await frame.getByRole('button', { name: /^Filters,/ }).click(); + await expect(frame.getByText('Before analysis: 0 workspace files excluded')).toBeVisible(); + await expect(frame.getByText('In Graph View: 0 Nodes excluded')).toBeVisible(); + await frame.locator('[data-codegraphy-surface="app"]').screenshot({ + path: path.join(proofDirectory, 'trello-242-before.png'), + }); + + await frame.getByRole('button', { name: /^Filters,/ }).click(); + await clickToolbarButton(frame, 'Plugins'); + await frame.getByRole('switch', { name: 'TypeScript/JavaScript' }).click(); + await expect(frame.getByRole('switch', { name: 'TypeScript/JavaScript' })).toBeChecked(); + await expect(frame.getByRole('progressbar', { name: 'Indexing progress' })).toBeHidden({ timeout: 30_000 }); + await clickToolbarButton(frame, 'Plugins'); + await frame.getByTitle('Re-index Workspace').click(); + await expect(frame.getByRole('progressbar', { name: 'Indexing progress' })).toBeHidden({ timeout: 30_000 }); + await expect.poll(() => getGraphCounts(frame)).toEqual({ + nodes: beforeCounts.nodes - 1, + edges: expect.any(Number), + }); + + await frame.getByRole('button', { name: /^Filters,/ }).click(); + await expect(frame.getByText('Before analysis: 1 workspace file excluded')).toBeVisible(); + await expect(frame.getByText('In Graph View: 0 Nodes excluded')).toBeVisible(); + await frame.locator('[data-codegraphy-surface="app"]').screenshot({ + path: path.join(proofDirectory, 'trello-242-after.png'), + }); + } finally { + await cleanupVSCode(vscode); + fs.rmSync(workspaceTempRoot, { recursive: true, force: true }); + } +}); + +async function indexWorkspace(frame: Parameters[0]): Promise { + const indexButton = frame.getByRole('button', { name: 'Index Workspace' }); + await expect(indexButton).toBeVisible(); + await indexButton.click(); + await expect( + frame.getByRole('progressbar', { name: 'Indexing progress' }), + ).toBeHidden({ timeout: 30_000 }); +} diff --git a/packages/extension/tests/webview/app/shell/view/visibleGraph.test.tsx b/packages/extension/tests/webview/app/shell/view/visibleGraph.test.tsx index fdf94090b4..af5699ee42 100644 --- a/packages/extension/tests/webview/app/shell/view/visibleGraph.test.tsx +++ b/packages/extension/tests/webview/app/shell/view/visibleGraph.test.tsx @@ -149,6 +149,7 @@ describe('App visible graph',()=>{ { pluginId: 'plugin.one', pluginName: 'Plugin One', patterns: ['**/generated/**'] }, ], pluginFilterPatterns: ['**/generated/**'], + filterAccounting: { kind: 'current', excludedFileCount: 3, gitIgnoredPathCount: 1 }, disabledPluginFilterPatterns: [], showOrphans: true, }); @@ -156,11 +157,13 @@ describe('App visible graph',()=>{ render(); fireEvent.click(screen.getByRole('button', { name: 'Filters, 1 enabled' })); - expect(screen.getByText('1 excluded from graph')).toBeInTheDocument(); + expect(screen.getByText('Before analysis: 3 workspace files excluded')).toBeInTheDocument(); + expect(screen.getByText('In Graph View: 1 Node excluded')).toBeInTheDocument(); fireEvent.click(screen.getByLabelText('Disable plugin Plugin One filters')); - expect(screen.getByText('0 excluded from graph')).toBeInTheDocument(); + expect(screen.getByText('Before analysis: 3 workspace files excluded')).toBeInTheDocument(); + expect(screen.getByText('In Graph View: 0 Nodes excluded')).toBeInTheDocument(); }); it('should hide graph corner controls while a right-side popup is open', async () => { diff --git a/packages/extension/tests/webview/searchBar/filters/popover.test.tsx b/packages/extension/tests/webview/searchBar/filters/popover.test.tsx index d19b56b95b..44b5e6a04b 100644 --- a/packages/extension/tests/webview/searchBar/filters/popover.test.tsx +++ b/packages/extension/tests/webview/searchBar/filters/popover.test.tsx @@ -14,7 +14,8 @@ function renderPopover(overrides: Partial { expect(screen.getByText('Filters')).toBeInTheDocument(); expect(screen.getByText('2 enabled')).toBeInTheDocument(); - expect(screen.getByText('4 excluded from graph')).toBeInTheDocument(); + expect(screen.getByText('Before analysis: 4 workspace files excluded')).toBeInTheDocument(); + expect(screen.getByText('In Graph View: 2 Nodes excluded')).toBeInTheDocument(); expect(screen.getByText('Custom')).toBeInTheDocument(); expect(screen.getByText('Plugin defaults')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Filters, 2 enabled' }).className).toContain('bg-secondary'); }); + it('reports pre-analysis File and Graph View Node exclusions without reconciling overlap', () => { + renderPopover({ + filterAccounting: { kind: 'current', excludedFileCount: 1, gitIgnoredPathCount: 1 }, + excludedNodeCount: 1, + }); + + expect(screen.getByText('Before analysis: 1 workspace file excluded')).toBeInTheDocument(); + expect(screen.getByText('In Graph View: 1 Node excluded')).toBeInTheDocument(); + expect(screen.queryByText(/2 .*excluded/)).not.toBeInTheDocument(); + }); + + it('does not report zero when persisted discovery accounting is unavailable', () => { + renderPopover({ filterAccounting: { kind: 'unavailable' } }); + + expect(screen.getByText( + 'Before analysis: re-index to calculate excluded workspace files', + )).toBeInTheDocument(); + }); + it('opens and closes from its own trigger when uncontrolled', () => { const props = renderPopover({ onOpenChange: vi.fn(), open: undefined }); diff --git a/packages/extension/tests/webview/store/messageHandlers/graph/controls.test.ts b/packages/extension/tests/webview/store/messageHandlers/graph/controls.test.ts index f58a62ba7e..b108c076dd 100644 --- a/packages/extension/tests/webview/store/messageHandlers/graph/controls.test.ts +++ b/packages/extension/tests/webview/store/messageHandlers/graph/controls.test.ts @@ -36,6 +36,7 @@ describe('graph message handlers: controls',()=>{ it('maps graph controls and favorites payloads', () => { const controls: IGraphControlsSnapshot = { + filterAccounting: { kind: 'current', excludedFileCount: 3, gitIgnoredPathCount: 2 }, nodeTypes: [{ id: 'file', label: 'File', defaultColor: '#A1A1AA', defaultVisible: true }], edgeTypes: [{ id: 'import', label: 'Import', defaultColor: '#64748B', defaultVisible: true }], nodeColors: { file: '#A1A1AA' }, @@ -47,6 +48,7 @@ describe('graph message handlers: controls',()=>{ type: 'GRAPH_CONTROLS_UPDATED', payload: controls, })).toEqual({ + filterAccounting: controls.filterAccounting, graphNodeTypes: controls.nodeTypes, graphEdgeTypes: controls.edgeTypes, nodeColors: controls.nodeColors, @@ -64,6 +66,7 @@ describe('graph message handlers: controls',()=>{ it('skips graph controls updates when extension echoes the current controls', () => { const controls: IGraphControlsSnapshot = { + filterAccounting: { kind: 'current', excludedFileCount: 3, gitIgnoredPathCount: 2 }, nodeTypes: [{ id: 'file', label: 'File', defaultColor: '#A1A1AA', defaultVisible: true }], edgeTypes: [{ id: 'import', label: 'Import', defaultColor: '#64748B', defaultVisible: true }], nodeColors: { file: '#A1A1AA' }, @@ -71,6 +74,7 @@ describe('graph message handlers: controls',()=>{ edgeVisibility: { import: false }, }; const state = createState({ + filterAccounting: controls.filterAccounting, graphNodeTypes: controls.nodeTypes, graphEdgeTypes: controls.edgeTypes, nodeColors: controls.nodeColors, @@ -78,6 +82,7 @@ describe('graph message handlers: controls',()=>{ edgeVisibility: controls.edgeVisibility, }); const echoedControls: IGraphControlsSnapshot = { + filterAccounting: { ...controls.filterAccounting }, nodeTypes: [...controls.nodeTypes], edgeTypes: [...controls.edgeTypes], nodeColors: { ...controls.nodeColors }, @@ -93,6 +98,7 @@ describe('graph message handlers: controls',()=>{ it('returns only changed graph control fields when extension echoes partial changes', () => { const controls: IGraphControlsSnapshot = { + filterAccounting: { kind: 'current', excludedFileCount: 3, gitIgnoredPathCount: 2 }, nodeTypes: [{ id: 'file', label: 'File', defaultColor: '#A1A1AA', defaultVisible: true }], edgeTypes: [{ id: 'import', label: 'Import', defaultColor: '#64748B', defaultVisible: true }], nodeColors: { file: '#A1A1AA' }, @@ -100,6 +106,7 @@ describe('graph message handlers: controls',()=>{ edgeVisibility: { import: false }, }; const state = createState({ + filterAccounting: controls.filterAccounting, graphNodeTypes: controls.nodeTypes, graphEdgeTypes: controls.edgeTypes, nodeColors: controls.nodeColors, @@ -108,6 +115,7 @@ describe('graph message handlers: controls',()=>{ }); const nextEdgeVisibility = { import: true }; const echoedControls: IGraphControlsSnapshot = { + filterAccounting: { kind: 'current', excludedFileCount: 4, gitIgnoredPathCount: 2 }, nodeTypes: [...controls.nodeTypes], edgeTypes: [...controls.edgeTypes], nodeColors: { ...controls.nodeColors }, @@ -118,6 +126,9 @@ describe('graph message handlers: controls',()=>{ expect(handleGraphControlsUpdated( { type: 'GRAPH_CONTROLS_UPDATED', payload: echoedControls }, { getState: () => state }, - )).toEqual({ edgeVisibility: nextEdgeVisibility }); + )).toEqual({ + edgeVisibility: nextEdgeVisibility, + filterAccounting: { kind: 'current', excludedFileCount: 4, gitIgnoredPathCount: 2 }, + }); }); }); diff --git a/packages/extension/tests/webview/store/messageHandlers/graph/fixture.ts b/packages/extension/tests/webview/store/messageHandlers/graph/fixture.ts index d2833f4d09..a4f8869edb 100644 --- a/packages/extension/tests/webview/store/messageHandlers/graph/fixture.ts +++ b/packages/extension/tests/webview/store/messageHandlers/graph/fixture.ts @@ -37,6 +37,7 @@ export function createState( optimisticLegendUpdates: {}, optimisticUserLegends: null, filterPatterns: [], + filterAccounting: { kind: 'unavailable' }, pluginFilterPatterns: [], pluginFilterGroups: [], disabledCustomFilterPatterns: [], diff --git a/packages/extension/tests/webview/store/messageHandlers/graph/settings.test.ts b/packages/extension/tests/webview/store/messageHandlers/graph/settings.test.ts index a77d7fe1db..8e105e9449 100644 --- a/packages/extension/tests/webview/store/messageHandlers/graph/settings.test.ts +++ b/packages/extension/tests/webview/store/messageHandlers/graph/settings.test.ts @@ -43,6 +43,7 @@ describe('graph message handlers: settings',()=>{ disabledCustomFilterPatterns: ['custom/**'], disabledPluginFilterPatterns: [], }); + }); it('maps depth, direction, physics, labels, max-files, and active-file payloads', () => {