From de2faac03192afd6b8ea02a7e2c70af415186941 Mon Sep 17 00:00:00 2001 From: "Pier Dolique (Perdolique)" Date: Tue, 8 Sep 2026 00:41:43 +0300 Subject: [PATCH 1/2] feat(workspaceScan): add workspace scans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ✨ Add cancellable workspace scans and clear-results commands with Problems diagnostics - ♻️ Share pending analysis with CodeLens while preserving independent cancellation and four reference lookup slots - ✨ Keep scan snapshots, mark surviving findings stale after edits, and report incomplete analysis - ✅ Cover closed files, multiple roots, exclusions, cancellation, and snapshot lifecycle - 📚 Document scan commands, exclusions, snapshots, and reference graph limitations --- .vscode-test.mjs | 6 + README.md | 19 +- package.json | 10 + src/analysis.ts | 246 ++++++++++-- src/analysisLifecycle.ts | 12 +- src/codeLensProvider.ts | 9 +- src/config.ts | 5 +- src/extension.ts | 60 ++- src/workspaceScan.ts | 335 ++++++++++++++++ test/analysis.test.ts | 122 ++++++ test/config.test.ts | 15 +- test/fixtures/workspace/scan/closed-cjs.cjs | 1 + test/fixtures/workspace/scan/closed-cts.cts | 1 + test/fixtures/workspace/scan/closed-js.js | 1 + test/fixtures/workspace/scan/closed-jsx.jsx | 1 + test/fixtures/workspace/scan/closed-mjs.mjs | 1 + test/fixtures/workspace/scan/closed-mts.mts | 1 + test/fixtures/workspace/scan/closed-ts.ts | 3 + test/fixtures/workspace/scan/closed-tsx.tsx | 1 + test/fixtures/workspace/scan/consumer.ts | 3 + test/fixtures/workspace/scan/tsconfig.json | 10 + test/multiRoot/workspaceScan.test.ts | 49 +++ test/noWorkspace/workspaceScan.test.ts | 41 ++ test/scanIntegration/workspaceScan.test.ts | 89 ++++ test/workspaceScan.test.ts | 424 ++++++++++++++++++++ 25 files changed, 1425 insertions(+), 40 deletions(-) create mode 100644 src/workspaceScan.ts create mode 100644 test/fixtures/workspace/scan/closed-cjs.cjs create mode 100644 test/fixtures/workspace/scan/closed-cts.cts create mode 100644 test/fixtures/workspace/scan/closed-js.js create mode 100644 test/fixtures/workspace/scan/closed-jsx.jsx create mode 100644 test/fixtures/workspace/scan/closed-mjs.mjs create mode 100644 test/fixtures/workspace/scan/closed-mts.mts create mode 100644 test/fixtures/workspace/scan/closed-ts.ts create mode 100644 test/fixtures/workspace/scan/closed-tsx.tsx create mode 100644 test/fixtures/workspace/scan/consumer.ts create mode 100644 test/fixtures/workspace/scan/tsconfig.json create mode 100644 test/multiRoot/workspaceScan.test.ts create mode 100644 test/noWorkspace/workspaceScan.test.ts create mode 100644 test/scanIntegration/workspaceScan.test.ts create mode 100644 test/workspaceScan.test.ts diff --git a/.vscode-test.mjs b/.vscode-test.mjs index fac462d..2113493 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -32,6 +32,12 @@ const shared = { } export default defineConfig([ + { + ...shared, + label: 'scan-integration', + files: 'build-test/test/scanIntegration/*.test.js', + workspaceFolder: singleRoot + }, { ...shared, label: 'single-root', diff --git a/README.md b/README.md index 51d3cfd..2eb99df 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,23 @@ Properties are analyzed only when Visual Studio Code reports them as direct clas Zero references describes the current workspace reference graph, not proof that code is unused everywhere. External consumers and dynamic calls may not be visible to the language provider. +Workspace Scan +-------------- + +Run **Zero Reference: Scan Workspace** from the Command Palette to check TypeScript and JavaScript files in every workspace folder, including files that are not open in an editor. The scan supports `.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, and `.cjs`. It runs only when requested and works even with CodeLens disabled. + +The progress notification shows the current file and supports cancellation. Documents are loaded without opening editor tabs or saving changes; files already open are analyzed using their current unsaved text. Untitled documents and files outside the workspace are not included. + +The scan respects `files.exclude`, `zeroReference.exclude`, and suppression comments, and always skips `.git` and `node_modules` directories. It follows VS Code's file-search API: `search.exclude` and `.gitignore` are not applied automatically. Use the existing exclusion settings for generated files. References from excluded files still count. + +Results appear together in **Problems**, with the source **Zero Reference**. Each information-level entry navigates to the declaration name and uses the theme's unnecessary-code presentation. Enable the Problems panel's information filter if these entries are hidden. The summary reports checked, skipped, and incompletely checked files and findings; files omitted by the initial file search are not counted as skipped. Provider failures and unresolved references make the scan incomplete rather than claiming that the project is clean. **Show Output** opens the detailed log, including original provider errors. + +Results are a snapshot of the last scan, kept in memory for this window even when documents close. After code changes, entries for changed, deleted, or renamed files are removed. Other entries say **Results from a previous scan; run Scan Workspace to update** and stop fading code until another scan finishes. Changing exclusions or workspace folders clears the snapshot. Toggling CodeLens does not affect it. + +A new scan replaces the previous snapshot only after its file traversal finishes, including when there are no findings. Cancellation or file-discovery failure keeps the previous snapshot. Editing the reference graph during a scan cancels it to avoid combining different project states. Already-issued provider requests may finish after cancellation, but their results cannot overwrite the snapshot. + +Run **Zero Reference: Clear Workspace Scan Results** to cancel any active scan and remove its findings. Re-running **Scan Workspace** cancels the previous run. **Zero Reference: Refresh** invalidates analysis and marks scan results as outdated; it does not automatically scan the workspace. Quick Fix continues to use only current cached analysis, never an outdated scan entry. No scan runs in the background automatically, and no code is deleted. + Exclusions and Suppression -------------------------- @@ -32,7 +49,7 @@ Use `zeroReference.exclude` to skip declarations in selected files: } ``` -Patterns use VS Code glob syntax and are relative to the file's workspace folder, including in multi-root workspaces. User, workspace, and folder settings follow VS Code's normal precedence. The default is an empty array; `files.exclude` is not applied automatically. Files outside a workspace folder are not excluded. References **from** excluded files still count when analyzing other files. +Patterns use VS Code glob syntax and are relative to the file's workspace folder, including in multi-root workspaces. User, workspace, and folder settings follow VS Code's normal precedence. The default is an empty array; editor CodeLens does not apply `files.exclude`, while workspace scans use it during file discovery. Files outside a workspace folder are not excluded from editor analysis. References **from** excluded files still count when analyzing other files. To suppress one declaration, place this comment immediately before its declaration line: diff --git a/package.json b/package.json index 220e023..755daf9 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,16 @@ "command": "zeroReference.refresh", "title": "Refresh", "category": "Zero Reference" + }, + { + "command": "zeroReference.scanWorkspace", + "title": "Scan Workspace", + "category": "Zero Reference" + }, + { + "command": "zeroReference.clearWorkspaceScanResults", + "title": "Clear Workspace Scan Results", + "category": "Zero Reference" } ] }, diff --git a/src/analysis.ts b/src/analysis.ts index 5f3abcb..1310a31 100644 --- a/src/analysis.ts +++ b/src/analysis.ts @@ -1,4 +1,4 @@ -import { commands, EventEmitter } from 'vscode' +import { CancellationTokenSource, commands, EventEmitter } from 'vscode' import type { CancellationToken, @@ -38,8 +38,25 @@ interface AnalysisIdentity { readonly graphEpoch: number; } -interface AnalysisCacheEntry extends AnalysisIdentity { +export interface AnalysisResult { readonly findings: readonly ZeroReferenceFinding[]; + readonly status: 'complete' | 'incomplete' | 'cancelled'; +} + +export interface AnalysisInvalidation { + readonly reason: 'graph' | 'configuration'; + readonly resources: readonly Uri[]; +} + +interface AnalysisCacheEntry extends AnalysisIdentity { + readonly result: AnalysisResult; +} + +interface PendingAnalysis extends AnalysisIdentity { + readonly cancellation: CancellationTokenSource; + consumers: number; + readonly result: Promise; + settled: boolean; } interface FindingCandidate { @@ -56,7 +73,8 @@ interface PendingAcquisition { /** Owns shared zero-reference analysis, caching, and invalidation state. */ export class ZeroReferenceAnalyzer implements Disposable { private readonly cache = new Map() - private readonly invalidationEventEmitter = new EventEmitter() + private readonly pending = new Map() + private readonly invalidationEventEmitter = new EventEmitter() private readonly referenceLookupLimiter = new ConcurrencyLimiter(referenceConcurrency) private configurationEpoch = 0 private graphEpoch = 0 @@ -66,7 +84,8 @@ export class ZeroReferenceAnalyzer implements Disposable { constructor( private readonly executeCommand: CommandExecutor = defaultCommandExecutor, - private readonly reportConfigurationWarning: (message: string) => void = console.warn + private readonly reportConfigurationWarning: (message: string) => void = console.warn, + private readonly reportProviderError: (error: unknown, uri: Uri) => void = error => { console.error(error) } ) {} /** Reads only current completed analysis; editor actions must not trigger reference lookups. */ @@ -84,17 +103,27 @@ export class ZeroReferenceAnalyzer implements Disposable { return [] } - return cachedAnalysis.findings + return cachedAnalysis.result.findings } async analyze( document: TextDocument, token: CancellationToken ): Promise { + const result = await this.analyzeDetailed(document, token) + + return result.findings + } + + /** Shares work between consumers while retaining scan completeness and independent cancellation. */ + analyzeDetailed( + document: TextDocument, + token: CancellationToken + ): Promise { const identity = this.createIdentity(document) if (!this.isAnalysisCurrent(document, identity, token)) { - return [] + return Promise.resolve(cancelledResult()) } const documentKey = document.uri.toString() @@ -102,25 +131,142 @@ export class ZeroReferenceAnalyzer implements Disposable { if (cachedAnalysis !== undefined && hasSameIdentity(cachedAnalysis, identity)) { - return cachedAnalysis.findings + return Promise.resolve(cachedAnalysis.result) } if (isDocumentExcluded(document, this.reportConfigurationWarning)) { - return [] + return Promise.resolve({ + findings: [], + status: 'complete' + }) + } + + let pending = this.pending.get(documentKey) + + if (pending !== undefined && (!hasSameIdentity(pending, identity) + || pending.cancellation.token.isCancellationRequested)) { + pending.cancellation.cancel() + pending = undefined + } + + if (pending === undefined) { + const cancellation = new CancellationTokenSource() + + const result = Promise.resolve().then(async () => { + try { + return await this.runAnalysis(document, identity, cancellation.token) + } catch (error: unknown) { + this.reportProviderError(error, document.uri) + + return { + findings: [], + status: 'incomplete' + } as const + } + }).finally(() => { + entry.settled = true + + if (this.pending.get(documentKey) === entry) { + this.pending.delete(documentKey) + } + + cancellation.dispose() + }) + + const entry: PendingAnalysis = { + cancellation, + configurationEpoch: identity.configurationEpoch, + consumers: 0, + documentVersion: identity.documentVersion, + graphEpoch: identity.graphEpoch, + result, + settled: false + } + + pending = entry + this.pending.set(documentKey, entry) + } + + return this.joinAnalysis(pending, document, token) + } + + private joinAnalysis( + pending: PendingAnalysis, + document: TextDocument, + token: CancellationToken + ): Promise { + pending.consumers += 1 + + return new Promise(resolve => { + let settled = false + let consumerCancellation: Disposable | undefined + let sharedCancellation: Disposable | undefined + + const finish = (result: AnalysisResult): void => { + if (settled) { + return + } + + settled = true + consumerCancellation?.dispose() + sharedCancellation?.dispose() + pending.consumers -= 1 + + if (pending.consumers === 0 && !pending.settled) { + pending.cancellation.cancel() + } + + resolve(result) + } + + const cancel = (): void => { + const result = cancelledResult() + + finish(result) + } + + consumerCancellation = token.onCancellationRequested(cancel) + sharedCancellation = pending.cancellation.token.onCancellationRequested(cancel) + + void pending.result.then(result => { + if (!this.isAnalysisCurrent(document, pending, token)) { + cancel() + + return + } + + finish(result) + }) + + if (token.isCancellationRequested || pending.cancellation.token.isCancellationRequested) { + cancel() + } + }) + } + + private async runAnalysis( + document: TextDocument, + identity: AnalysisIdentity, + token: CancellationToken + ): Promise { + if (!this.isAnalysisCurrent(document, identity, token)) { + return cancelledResult() } let hasLoggedError = false let hasProviderError = false + let isIncomplete = false const logErrorOnce = (error: unknown): void => { hasProviderError = true + isIncomplete = true if (hasLoggedError) { return } hasLoggedError = true - console.error(error) + this.reportProviderError(error, document.uri) } let symbols: DocumentSymbol[] | SymbolInformation[] | undefined @@ -133,12 +279,21 @@ export class ZeroReferenceAnalyzer implements Disposable { } catch (error: unknown) { logErrorOnce(error) - return [] + return { + findings: [], + status: 'incomplete' + } } - if (symbols === undefined - || !this.isAnalysisCurrent(document, identity, token)) { - return [] + if (!this.isAnalysisCurrent(document, identity, token)) { + return cancelledResult() + } + + if (symbols === undefined) { + return { + findings: [], + status: 'incomplete' + } } const symbolData = getSymbolData(symbols, document) @@ -164,34 +319,46 @@ export class ZeroReferenceAnalyzer implements Disposable { suppressionLines, suppressedRanges, token, - logErrorOnce + logErrorOnce, + () => { isIncomplete = true } ) if (!this.isAnalysisCurrent(document, identity, token)) { - return [] + return cancelledResult() } const findings = getUniqueFindings(candidates) + const result: AnalysisResult = { + findings, + status: isIncomplete ? 'incomplete' : 'complete' + } + if (!hasProviderError) { + const documentKey = document.uri.toString() + this.cache.set(documentKey, { configurationEpoch: identity.configurationEpoch, documentVersion: identity.documentVersion, - findings, - graphEpoch: identity.graphEpoch + graphEpoch: identity.graphEpoch, + result }) } - return findings + return result } - invalidateGraph(): void { + invalidateGraph(resources: readonly Uri[] = []): void { if (this.isDisposed) { return } this.graphEpoch += 1 - this.invalidateAll() + + this.invalidateAll({ + reason: 'graph', + resources + }) } invalidateConfiguration(): void { @@ -200,18 +367,25 @@ export class ZeroReferenceAnalyzer implements Disposable { } this.configurationEpoch += 1 - this.invalidateAll() + + this.invalidateAll({ + reason: 'configuration', + resources: [] + }) } forgetDocument(uri: Uri): void { const documentKey = uri.toString() this.cache.delete(documentKey) + this.pending.get(documentKey)?.cancellation.cancel() + this.pending.delete(documentKey) } dispose(): void { this.isDisposed = true this.cache.clear() + this.cancelPending() this.invalidationEventEmitter.dispose() } @@ -223,9 +397,18 @@ export class ZeroReferenceAnalyzer implements Disposable { } } - private invalidateAll(): void { + private invalidateAll(event: AnalysisInvalidation): void { this.cache.clear() - this.invalidationEventEmitter.fire() + this.cancelPending() + this.invalidationEventEmitter.fire(event) + } + + private cancelPending(): void { + for (const entry of this.pending.values()) { + entry.cancellation.cancel() + } + + this.pending.clear() } private async findCandidates( @@ -236,7 +419,8 @@ export class ZeroReferenceAnalyzer implements Disposable { suppressionLines: ReadonlyMap, suppressedRanges: ReadonlySet, token: CancellationToken, - logErrorOnce: (error: unknown) => void + logErrorOnce: (error: unknown) => void, + reportIncomplete: () => void ): Promise { const candidates: (FindingCandidate | null)[] = Array.from( { length: symbols.length }, @@ -307,6 +491,11 @@ export class ZeroReferenceAnalyzer implements Disposable { return } + if (locations === undefined || locations.length === 0) { + reportIncomplete() + continue + } + const fallbackDeclarationIdentity = getDeclarationIdentityKey(symbol) const declarationGroupKey = getDeclarationGroupKey( @@ -361,7 +550,7 @@ export class ZeroReferenceAnalyzer implements Disposable { } function hasSameIdentity( - cacheEntry: AnalysisCacheEntry, + cacheEntry: AnalysisIdentity, identity: AnalysisIdentity ): boolean { const hasMatchingIdentity = cacheEntry.documentVersion === identity.documentVersion @@ -371,6 +560,13 @@ function hasSameIdentity( return hasMatchingIdentity } +function cancelledResult(): AnalysisResult { + return { + findings: [], + status: 'cancelled' + } +} + function getUniqueFindings( candidates: readonly (FindingCandidate | null)[] ): readonly ZeroReferenceFinding[] { diff --git a/src/analysisLifecycle.ts b/src/analysisLifecycle.ts index f459a67..eb45d93 100644 --- a/src/analysisLifecycle.ts +++ b/src/analysisLifecycle.ts @@ -18,7 +18,7 @@ export function registerAnalysisLifecycle( const hasSupportedLanguage = isSupportedLanguage(event.document.languageId) if (hasContentChanges && hasSupportedLanguage) { - analyzer.invalidateGraph() + analyzer.invalidateGraph([event.document.uri]) } }), workspace.onDidCloseTextDocument(document => { @@ -30,11 +30,11 @@ export function registerAnalysisLifecycle( }), fileWatcher.onDidChange(uri => { if (isSupportedFile(uri)) { - analyzer.invalidateGraph() + analyzer.invalidateGraph([uri]) } }), - fileWatcher.onDidDelete(() => { - analyzer.invalidateGraph() + fileWatcher.onDidDelete(uri => { + analyzer.invalidateGraph([uri]) }) ] @@ -48,7 +48,7 @@ async function invalidateCreatedResource( analyzer: ZeroReferenceAnalyzer ): Promise { if (isSupportedFile(uri)) { - analyzer.invalidateGraph() + analyzer.invalidateGraph([uri]) return } @@ -58,7 +58,7 @@ async function invalidateCreatedResource( const isDirectory = (resourceStat.type & FileType.Directory) !== 0 if (isDirectory) { - analyzer.invalidateGraph() + analyzer.invalidateGraph([uri]) } } catch { // The resource can disappear again before the asynchronous stat completes. diff --git a/src/codeLensProvider.ts b/src/codeLensProvider.ts index 18633d2..a0d17cf 100644 --- a/src/codeLensProvider.ts +++ b/src/codeLensProvider.ts @@ -1,10 +1,11 @@ -import { CodeLens, EventEmitter } from 'vscode' +import { CodeLens, EventEmitter, workspace } from 'vscode' import type { CancellationToken, CodeLensProvider, Disposable, TextDocument } from 'vscode' import type { ZeroReferenceAnalyzer } from './analysis.js' import { getUseCodeLens } from './config.js' export class ZeroReferenceCodeLensProvider implements CodeLensProvider, Disposable { private readonly analysisInvalidationSubscription: Disposable + private readonly configurationSubscription: Disposable private readonly updateEventEmitter = new EventEmitter() private isDisposed = false @@ -17,6 +18,11 @@ export class ZeroReferenceCodeLensProvider implements CodeLensProvider, Disposab this.analysisInvalidationSubscription = this.analyzer.onDidInvalidate(() => { this.updateEventEmitter.fire() }) + this.configurationSubscription = workspace.onDidChangeConfiguration(event => { + if (event.affectsConfiguration('zeroReference.useCodeLens')) { + this.updateEventEmitter.fire() + } + }) } async provideCodeLenses( @@ -50,6 +56,7 @@ export class ZeroReferenceCodeLensProvider implements CodeLensProvider, Disposab dispose(): void { this.isDisposed = true this.analysisInvalidationSubscription.dispose() + this.configurationSubscription.dispose() this.updateEventEmitter.dispose() } diff --git a/src/config.ts b/src/config.ts index 726d33a..33eae3c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,6 @@ import { ConfigurationTarget, languages, RelativePattern, workspace } from 'vsco import type { Disposable, TextDocument } from 'vscode' const useCodeLensKey = 'useCodeLens' -const useCodeLensConfigurationKey = `zeroReference.${useCodeLensKey}` const excludeConfigurationKey = 'zeroReference.exclude' export function isDocumentExcluded( @@ -71,8 +70,8 @@ export async function updateUseCodeLens(value: boolean): Promise { export function registerAnalysisConfigurationListener(onChange: () => void): Disposable { return workspace.onDidChangeConfiguration(event => { - if (event.affectsConfiguration(useCodeLensConfigurationKey) - || event.affectsConfiguration(excludeConfigurationKey)) { + if (event.affectsConfiguration(excludeConfigurationKey) + || event.affectsConfiguration('files.exclude')) { onChange() } }) diff --git a/src/extension.ts b/src/extension.ts index 147d106..3380aae 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,16 +1,26 @@ -import { CodeActionKind, commands, languages, window } from 'vscode' -import type { ExtensionContext } from 'vscode' +import { CodeActionKind, commands, languages, ProgressLocation, window } from 'vscode' +import type { ExtensionContext, LogOutputChannel, Uri } from 'vscode' import { ZeroReferenceAnalyzer } from './analysis.js' import { registerAnalysisLifecycle } from './analysisLifecycle.js' import { ZeroReferenceCodeLensProvider } from './codeLensProvider.js' import { ZeroReferenceCodeActionProvider } from './codeActionProvider.js' import { getUseCodeLens, updateUseCodeLens } from './config.js' import { getDocumentFilter } from './symbols.js' +import { ZeroReferenceWorkspaceScanner } from './workspaceScan.js' +import type { WorkspaceScanResult } from './workspaceScan.js' export function activate(context: ExtensionContext): void { const outputChannel = window.createOutputChannel('Zero Reference', { log: true }) const reportConfigurationWarning = outputChannel.warn.bind(outputChannel) - const analyzer = new ZeroReferenceAnalyzer(undefined, reportConfigurationWarning) + + const reportProviderError = (error: unknown, uri: Uri): void => { + const message = `Language provider failed for ${uri.toString()}.` + + outputChannel.error(message, error) + } + + const analyzer = new ZeroReferenceAnalyzer(undefined, reportConfigurationWarning, reportProviderError) + const scanner = new ZeroReferenceWorkspaceScanner(analyzer, outputChannel) const codeLensProvider = new ZeroReferenceCodeLensProvider(analyzer) const codeActionProvider = new ZeroReferenceCodeActionProvider(analyzer) const documentFilter = getDocumentFilter() @@ -18,6 +28,7 @@ export function activate(context: ExtensionContext): void { context.subscriptions.push( codeLensProvider, + scanner, analyzer, outputChannel, commands.registerCommand('zeroReference.toggleCodeLens', async () => { @@ -26,6 +37,8 @@ export function activate(context: ExtensionContext): void { await updateUseCodeLens(!useCodeLens) }), commands.registerCommand('zeroReference.refresh', refreshAnalysis), + commands.registerCommand('zeroReference.scanWorkspace', () => scanWorkspace(scanner, outputChannel)), + commands.registerCommand('zeroReference.clearWorkspaceScanResults', () => scanner.clear()), registerAnalysisLifecycle(analyzer), languages.registerCodeLensProvider(documentFilter, codeLensProvider), languages.registerCodeActionsProvider(documentFilter, codeActionProvider, { @@ -34,6 +47,47 @@ export function activate(context: ExtensionContext): void { ) } +async function scanWorkspace( + scanner: ZeroReferenceWorkspaceScanner, + output: LogOutputChannel +): Promise { + const result = await window.withProgress({ + location: ProgressLocation.Notification, + title: 'Zero Reference: Scan Workspace', + cancellable: true + }, (progress, token) => scanner.scan(progress, token)) + + if (result.status === 'cancelled') { + return result + } + + if (result.status === 'noWorkspace') { + void window.showInformationMessage('Open a folder or workspace to scan for zero references.') + + return result + } + + if (result.status !== 'failed' && result.findings > 0) { + await commands.executeCommand('workbench.actions.view.problems') + } + + const summary = result.status === 'failed' + ? 'Zero Reference could not find workspace files. Previous scan results were kept.' + : `Zero Reference: ${result.findings} findings; ${result.checkedFiles} checked, ${result.skippedFiles} skipped, ${result.incompleteFiles} incomplete files.` + + const message = result.status === 'complete' + ? window.showInformationMessage(summary, 'Show Output') + : window.showWarningMessage(summary, 'Show Output') + + void message.then(action => { + if (action === 'Show Output') { + output.show(true) + } + }) + + return result +} + /** Creates the public refresh command handler for shared analysis state. */ export function createRefreshHandler( analyzer: ZeroReferenceAnalyzer diff --git a/src/workspaceScan.ts b/src/workspaceScan.ts new file mode 100644 index 0000000..51a6484 --- /dev/null +++ b/src/workspaceScan.ts @@ -0,0 +1,335 @@ +import { + CancellationTokenSource, + Diagnostic, + DiagnosticSeverity, + DiagnosticTag, + extensions, + languages, + workspace +} from 'vscode' + +import type { CancellationToken, DiagnosticCollection, Disposable, LogOutputChannel, Progress, Uri } from 'vscode' +import type { AnalysisInvalidation, ZeroReferenceAnalyzer, ZeroReferenceFinding } from './analysis.js' +import { isDocumentExcluded } from './config.js' +import { isSupportedLanguage } from './symbols.js' + +const supportedFiles = '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}' +const staleMessage = 'Results from a previous scan; run Scan Workspace to update' + +export interface WorkspaceScanResult { + readonly status: 'complete' | 'incomplete' | 'cancelled' | 'failed' | 'noWorkspace'; + readonly checkedFiles: number; + readonly skippedFiles: number; + readonly incompleteFiles: number; + readonly findings: number; +} + +interface ScanCounts { + checkedFiles: number; + skippedFiles: number; + incompleteFiles: number; + findings: number; +} + +export interface ScanProgress { + readonly message?: string; + readonly increment?: number; +} + +interface FileFindings { + readonly uri: Uri; + readonly findings: readonly ZeroReferenceFinding[]; +} + +/** Owns manual scans and their persistent-in-window diagnostic snapshot, independently of editor display. */ +export class ZeroReferenceWorkspaceScanner implements Disposable { + private readonly invalidationSubscription: Disposable + private snapshot = new Map() + private active: CancellationTokenSource | undefined + private isDisposed = false + + constructor( + private readonly analyzer: ZeroReferenceAnalyzer, + private readonly output: Pick, + private readonly diagnostics: DiagnosticCollection = languages.createDiagnosticCollection('Zero Reference'), + private readonly findFiles: (token: CancellationToken) => Thenable = findWorkspaceFiles + ) { + this.invalidationSubscription = analyzer.onDidInvalidate(event => { + this.invalidate(event) + }) + } + + async scan(progress: Progress, token: CancellationToken): Promise { + this.cancelActive() + + const cancellation = new CancellationTokenSource() + const cancellationSubscription = token.onCancellationRequested(() => cancellation.cancel()) + + const counts: ScanCounts = { + checkedFiles: 0, + skippedFiles: 0, + incompleteFiles: 0, + findings: 0 + } + + this.active = cancellation + + if (token.isCancellationRequested) { + cancellation.cancel() + } + + let status: WorkspaceScanResult['status'] + + try { + status = await this.run(cancellation, progress, counts) + } catch (error: unknown) { + if (this.isCurrent(cancellation)) { + this.output.error('Workspace scan failed during file discovery.', error) + status = 'failed' + } else { + status = 'cancelled' + } + } finally { + if (this.active === cancellation) { + this.active = undefined + } + + cancellationSubscription.dispose() + cancellation.dispose() + } + + const result: WorkspaceScanResult = { + status, + checkedFiles: counts.checkedFiles, + skippedFiles: counts.skippedFiles, + incompleteFiles: counts.incompleteFiles, + findings: counts.findings + } + + const summary = `Workspace scan ${status}: ${counts.checkedFiles} checked, ${counts.skippedFiles} skipped, ${counts.incompleteFiles} incomplete files; ${counts.findings} findings.` + + this.output.info(summary) + + return result + } + + clear(): void { + this.cancelActive() + this.snapshot.clear() + this.diagnostics.clear() + } + + dispose(): void { + if (this.isDisposed) { + return + } + + this.isDisposed = true + this.clear() + this.invalidationSubscription.dispose() + this.diagnostics.dispose() + } + + private async run( + cancellation: CancellationTokenSource, + progress: Progress, + counts: ScanCounts + ): Promise { + if (!this.isCurrent(cancellation)) { + return 'cancelled' + } + + if (workspace.workspaceFolders === undefined || workspace.workspaceFolders.length === 0) { + return 'noWorkspace' + } + + progress.report({ message: 'Preparing TypeScript and JavaScript analysis…' }) + + const languageExtension = extensions.getExtension('vscode.typescript-language-features') + + await languageExtension?.activate() + + if (!this.isCurrent(cancellation)) { + return 'cancelled' + } + + progress.report({ message: 'Finding TypeScript and JavaScript files…' }) + + const discovered = await this.findFiles(cancellation.token) + const uniqueFiles = new Map() + + for (const uri of discovered) { + uniqueFiles.set(uri.toString(), uri) + } + + const files = [...uniqueFiles.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) + const findings = new Map() + + for (const [index, [key, uri]] of files.entries()) { + if (!this.isCurrent(cancellation)) { + return 'cancelled' + } + + const relativePath = workspace.asRelativePath(uri, true) + const message = `${index + 1}/${files.length}: ${relativePath}` + + progress.report({ message }) + + if (isDependencyOrOutsideWorkspace(uri)) { + counts.skippedFiles += 1 + } else { + try { + const document = await workspace.openTextDocument(uri) + + if (!this.isCurrent(cancellation)) { + return 'cancelled' + } + + const reportWarning = this.output.warn.bind(this.output) + const excluded = isDocumentExcluded(document, reportWarning) + + if (!isSupportedLanguage(document.languageId) || excluded) { + counts.skippedFiles += 1 + } else { + const result = await this.analyzer.analyzeDetailed(document, cancellation.token) + + if (!this.isCurrent(cancellation)) { + return 'cancelled' + } + + if (result.status === 'complete') { + counts.checkedFiles += 1 + } else { + counts.incompleteFiles += 1 + + const warning = `Could not fully analyze ${uri.toString()}; some declarations could not be confirmed.` + + this.output.warn(warning) + } + + if (result.findings.length > 0) { + findings.set(key, { + uri, + findings: result.findings + }) + + counts.findings += result.findings.length + } + } + } catch (error: unknown) { + if (!this.isCurrent(cancellation)) { + return 'cancelled' + } + + counts.incompleteFiles += 1 + + const message = `Could not analyze ${uri.toString()}.` + + this.output.error(message, error) + } + } + + const increment = 100 / files.length + + progress.report({ increment }) + } + + if (!this.isCurrent(cancellation)) { + return 'cancelled' + } + + this.publish(findings, false) + + return counts.incompleteFiles > 0 ? 'incomplete' : 'complete' + } + + private invalidate(event: AnalysisInvalidation): void { + if (event.reason === 'configuration') { + this.clear() + + return + } + + this.cancelActive() + + const remaining = new Map() + + for (const [key, file] of this.snapshot) { + const affected = event.resources.some(resource => containsResource(resource, file.uri)) + + if (!affected) { + remaining.set(key, file) + } + } + + this.publish(remaining, true) + } + + private publish(snapshot: Map, stale: boolean): void { + const entries: [Uri, Diagnostic[] | undefined][] = [] + + for (const [key, file] of this.snapshot) { + if (!snapshot.has(key)) { + entries.push([file.uri, undefined]) + } + } + + for (const file of snapshot.values()) { + const diagnostics = file.findings.map(finding => { + const currentMessage = `"${finding.name}" has zero workspace references. External and dynamic consumers may not be visible.` + + const message = stale + ? `"${finding.name}": ${staleMessage}` + : currentMessage + + const diagnostic = new Diagnostic(finding.declarationRange, message, DiagnosticSeverity.Information) + + diagnostic.source = 'Zero Reference' + diagnostic.code = 'zero-reference' + diagnostic.tags = stale ? [] : [DiagnosticTag.Unnecessary] + + return diagnostic + }) + + entries.push([file.uri, diagnostics]) + } + + this.snapshot = snapshot + this.diagnostics.set(entries) + } + + private cancelActive(): void { + this.active?.cancel() + this.active = undefined + } + + private isCurrent(cancellation: CancellationTokenSource): boolean { + return !this.isDisposed && this.active === cancellation && !cancellation.token.isCancellationRequested + } +} + +/** Uses native file exclusions, without importing search exclusions or ignore files. */ +function findWorkspaceFiles(token: CancellationToken): Thenable { + return workspace.findFiles(supportedFiles, undefined, undefined, token) +} + +function isDependencyOrOutsideWorkspace(uri: Uri): boolean { + const folder = workspace.getWorkspaceFolder(uri) + + if (folder === undefined) { + return true + } + + const relativePath = uri.path.slice(folder.uri.path.length) + const segments = relativePath.split('/') + + return segments.includes('node_modules') || segments.includes('.git') +} + +function containsResource(parent: Uri, child: Uri): boolean { + const prefix = parent.path.endsWith('/') ? parent.path : `${parent.path}/` + + return parent.scheme === child.scheme + && parent.authority === child.authority + && (parent.path === child.path || child.path.startsWith(prefix)) +} diff --git a/test/analysis.test.ts b/test/analysis.test.ts index 63e5ede..472e355 100644 --- a/test/analysis.test.ts +++ b/test/analysis.test.ts @@ -14,8 +14,130 @@ import { import { ZeroReferenceAnalyzer } from '../src/analysis.js' import type { CommandExecutor } from '../src/analysis.js' +import { ZeroReferenceCodeLensProvider } from '../src/codeLensProvider.js' suite('ZeroReferenceAnalyzer', () => { + for (const cancelledConsumer of ['scan', 'lens']) { + test(`shares pending CodeLens and scan work when the ${cancelledConsumer} consumer cancels`, async () => { + const fixture = await createSymbolFixture(1) + const gate = createDeferred() + const started = createDeferred() + + const harness = createAnalyzerHarness(fixture, async position => { + started.resolve() + await gate.promise + + const range = fixture.declarationRanges[position.line] + + assert.ok(range !== undefined) + + return [new Location(fixture.document.uri, range)] + }) + + const scanner = new CancellationTokenSource() + const lenses = new CancellationTokenSource() + const provider = new ZeroReferenceCodeLensProvider(harness.analyzer, () => true) + + try { + const scan = harness.analyzer.analyzeDetailed(fixture.document, scanner.token) + const lens = provider.provideCodeLenses(fixture.document, lenses.token) + + await started.promise + assert.equal(harness.counts.documentSymbolCalls, 1) + assert.equal(harness.counts.referenceCalls, 1) + + if (cancelledConsumer === 'scan') { + scanner.cancel() + assert.equal((await scan).status, 'cancelled') + } else { + lenses.cancel() + assert.deepEqual(await lens, []) + } + + gate.resolve() + + if (cancelledConsumer === 'scan') { + assert.equal((await lens).length, 1) + } else { + assert.equal((await scan).status, 'complete') + } + + const fresh = new CancellationTokenSource() + const cached = await harness.analyzer.analyzeDetailed(fixture.document, fresh.token) + + assert.equal(cached.findings.length, 1) + assert.equal(harness.counts.referenceCalls, 1) + fresh.dispose() + } finally { + gate.resolve() + provider.dispose() + scanner.dispose() + lenses.dispose() + harness.analyzer.dispose() + } + }) + } + + test('retains occupied lookup slots until cancelled requests actually finish', async () => { + const fixture = await createSymbolFixture(8) + const gate = createDeferred() + + const harness = createAnalyzerHarness(fixture, async position => { + await gate.promise + + const range = fixture.declarationRanges[position.line] + + assert.ok(range !== undefined) + + return [new Location(fixture.document.uri, range)] + }) + + const first = new CancellationTokenSource() + const second = new CancellationTokenSource() + + try { + const cancelled = harness.analyzer.analyzeDetailed(fixture.document, first.token) + + await waitUntil(() => harness.counts.referenceCalls === 4) + first.cancel() + assert.equal((await cancelled).status, 'cancelled') + + const retry = harness.analyzer.analyzeDetailed(fixture.document, second.token) + + await waitUntil(() => harness.counts.documentSymbolCalls === 2) + await delay(25) + assert.equal(harness.counts.referenceCalls, 4) + gate.resolve() + assert.equal((await retry).findings.length, 8) + assert.equal(harness.counts.referenceCalls, 12) + } finally { + gate.resolve() + harness.analyzer.dispose() + first.dispose() + second.dispose() + } + }) + + test('distinguishes unconfirmed references from a complete finding-free result', async () => { + const fixture = await createSymbolFixture(1) + const cancellation = new CancellationTokenSource() + + for (const references of [undefined, [], [new Location(Uri.parse('file:///consumer.ts'), new Range(0, 0, 0, 2))]]) { + const harness = createAnalyzerHarness(fixture, () => references) + + try { + const result = await harness.analyzer.analyzeDetailed(fixture.document, cancellation.token) + + assert.deepEqual(result.findings, []) + assert.equal(result.status, references?.length ? 'complete' : 'incomplete') + } finally { + harness.analyzer.dispose() + } + } + + cancellation.dispose() + }) + test('reuses a completed analysis with the same identity', async () => { const fixture = await createSymbolFixture(2) diff --git a/test/config.test.ts b/test/config.test.ts index 1f77e2e..efbfbcf 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,6 +1,8 @@ import { strict as assert } from 'node:assert' import { commands, ConfigurationTarget, extensions, workspace } from 'vscode' import { registerAnalysisConfigurationListener } from '../src/config.js' +import { ZeroReferenceAnalyzer } from '../src/analysis.js' +import { ZeroReferenceCodeLensProvider } from '../src/codeLensProvider.js' suite('useCodeLens configuration', () => { suiteSetup(async () => { @@ -43,8 +45,15 @@ suite('useCodeLens configuration', () => { const configuration = workspace.getConfiguration('zeroReference') const change = createDeferred() let refreshCount = 0 + let invalidationCount = 0 + const analyzer = new ZeroReferenceAnalyzer() + const provider = new ZeroReferenceCodeLensProvider(analyzer) - const listener = registerAnalysisConfigurationListener(() => { + const configurationListener = registerAnalysisConfigurationListener(() => { + invalidationCount += 1 + }) + + const listener = provider.onDidChangeCodeLenses(() => { refreshCount += 1 change.resolve() }) @@ -55,8 +64,12 @@ suite('useCodeLens configuration', () => { await delay(25) assert.equal(refreshCount, 1) + assert.equal(invalidationCount, 0) } finally { listener.dispose() + configurationListener.dispose() + provider.dispose() + analyzer.dispose() } }) }) diff --git a/test/fixtures/workspace/scan/closed-cjs.cjs b/test/fixtures/workspace/scan/closed-cjs.cjs new file mode 100644 index 0000000..b207ad6 --- /dev/null +++ b/test/fixtures/workspace/scan/closed-cjs.cjs @@ -0,0 +1 @@ +export function scanUnusedCjs() {} diff --git a/test/fixtures/workspace/scan/closed-cts.cts b/test/fixtures/workspace/scan/closed-cts.cts new file mode 100644 index 0000000..959196a --- /dev/null +++ b/test/fixtures/workspace/scan/closed-cts.cts @@ -0,0 +1 @@ +export function scanUnusedCts() {} diff --git a/test/fixtures/workspace/scan/closed-js.js b/test/fixtures/workspace/scan/closed-js.js new file mode 100644 index 0000000..7e779f9 --- /dev/null +++ b/test/fixtures/workspace/scan/closed-js.js @@ -0,0 +1 @@ +export function scanUnusedJs() {} diff --git a/test/fixtures/workspace/scan/closed-jsx.jsx b/test/fixtures/workspace/scan/closed-jsx.jsx new file mode 100644 index 0000000..a357127 --- /dev/null +++ b/test/fixtures/workspace/scan/closed-jsx.jsx @@ -0,0 +1 @@ +export function scanUnusedJsx() {} diff --git a/test/fixtures/workspace/scan/closed-mjs.mjs b/test/fixtures/workspace/scan/closed-mjs.mjs new file mode 100644 index 0000000..6dfccac --- /dev/null +++ b/test/fixtures/workspace/scan/closed-mjs.mjs @@ -0,0 +1 @@ +export function scanUnusedMjs() {} diff --git a/test/fixtures/workspace/scan/closed-mts.mts b/test/fixtures/workspace/scan/closed-mts.mts new file mode 100644 index 0000000..2a7fe1c --- /dev/null +++ b/test/fixtures/workspace/scan/closed-mts.mts @@ -0,0 +1 @@ +export function scanUnusedMts() {} diff --git a/test/fixtures/workspace/scan/closed-ts.ts b/test/fixtures/workspace/scan/closed-ts.ts new file mode 100644 index 0000000..7a543e0 --- /dev/null +++ b/test/fixtures/workspace/scan/closed-ts.ts @@ -0,0 +1,3 @@ +export function scanUnusedTs() {} + +export function scanUsedTs() {} diff --git a/test/fixtures/workspace/scan/closed-tsx.tsx b/test/fixtures/workspace/scan/closed-tsx.tsx new file mode 100644 index 0000000..e55d9da --- /dev/null +++ b/test/fixtures/workspace/scan/closed-tsx.tsx @@ -0,0 +1 @@ +export function scanUnusedTsx() {} diff --git a/test/fixtures/workspace/scan/consumer.ts b/test/fixtures/workspace/scan/consumer.ts new file mode 100644 index 0000000..cdabd83 --- /dev/null +++ b/test/fixtures/workspace/scan/consumer.ts @@ -0,0 +1,3 @@ +import { scanUsedTs } from './closed-ts.js' + +scanUsedTs() diff --git a/test/fixtures/workspace/scan/tsconfig.json b/test/fixtures/workspace/scan/tsconfig.json new file mode 100644 index 0000000..12947f2 --- /dev/null +++ b/test/fixtures/workspace/scan/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "allowJs": true, + "noEmit": true, + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": ["**/*"] +} diff --git a/test/multiRoot/workspaceScan.test.ts b/test/multiRoot/workspaceScan.test.ts new file mode 100644 index 0000000..3ff7189 --- /dev/null +++ b/test/multiRoot/workspaceScan.test.ts @@ -0,0 +1,49 @@ +import { strict as assert } from 'node:assert' +import { CancellationTokenSource, ConfigurationTarget, languages, Uri, window, workspace } from 'vscode' +import { ZeroReferenceAnalyzer } from '../../src/analysis.js' +import { registerAnalysisLifecycle } from '../../src/analysisLifecycle.js' +import { ZeroReferenceWorkspaceScanner } from '../../src/workspaceScan.js' + +suite('multi-root workspace scan', () => { + test('discovers both roots and applies folder-specific exclusions', async () => { + const first = workspace.workspaceFolders?.[0] + const second = workspace.workspaceFolders?.[1] + + assert.ok(first !== undefined && second !== undefined) + + const firstUri = Uri.joinPath(first.uri, 'same.ts') + const secondUri = Uri.joinPath(second.uri, 'same.ts') + const output = window.createOutputChannel('Multi-root scan test', { log: true }) + const analyzer = new ZeroReferenceAnalyzer() + const collection = languages.createDiagnosticCollection('multi-root-scan-test') + const scanner = new ZeroReferenceWorkspaceScanner(analyzer, output, collection) + const lifecycle = registerAnalysisLifecycle(analyzer) + const cancellation = new CancellationTokenSource() + const configuration = workspace.getConfiguration('zeroReference', firstUri) + const previous = configuration.inspect('exclude')?.workspaceFolderValue + + try { + const firstScan = await scanner.scan({ report: () => {} }, cancellation.token) + + assert.equal(firstScan.status, 'complete') + assert.equal(collection.get(firstUri)?.length, 1) + assert.equal(collection.get(secondUri)?.length, 1) + await configuration.update('exclude', ['same.ts'], ConfigurationTarget.WorkspaceFolder) + assert.deepEqual(collection.get(firstUri), []) + assert.deepEqual(collection.get(secondUri), []) + + const secondScan = await scanner.scan({ report: () => {} }, cancellation.token) + + assert.equal(secondScan.skippedFiles, 1) + assert.deepEqual(collection.get(firstUri), []) + assert.equal(collection.get(secondUri)?.length, 1) + } finally { + scanner.dispose() + lifecycle.dispose() + analyzer.dispose() + cancellation.dispose() + output.dispose() + await configuration.update('exclude', previous, ConfigurationTarget.WorkspaceFolder) + } + }) +}) diff --git a/test/noWorkspace/workspaceScan.test.ts b/test/noWorkspace/workspaceScan.test.ts new file mode 100644 index 0000000..ba9aea3 --- /dev/null +++ b/test/noWorkspace/workspaceScan.test.ts @@ -0,0 +1,41 @@ +import { strict as assert } from 'node:assert' +import { CancellationTokenSource, languages, workspace } from 'vscode' +import { ZeroReferenceAnalyzer } from '../../src/analysis.js' +import { ZeroReferenceWorkspaceScanner } from '../../src/workspaceScan.js' + +suite('workspace scan without a workspace', () => { + test('does not search or analyze untitled documents', async () => { + assert.equal(workspace.workspaceFolders, undefined) + + await workspace.openTextDocument({ + content: 'function entry() {}', + language: 'typescript' + }) + + const output = { + info: () => {}, + warn: () => {}, + error: () => {} + } + + const analyzer = new ZeroReferenceAnalyzer() + const collection = languages.createDiagnosticCollection('no-workspace-scan-test') + + const scanner = new ZeroReferenceWorkspaceScanner(analyzer, output, collection, async () => { + assert.fail('must not search without workspace folders') + }) + + const cancellation = new CancellationTokenSource() + + try { + const result = await scanner.scan({ report: () => {} }, cancellation.token) + + assert.equal(result.status, 'noWorkspace') + assert.equal(result.findings, 0) + } finally { + scanner.dispose() + analyzer.dispose() + cancellation.dispose() + } + }) +}) diff --git a/test/scanIntegration/workspaceScan.test.ts b/test/scanIntegration/workspaceScan.test.ts new file mode 100644 index 0000000..8713c61 --- /dev/null +++ b/test/scanIntegration/workspaceScan.test.ts @@ -0,0 +1,89 @@ +import { strict as assert } from 'node:assert' + +import { + commands, + ConfigurationTarget, + DiagnosticSeverity, + languages, + Range, + Uri, + window, + WorkspaceEdit, + workspace +} from 'vscode' + +import type { WorkspaceScanResult } from '../../src/workspaceScan.js' + +suite('fresh workspace scan integration', () => { + test('scans closed files with real providers and unsaved text even with CodeLens disabled', async () => { + const folder = workspace.workspaceFolders?.[0] + + assert.ok(folder !== undefined) + + const root = Uri.joinPath(folder.uri, 'scan') + const typescriptUri = Uri.joinPath(root, 'closed-ts.ts') + const javascriptUri = Uri.joinPath(root, 'closed-js.js') + const isJavascriptDocumentOpen = workspace.textDocuments.some(document => document.uri.toString() === javascriptUri.toString()) + + assert.equal(isJavascriptDocumentOpen, false) + + const configuration = workspace.getConfiguration('zeroReference') + const previous = configuration.inspect('useCodeLens')?.workspaceValue + const previousExclusions = configuration.inspect('exclude')?.workspaceValue + const document = await workspace.openTextDocument(typescriptUri) + const original = document.getText() + const tabs = window.tabGroups.all.flatMap(group => group.tabs).length + + try { + await configuration.update('useCodeLens', false, ConfigurationTarget.Workspace) + await configuration.update('exclude', ['scan/consumer.ts'], ConfigurationTarget.Workspace) + + const edit = new WorkspaceEdit() + const end = document.positionAt(original.length) + + edit.insert(typescriptUri, end, '\nscanUnusedTs();\n') + assert.equal(await workspace.applyEdit(edit), true) + + const result = await commands.executeCommand('zeroReference.scanWorkspace') + + assert.ok(result !== undefined) + assert.ok(result.status === 'complete' || result.status === 'incomplete') + assert.equal(window.tabGroups.all.flatMap(group => group.tabs).length, tabs) + assert.equal(document.isDirty, true) + + const file = await workspace.fs.readFile(typescriptUri) + + assert.equal(Buffer.from(file).toString(), original) + + const typescriptFindings = languages.getDiagnostics(typescriptUri).filter(item => item.source === 'Zero Reference') + + assert.equal(typescriptFindings.some(item => item.message.includes('scanUnusedTs')), false) + assert.equal(typescriptFindings.some(item => item.message.includes('scanUsedTs')), false) + + for (const [extension, name] of [['tsx', 'Tsx'], ['mts', 'Mts'], ['cts', 'Cts'], ['js', 'Js'], ['jsx', 'Jsx'], ['mjs', 'Mjs'], ['cjs', 'Cjs']]) { + const uri = Uri.joinPath(root, `closed-${extension}.${extension}`) + const finding = languages.getDiagnostics(uri).find(item => item.source === 'Zero Reference' && item.message.includes(`scanUnused${name}`)) + + assert.ok(finding !== undefined, `missing scan finding in ${extension}`) + + const target = await workspace.openTextDocument(uri) + + assert.equal(target.getText(finding.range), `scanUnused${name}`) + assert.equal(finding.severity, DiagnosticSeverity.Information) + } + + await commands.executeCommand('zeroReference.clearWorkspaceScanResults') + assert.equal(languages.getDiagnostics(javascriptUri).some(item => item.source === 'Zero Reference'), false) + } finally { + await commands.executeCommand('zeroReference.clearWorkspaceScanResults') + await configuration.update('useCodeLens', previous, ConfigurationTarget.Workspace) + await configuration.update('exclude', previousExclusions, ConfigurationTarget.Workspace) + + const restore = new WorkspaceEdit() + + restore.replace(typescriptUri, new Range(0, 0, document.lineCount, 0), original) + await workspace.applyEdit(restore) + } + }) + +}) diff --git a/test/workspaceScan.test.ts b/test/workspaceScan.test.ts new file mode 100644 index 0000000..69ff1d0 --- /dev/null +++ b/test/workspaceScan.test.ts @@ -0,0 +1,424 @@ +import { strict as assert } from 'node:assert' + +import { + CancellationTokenSource, + ConfigurationTarget, + DiagnosticSeverity, + DiagnosticTag, + DocumentSymbol, + languages, + Location, + Position, + Range, + SymbolKind, + Uri, + workspace +} from 'vscode' + +import type { CancellationToken, LogOutputChannel } from 'vscode' +import { ZeroReferenceAnalyzer } from '../src/analysis.js' +import type { CommandExecutor } from '../src/analysis.js' +import { registerAnalysisLifecycle } from '../src/analysisLifecycle.js' +import { ZeroReferenceWorkspaceScanner } from '../src/workspaceScan.js' +import type { ScanProgress } from '../src/workspaceScan.js' + +suite('workspace scan', () => { + let root: Uri + let first: Uri + let second: Uri + + suiteSetup(async () => { + const folder = workspace.workspaceFolders?.[0] + + assert.ok(folder !== undefined) + root = Uri.joinPath(folder.uri, 'scan-tests') + first = Uri.joinPath(root, 'a.ts') + second = Uri.joinPath(root, 'nested', 'b.js') + await workspace.fs.createDirectory(Uri.joinPath(root, 'nested')) + await workspace.fs.writeFile(first, Buffer.from('function entry() {}\n')) + await workspace.fs.writeFile(second, Buffer.from('function entry() {}\n')) + }) + + suiteTeardown(async () => { + await workspace.fs.delete(root, { recursive: true }) + }) + + test('deduplicates, sorts, publishes name ranges and reuses unchanged analysis', async () => { + const harness = createHarness(async () => [second, first, first]) + + try { + const result = await harness.scan() + + assert.equal(result.status, 'complete') + assert.equal(result.checkedFiles, 2) + assert.equal(result.findings, 2) + assert.deepEqual(harness.symbolUris, [first.toString(), second.toString()]) + + const diagnostic = harness.diagnostics.get(first)?.[0] + + assert.ok(diagnostic !== undefined) + assert.ok(diagnostic.range.isEqual(new Range(0, 9, 0, 14))) + assert.equal(diagnostic.source, 'Zero Reference') + assert.equal(diagnostic.code, 'zero-reference') + assert.equal(diagnostic.severity, DiagnosticSeverity.Information) + assert.deepEqual(diagnostic.tags, [DiagnosticTag.Unnecessary]) + assert.ok(harness.progress.some(item => item.message?.includes('1/2:'))) + assert.equal(harness.progress.reduce((sum, item) => sum + (item.increment ?? 0), 0), 100) + await harness.scan() + assert.equal(harness.referenceUris.length, 2) + } finally { + harness.dispose() + } + }) + + test('retains the previous snapshot until replacement and removes absent findings atomically', async () => { + let files = [first] + const gate = deferred() + const started = deferred() + let defer = false + + const harness = createHarness(async () => files, async (uri, position) => { + if (defer) { + started.resolve() + await gate.promise + } + + return [new Location(uri, new Range(position, position.translate(0, 5)))] + }) + + try { + await harness.scan() + files = [second] + defer = true + + const pending = harness.scan() + + await started.promise + assert.equal(harness.diagnostics.get(first)?.length, 1) + assert.deepEqual(harness.diagnostics.get(second), []) + gate.resolve() + await pending + assert.deepEqual(harness.diagnostics.get(first), []) + assert.equal(harness.diagnostics.get(second)?.length, 1) + files = [] + + const empty = await harness.scan() + + assert.equal(empty.status, 'complete') + assert.equal(empty.findings, 0) + assert.deepEqual(harness.diagnostics.get(second), []) + } finally { + gate.resolve() + harness.dispose() + } + }) + + for (const action of ['cancel', 'clear', 'restart', 'edit', 'configuration', 'dispose']) { + test(`${action} prevents delayed work from publishing or dequeuing another file`, async () => { + const gate = deferred() + const started = deferred() + let defer = false + let files = [first] + + const harness = createHarness(async () => files, async (uri, position) => { + if (defer) { + started.resolve() + await gate.promise + } + + return [new Location(uri, new Range(position, position.translate(0, 5)))] + }) + + try { + await harness.scan() + files = [second, Uri.joinPath(root, 'z.ts')] + defer = true + + const pending = harness.scan() + + await started.promise + + if (action === 'cancel') { + harness.cancellation.cancel() + } else if (action === 'clear') { + harness.scanner.clear() + } else if (action === 'restart') { + files = [] + + const replacement = await harness.scan() + + assert.equal(replacement.status, 'complete') + } else if (action === 'edit') { + harness.analyzer.invalidateGraph([second]) + } else if (action === 'configuration') { + harness.analyzer.invalidateConfiguration() + } else { + harness.scanner.dispose() + } + + assert.equal((await pending).status, 'cancelled') + gate.resolve() + await new Promise(resolve => setTimeout(resolve, 25)) + assert.equal(harness.symbolUris.length, 2) + + if (action !== 'dispose') { + assert.deepEqual(harness.diagnostics.get(second), []) + + const previous = harness.diagnostics.get(first) + + if (action === 'cancel' || action === 'edit') { + assert.equal(previous?.length, 1) + + if (action === 'edit') { + assert.ok(previous?.[0]?.message.includes('Results from a previous scan')) + assert.deepEqual(previous?.[0]?.tags, []) + } + } else { + assert.deepEqual(previous, []) + } + } + } finally { + gate.resolve() + harness.dispose() + } + }) + } + + test('marks other files stale, removes changed descendants and retains the snapshot on cache eviction', async () => { + const harness = createHarness(async () => [first, second]) + + try { + await harness.scan() + harness.analyzer.forgetDocument(first) + assert.equal(harness.diagnostics.get(first)?.length, 1) + harness.analyzer.invalidateGraph([Uri.joinPath(root, 'nested')]) + assert.deepEqual(harness.diagnostics.get(second), []) + assert.ok(harness.diagnostics.get(first)?.[0]?.message.includes('Results from a previous scan')) + assert.deepEqual(harness.diagnostics.get(first)?.[0]?.tags, []) + await harness.scan() + assert.deepEqual(harness.diagnostics.get(first)?.[0]?.tags, [DiagnosticTag.Unnecessary]) + harness.analyzer.invalidateGraph([first]) + assert.deepEqual(harness.diagnostics.get(first), []) + assert.equal(harness.diagnostics.get(second)?.length, 1) + } finally { + harness.dispose() + } + }) + + test('continues after missing files and provider failures without reporting a clean scan', async () => { + const providerError = new Error('raw scan provider failure') + const missing = Uri.joinPath(root, 'missing.ts') + + const harness = createHarness(async () => [first, second, missing], async (uri, position) => { + if (uri.toString() === first.toString()) { + throw providerError + } + + return [new Location(uri, new Range(position, position.translate(0, 5)))] + }) + + try { + const result = await harness.scan() + + assert.equal(result.status, 'incomplete') + assert.equal(result.checkedFiles, 1) + assert.equal(result.incompleteFiles, 2) + assert.equal(result.findings, 1) + assert.ok(harness.errors.includes(providerError)) + assert.ok(harness.errors.includes(first.toString())) + assert.deepEqual(harness.diagnostics.get(first), []) + assert.equal(harness.diagnostics.get(second)?.length, 1) + await harness.scan() + assert.equal(harness.referenceUris.filter(uri => uri === first.toString()).length, 2) + } finally { + harness.dispose() + } + }) + + test('keeps a previous snapshot after a raw discovery failure', async () => { + let fail = false + const error = new Error('raw discovery failure') + + const harness = createHarness(async () => { + if (fail) { + throw error + } + + return [first] + }) + + try { + await harness.scan() + fail = true + assert.equal((await harness.scan()).status, 'failed') + assert.equal(harness.diagnostics.get(first)?.length, 1) + assert.ok(harness.errors.includes(error)) + } finally { + harness.dispose() + } + }) + + test('applies native file exclusions but not search exclusions or gitignore, and skips dependencies and suppression', async () => { + const ignored = Uri.joinPath(root, 'ignored.ts') + const suppressed = Uri.joinPath(root, 'suppressed.ts') + const dependency = Uri.joinPath(root, 'node_modules', 'dependency.ts') + const gitFile = Uri.joinPath(root, '.git', 'hidden.ts') + + await workspace.fs.createDirectory(Uri.joinPath(root, 'node_modules')) + await workspace.fs.createDirectory(Uri.joinPath(root, '.git')) + + for (const uri of [ignored, dependency, gitFile]) { + await workspace.fs.writeFile(uri, Buffer.from('function entry() {}\n')) + } + + await workspace.fs.writeFile(Uri.joinPath(root, '.gitignore'), Buffer.from('ignored.ts\n')) + await workspace.fs.writeFile(suppressed, Buffer.from('// zero-reference-ignore-next-line\nfunction entry() {}\n')) + + const filesConfiguration = workspace.getConfiguration('files') + const searchConfiguration = workspace.getConfiguration('search') + const configuration = workspace.getConfiguration('zeroReference') + const filesExclude = filesConfiguration.inspect('exclude')?.workspaceValue + const searchExclude = searchConfiguration.inspect('exclude')?.workspaceValue + const exclusions = configuration.inspect('exclude')?.workspaceValue + const harness = createHarness() + + try { + await filesConfiguration.update('exclude', { + 'scan-tests/a.ts': true, + '**/.git': false + }, ConfigurationTarget.Workspace) + + await searchConfiguration.update('exclude', { '**/ignored.ts': true }, ConfigurationTarget.Workspace) + await configuration.update('exclude', ['scan-tests/nested/**'], ConfigurationTarget.Workspace) + await harness.scan() + assert.ok(harness.symbolUris.includes(ignored.toString())) + assert.ok(harness.symbolUris.includes(suppressed.toString())) + + for (const uri of [first, second, dependency, gitFile]) { + assert.equal(harness.symbolUris.includes(uri.toString()), false, uri.toString()) + } + + assert.equal(harness.referenceUris.includes(suppressed.toString()), false) + assert.equal(harness.diagnostics.get(ignored)?.length, 1) + } finally { + harness.dispose() + await filesConfiguration.update('exclude', filesExclude, ConfigurationTarget.Workspace) + await searchConfiguration.update('exclude', searchExclude, ConfigurationTarget.Workspace) + await configuration.update('exclude', exclusions, ConfigurationTarget.Workspace) + } + }) + + test('CodeLens toggles preserve snapshots while exclusion changes clear them', async () => { + const harness = createHarness(async () => [first]) + const lifecycle = registerAnalysisLifecycle(harness.analyzer) + const configuration = workspace.getConfiguration('zeroReference') + const previous = configuration.inspect('useCodeLens')?.workspaceValue + const exclusions = configuration.inspect('exclude')?.workspaceValue + + try { + await harness.scan() + await configuration.update('useCodeLens', false, ConfigurationTarget.Workspace) + assert.deepEqual(harness.diagnostics.get(first)?.[0]?.tags, [DiagnosticTag.Unnecessary]) + await configuration.update('exclude', ['scan-tests/**'], ConfigurationTarget.Workspace) + assert.deepEqual(harness.diagnostics.get(first), []) + } finally { + lifecycle.dispose() + harness.dispose() + await configuration.update('useCodeLens', previous, ConfigurationTarget.Workspace) + await configuration.update('exclude', exclusions, ConfigurationTarget.Workspace) + } + }) +}) + +function createHarness( + findFiles?: (token: CancellationToken) => Thenable, + references?: (uri: Uri, position: Position) => Promise +) { + const symbolUris: string[] = [] + const referenceUris: string[] = [] + const errors: unknown[] = [] + const progress: ScanProgress[] = [] + + const output: Pick = { + error: (error: unknown, ...args: unknown[]): void => { errors.push(error, ...args) }, + info: (): void => {}, + warn: (): void => {} + } + + const executeCommand: CommandExecutor = async (command: string, ...args: readonly unknown[]): Promise => { + const uri = args[0] + + assert.ok(uri instanceof Uri) + + if (command === 'vscode.executeDocumentSymbolProvider') { + symbolUris.push(uri.toString()) + + const document = await workspace.openTextDocument(uri) + const offset = document.getText().indexOf('function entry') + + if (offset === -1) { + return [] as Result + } + + const start = document.positionAt(offset) + const range = new Range(start, start.translate(0, 19)) + const name = new Range(start.translate(0, 9), start.translate(0, 14)) + const symbol = new DocumentSymbol('entry', '', SymbolKind.Function, range, name) + + return [symbol] as Result + } + + assert.equal(command, 'vscode.executeReferenceProvider') + + const position = args[1] + + assert.ok(position instanceof Position) + referenceUris.push(uri.toString()) + + const locations = references === undefined + ? [new Location(uri, new Range(position, position.translate(0, 5)))] + : await references(uri, position) + + return locations as Result + } + + const analyzer = new ZeroReferenceAnalyzer(executeCommand, undefined, (error, uri) => { + errors.push(error, uri.toString()) + }) + + const diagnostics = languages.createDiagnosticCollection('scan-test') + const scanner = new ZeroReferenceWorkspaceScanner(analyzer, output, diagnostics, findFiles) + const cancellation = new CancellationTokenSource() + let disposed = false + + return { + analyzer, + cancellation, + diagnostics, + errors, + progress, + referenceUris, + scanner, + symbolUris, + scan: () => scanner.scan({ report: value => { progress.push(value) } }, cancellation.token), + + dispose(): void { + if (!disposed) { + disposed = true + scanner.dispose() + analyzer.dispose() + cancellation.dispose() + } + } + } +} + +function deferred() { + let resolve: () => void = () => {} + const promise = new Promise(complete => { resolve = complete }) + + return { + promise, + resolve + } +} From dfa266c4c5eef87e118e7ef7894356d54edfff36 Mon Sep 17 00:00:00 2001 From: "Pier Dolique (Perdolique)" Date: Tue, 8 Sep 2026 07:13:08 +0300 Subject: [PATCH 2/2] test(workspaceScan): stabilize editor assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ✅ Compare document tab URIs instead of counting startup and file tabs together - ✅ Prepare the unsaved editor before measuring scan effects and exercise the Welcome page --- test/scanIntegration/workspaceScan.test.ts | 27 ++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/test/scanIntegration/workspaceScan.test.ts b/test/scanIntegration/workspaceScan.test.ts index 8713c61..1620de5 100644 --- a/test/scanIntegration/workspaceScan.test.ts +++ b/test/scanIntegration/workspaceScan.test.ts @@ -6,6 +6,7 @@ import { DiagnosticSeverity, languages, Range, + TabInputText, Uri, window, WorkspaceEdit, @@ -32,11 +33,13 @@ suite('fresh workspace scan integration', () => { const previousExclusions = configuration.inspect('exclude')?.workspaceValue const document = await workspace.openTextDocument(typescriptUri) const original = document.getText() - const tabs = window.tabGroups.all.flatMap(group => group.tabs).length try { + // Welcome can appear during startup without opening a document editor. + await commands.executeCommand('workbench.action.openWalkthrough') await configuration.update('useCodeLens', false, ConfigurationTarget.Workspace) await configuration.update('exclude', ['scan/consumer.ts'], ConfigurationTarget.Workspace) + await window.showTextDocument(document, { preview: false }) const edit = new WorkspaceEdit() const end = document.positionAt(original.length) @@ -44,11 +47,15 @@ suite('fresh workspace scan integration', () => { edit.insert(typescriptUri, end, '\nscanUnusedTs();\n') assert.equal(await workspace.applyEdit(edit), true) + const textTabUris = getOpenTextTabUris() const result = await commands.executeCommand('zeroReference.scanWorkspace') assert.ok(result !== undefined) assert.ok(result.status === 'complete' || result.status === 'incomplete') - assert.equal(window.tabGroups.all.flatMap(group => group.tabs).length, tabs) + + const currentTextTabUris = getOpenTextTabUris() + + assert.deepEqual(currentTextTabUris, textTabUris, 'Scanning must not open document editor tabs') assert.equal(document.isDirty, true) const file = await workspace.fs.readFile(typescriptUri) @@ -87,3 +94,19 @@ suite('fresh workspace scan integration', () => { }) }) + +function getOpenTextTabUris(): string[] { + const uris: string[] = [] + + for (const group of window.tabGroups.all) { + for (const tab of group.tabs) { + if (tab.input instanceof TabInputText) { + const uri = tab.input.uri.toString() + + uris.push(uri) + } + } + } + + return uris.sort() +}