Skip to content

Commit 0683026

Browse files
feat: implement ProjectResolver service with caching and multi-root workspace support (#52)
- Add new ProjectResolver service to centralize project root resolution logic - Implement intelligent caching with 30-second timeout and automatic invalidation - Enhance multi-root workspace support with workspace-aware project selection - Move resolveStepZenProjectRoot logic from utils to dedicated service - Update CLI service to use ProjectResolver with dynamic imports - Add workspace change listeners to clear cache when folders change - Maintain backward compatibility with deprecated wrapper function - Add comprehensive test suite with 101 passing tests covering: * Core resolution functionality and caching behavior * Multi-root workspace scenarios and error handling * Service registry integration and mocking Resolves #45 Co-authored-by: CursorAI
1 parent f8aff0b commit 0683026

File tree

7 files changed

+672
-131
lines changed

7 files changed

+672
-131
lines changed

src/extension.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { StepZenError, handleError } from "./errors";
55
import { UI, FILE_PATTERNS } from "./utils/constants";
66
import { safeRegisterCommand } from "./utils/safeRegisterCommand";
77
import { scanStepZenProject, computeHash } from "./utils/stepzenProjectScanner";
8-
import { resolveStepZenProjectRoot } from "./utils/stepzenProject";
98
import { StepZenCodeLensProvider } from "./utils/codelensProvider";
109
import { services } from "./services";
1110

@@ -61,10 +60,13 @@ async function initialiseFor(folder: vscode.WorkspaceFolder) {
6160
watcher?.dispose();
6261
activeFolder = folder;
6362

63+
// Clear project resolver cache when switching workspace folders
64+
services.projectResolver.clearCache();
65+
6466
// figure out where the actual StepZen project lives (may be nested)
6567
let projectRoot: string;
6668
try {
67-
projectRoot = await resolveStepZenProjectRoot(folder.uri);
69+
projectRoot = await services.projectResolver.resolveStepZenProjectRoot(folder.uri);
6870
} catch (err) {
6971
vscode.window.showWarningMessage(
7072
`StepZen Tools: could not locate a StepZen project under ${folder.name}`,
@@ -275,6 +277,17 @@ export async function activate(context: vscode.ExtensionContext) {
275277
}),
276278
);
277279

280+
// 3 Clear project resolver cache when workspace folders change
281+
context.subscriptions.push(
282+
vscode.workspace.onDidChangeWorkspaceFolders((event) => {
283+
// Clear cache when workspace folders are added or removed
284+
if (event.added.length > 0 || event.removed.length > 0) {
285+
services.logger.debug("Workspace folders changed, clearing project resolver cache");
286+
services.projectResolver.clearCache();
287+
}
288+
}),
289+
);
290+
278291
runtimeDiag = vscode.languages.createDiagnosticCollection(
279292
UI.DIAGNOSTIC_COLLECTION_NAME,
280293
);

src/services/cli.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import * as path from 'path';
33
import * as fs from 'fs';
44
import * as os from 'os';
55
import { logger } from './logger';
6-
import { resolveStepZenProjectRoot } from '../utils/stepzenProject';
76
import { CliError } from '../errors';
87

98
/**
@@ -21,7 +20,10 @@ export class StepzenCliService {
2120
async deploy(): Promise<void> {
2221
try {
2322
logger.info('Starting StepZen deployment...');
24-
const projectRoot = await resolveStepZenProjectRoot();
23+
24+
// Import services here to avoid circular dependency
25+
const { services } = await import('./index.js');
26+
const projectRoot = await services.projectResolver.resolveStepZenProjectRoot();
2527

2628
// Instead of using inherit, we'll capture the output for better error handling
2729
const result = await this.spawnProcessWithOutput(['deploy'], {
@@ -70,7 +72,10 @@ export class StepzenCliService {
7072

7173
try {
7274
logger.info('Executing StepZen GraphQL request...');
73-
const projectRoot = await resolveStepZenProjectRoot();
75+
76+
// Import services here to avoid circular dependency
77+
const { services } = await import('./index.js');
78+
const projectRoot = await services.projectResolver.resolveStepZenProjectRoot();
7479

7580
// Create a temporary file with the query in system temp directory
7681
const timestamp = new Date().getTime();

src/services/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { StepzenCliService } from './cli';
22
import { Logger, logger } from './logger';
3+
import { ProjectResolver } from './projectResolver';
34

45
/**
56
* Service registry for dependency injection of application services
@@ -8,6 +9,7 @@ import { Logger, logger } from './logger';
89
export interface ServiceRegistry {
910
cli: StepzenCliService;
1011
logger: Logger;
12+
projectResolver: ProjectResolver;
1113
}
1214

1315
/**
@@ -16,6 +18,7 @@ export interface ServiceRegistry {
1618
export const services: ServiceRegistry = {
1719
cli: new StepzenCliService(),
1820
logger,
21+
projectResolver: new ProjectResolver(logger),
1922
};
2023

2124
/**

src/services/projectResolver.ts

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
/**
2+
* Copyright IBM Corp. 2025
3+
* Assisted by CursorAI
4+
*/
5+
6+
import * as vscode from "vscode";
7+
import * as path from "path";
8+
import * as fs from "fs";
9+
import { StepZenError } from "../errors";
10+
import { Logger } from "./logger";
11+
12+
/**
13+
* Cache entry for a resolved project root
14+
*/
15+
interface ProjectRootCache {
16+
/** The resolved project root path */
17+
projectRoot: string;
18+
/** Timestamp when this was cached */
19+
timestamp: number;
20+
/** The workspace folder this was resolved for */
21+
workspaceFolder?: vscode.WorkspaceFolder;
22+
/** The hint URI that was used for resolution */
23+
hintUri?: vscode.Uri;
24+
}
25+
26+
/**
27+
* Service for resolving StepZen project roots with caching support
28+
* Handles multi-root workspace scenarios and caches the last resolved root
29+
*/
30+
export class ProjectResolver {
31+
private cache: ProjectRootCache | null = null;
32+
private readonly cacheTimeout = 30000; // 30 seconds cache timeout
33+
34+
constructor(private logger: Logger) {}
35+
36+
/**
37+
* Resolves the root directory of a StepZen project with caching
38+
* First tries to find a project containing the active editor,
39+
* then scans the workspace, and finally prompts the user if multiple projects exist
40+
*
41+
* @param hintUri Optional URI hint to start searching from
42+
* @param forceRefresh If true, bypasses cache and forces fresh resolution
43+
* @returns Promise resolving to the absolute path of the project root
44+
* @throws StepZenError if no StepZen project is found or user cancels selection
45+
*/
46+
async resolveStepZenProjectRoot(
47+
hintUri?: vscode.Uri,
48+
forceRefresh = false,
49+
): Promise<string> {
50+
// Check cache first (unless force refresh is requested)
51+
if (!forceRefresh && this.isCacheValid(hintUri)) {
52+
this.logger.debug(`Using cached project root: ${this.cache!.projectRoot}`);
53+
return this.cache!.projectRoot;
54+
}
55+
56+
this.logger.debug("Resolving StepZen project root...");
57+
58+
// ① Try the folder that owns the active editor or hint URI
59+
const start = hintUri ?? vscode.window.activeTextEditor?.document.uri;
60+
if (start) {
61+
// Validate that the URI has a valid filesystem path
62+
if (!start.fsPath || typeof start.fsPath !== "string") {
63+
this.logger.warn("Invalid path in active editor URI");
64+
} else {
65+
const byAscend = this.ascendForConfig(path.dirname(start.fsPath));
66+
if (byAscend) {
67+
this.updateCache(byAscend, start);
68+
return byAscend;
69+
}
70+
}
71+
}
72+
73+
// ② Otherwise scan the workspace(s) for StepZen projects
74+
this.logger.info("StepZen project not found in current folder. Scanning workspace(s)...");
75+
76+
const configs = await this.findStepZenConfigs();
77+
if (!configs || configs.length === 0) {
78+
throw new StepZenError(
79+
"No StepZen project (stepzen.config.json) found in workspace.",
80+
"CONFIG_NOT_FOUND"
81+
);
82+
}
83+
84+
if (configs.length === 1) {
85+
if (!configs[0].fsPath) {
86+
throw new StepZenError(
87+
"Invalid file path for StepZen configuration.",
88+
"INVALID_FILE_PATH"
89+
);
90+
}
91+
const projectRoot = path.dirname(configs[0].fsPath);
92+
this.updateCache(projectRoot, hintUri);
93+
return projectRoot;
94+
}
95+
96+
// ③ Prompt when several projects exist
97+
this.logger.info("Multiple StepZen projects found. Prompting for selection...");
98+
99+
const projectRoot = await this.promptForProjectSelection(configs);
100+
this.updateCache(projectRoot, hintUri);
101+
return projectRoot;
102+
}
103+
104+
/**
105+
* Clears the cached project root
106+
* Useful when workspace changes or project structure changes
107+
*/
108+
clearCache(): void {
109+
this.logger.debug("Clearing project root cache");
110+
this.cache = null;
111+
}
112+
113+
/**
114+
* Gets the currently cached project root if valid
115+
* @returns The cached project root path or null if no valid cache
116+
*/
117+
getCachedProjectRoot(): string | null {
118+
if (this.isCacheValid()) {
119+
return this.cache!.projectRoot;
120+
}
121+
return null;
122+
}
123+
124+
/**
125+
* Checks if the current cache is valid for the given hint URI
126+
*/
127+
private isCacheValid(hintUri?: vscode.Uri): boolean {
128+
if (!this.cache) {
129+
return false;
130+
}
131+
132+
// Check if cache has expired
133+
const now = Date.now();
134+
if (now - this.cache.timestamp > this.cacheTimeout) {
135+
this.logger.debug("Project root cache expired");
136+
return false;
137+
}
138+
139+
// If we have a hint URI, check if it's in the same workspace folder as cached
140+
if (hintUri) {
141+
const currentWorkspaceFolder = vscode.workspace.getWorkspaceFolder(hintUri);
142+
if (currentWorkspaceFolder !== this.cache.workspaceFolder) {
143+
this.logger.debug("Hint URI is in different workspace folder than cached");
144+
return false;
145+
}
146+
}
147+
148+
// Verify the cached project root still exists and has a config file
149+
const configPath = path.join(this.cache.projectRoot, "stepzen.config.json");
150+
if (!fs.existsSync(configPath)) {
151+
this.logger.debug("Cached project root no longer contains stepzen.config.json");
152+
return false;
153+
}
154+
155+
return true;
156+
}
157+
158+
/**
159+
* Updates the cache with a new project root
160+
*/
161+
private updateCache(projectRoot: string, hintUri?: vscode.Uri): void {
162+
const workspaceFolder = hintUri ? vscode.workspace.getWorkspaceFolder(hintUri) : undefined;
163+
164+
this.cache = {
165+
projectRoot,
166+
timestamp: Date.now(),
167+
workspaceFolder,
168+
hintUri,
169+
};
170+
171+
this.logger.debug(`Cached project root: ${projectRoot}`);
172+
}
173+
174+
/**
175+
* Finds all StepZen configuration files in the workspace(s)
176+
* Supports multi-root workspaces by searching in all workspace folders
177+
*/
178+
private async findStepZenConfigs(): Promise<vscode.Uri[]> {
179+
const configs: vscode.Uri[] = [];
180+
181+
// Handle multi-root workspaces
182+
if (vscode.workspace.workspaceFolders) {
183+
for (const folder of vscode.workspace.workspaceFolders) {
184+
try {
185+
const folderConfigs = await vscode.workspace.findFiles(
186+
new vscode.RelativePattern(folder, "**/stepzen.config.json"),
187+
new vscode.RelativePattern(folder, "**/node_modules/**"),
188+
);
189+
configs.push(...folderConfigs);
190+
} catch (err) {
191+
this.logger.warn(`Failed to search for configs in workspace folder ${folder.name}: ${err}`);
192+
}
193+
}
194+
} else {
195+
// Fallback for single workspace
196+
const allConfigs = await vscode.workspace.findFiles(
197+
"**/stepzen.config.json",
198+
"**/node_modules/**",
199+
);
200+
configs.push(...allConfigs);
201+
}
202+
203+
return configs;
204+
}
205+
206+
/**
207+
* Prompts user to select from multiple StepZen projects
208+
*/
209+
private async promptForProjectSelection(configs: vscode.Uri[]): Promise<string> {
210+
// Validate that all configs have valid paths
211+
const validConfigs = configs.filter(
212+
(c) => c.fsPath && typeof c.fsPath === "string",
213+
);
214+
215+
if (validConfigs.length === 0) {
216+
throw new StepZenError(
217+
"No valid StepZen project paths found.",
218+
"INVALID_PROJECT_PATHS"
219+
);
220+
}
221+
222+
// Create pick items with workspace folder context for multi-root workspaces
223+
const pickItems = validConfigs.map((c) => {
224+
const projectDir = path.dirname(c.fsPath);
225+
const workspaceFolder = vscode.workspace.getWorkspaceFolder(c);
226+
227+
let label: string;
228+
if (workspaceFolder && vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 1) {
229+
// Multi-root workspace: include workspace folder name
230+
const relativePath = vscode.workspace.asRelativePath(projectDir, false);
231+
label = `${workspaceFolder.name}: ${relativePath}`;
232+
} else {
233+
// Single workspace: just show relative path
234+
label = vscode.workspace.asRelativePath(projectDir, false);
235+
}
236+
237+
return {
238+
label,
239+
target: projectDir,
240+
description: workspaceFolder ? `in ${workspaceFolder.name}` : undefined,
241+
};
242+
});
243+
244+
const pick = await vscode.window.showQuickPick(pickItems, {
245+
placeHolder: "Select the StepZen project to use",
246+
matchOnDescription: true,
247+
});
248+
249+
if (!pick || !pick.target) {
250+
throw new StepZenError(
251+
"Operation cancelled by user.",
252+
"USER_CANCELLED"
253+
);
254+
}
255+
256+
return pick.target;
257+
}
258+
259+
/**
260+
* Helper function that ascends directory tree looking for stepzen.config.json
261+
* @param dir Starting directory path
262+
* @returns Path to directory containing stepzen.config.json or null if not found
263+
*/
264+
private ascendForConfig(dir: string): string | null {
265+
// Validate input
266+
if (!dir || typeof dir !== "string") {
267+
this.logger.error("Invalid directory path provided to ascendForConfig");
268+
return null;
269+
}
270+
271+
try {
272+
while (true) {
273+
const configPath = path.join(dir, "stepzen.config.json");
274+
if (fs.existsSync(configPath)) {
275+
return dir;
276+
}
277+
const parent = path.dirname(dir);
278+
if (parent === dir) {
279+
break;
280+
}
281+
dir = parent;
282+
}
283+
} catch (err) {
284+
this.logger.error("Failed to search for StepZen configuration in directory tree", err);
285+
return null;
286+
}
287+
288+
return null;
289+
}
290+
}

0 commit comments

Comments
 (0)