Skip to content

Commit d2265db

Browse files
committed
Improve handling of unknown QL pack roots for multi-query MRVAs
1 parent 1f24cd1 commit d2265db

File tree

14 files changed

+298
-18
lines changed

14 files changed

+298
-18
lines changed

extensions/ql-vscode/src/common/files.ts

+28-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { pathExists, stat, readdir, opendir } from "fs-extra";
2-
import { isAbsolute, join, relative, resolve } from "path";
2+
import { isAbsolute, join, relative, resolve, sep } from "path";
33
import { tmpdir as osTmpdir } from "os";
44

55
/**
@@ -132,3 +132,30 @@ export function isIOError(e: any): e is IOError {
132132
export function tmpdir(): string {
133133
return osTmpdir();
134134
}
135+
136+
/**
137+
* Finds the common parent directory of an arbitrary number of paths. If the paths are absolute,
138+
* the result is also absolute. If the paths are relative, the result is relative.
139+
* @param paths The array of paths.
140+
* @returns The common parent directory of the paths.
141+
*/
142+
export function findCommonParentDir(...paths: string[]): string {
143+
// Split each path into its components
144+
const pathParts = paths.map((path) => path.split(sep));
145+
146+
let commonDir = "";
147+
// Iterate over the components of the first path and checks if the same
148+
// component exists at the same position in all the other paths. If it does,
149+
// add the component to the common directory. If it doesn't, stop the
150+
// iteration and returns the common directory found so far.
151+
for (let i = 0; i < pathParts[0].length; i++) {
152+
const part = pathParts[0][i];
153+
if (pathParts.every((parts) => parts[i] === part)) {
154+
commonDir = `${commonDir}${part}${sep}`;
155+
} else {
156+
break;
157+
}
158+
}
159+
160+
return commonDir;
161+
}

extensions/ql-vscode/src/common/ql.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,16 @@ export async function getQlPackFilePath(
3232
* Recursively find the directory containing qlpack.yml or codeql-pack.yml. If
3333
* no such directory is found, the directory containing the query file is returned.
3434
* @param queryFile The query file to start from.
35-
* @returns The path to the pack root.
35+
* @returns The path to the pack root or undefined if it doesn't exist.
3636
*/
37-
export async function findPackRoot(queryFile: string): Promise<string> {
37+
export async function findPackRoot(
38+
queryFile: string,
39+
): Promise<string | undefined> {
3840
let dir = dirname(queryFile);
3941
while (!(await getQlPackFilePath(dir))) {
4042
dir = dirname(dir);
4143
if (isFileSystemRoot(dir)) {
42-
// there is no qlpack.yml or codeql-pack.yml in this directory or any parent directory.
43-
// just use the query file's directory as the pack root.
44-
return dirname(queryFile);
44+
return undefined;
4545
}
4646
}
4747

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { dirname } from "path";
2+
import { findCommonParentDir } from "../common/files";
3+
import { findPackRoot } from "../common/ql";
4+
5+
/**
6+
* This function finds the root directory of the QL pack that contains the provided query files.
7+
* It handles several cases:
8+
* - If no query files are provided, it throws an error.
9+
* - If all query files are in the same QL pack, it returns the root directory of that pack.
10+
* - If the query files are in different QL packs, it throws an error.
11+
* - If some query files are in a QL pack and some aren't, it throws an error.
12+
* - If none of the query files are in a QL pack, it returns the common parent directory of the query files. However,
13+
* if there are more than one query files and they're not in the same workspace folder, it throws an error.
14+
*
15+
* @param queryFiles - An array of file paths for the query files.
16+
* @param workspaceFolders - An array of workspace folder paths.
17+
* @returns The root directory of the QL pack that contains the query files, or the common parent directory of the query files.
18+
*/
19+
export async function findVariantAnalysisQlPackRoot(
20+
queryFiles: string[],
21+
workspaceFolders: string[],
22+
): Promise<string> {
23+
if (queryFiles.length === 0) {
24+
throw Error("No query files provided");
25+
}
26+
27+
// Calculate the pack root for each query file
28+
const packRoots: Array<string | undefined> = [];
29+
for (const queryFile of queryFiles) {
30+
const packRoot = await findPackRoot(queryFile);
31+
packRoots.push(packRoot);
32+
}
33+
34+
if (queryFiles.length === 1) {
35+
return packRoots[0] ?? dirname(queryFiles[0]);
36+
}
37+
38+
const uniquePackRoots = Array.from(new Set(packRoots));
39+
40+
if (uniquePackRoots.length > 1) {
41+
if (uniquePackRoots.includes(undefined)) {
42+
throw Error("Some queries are in a pack and some aren't");
43+
} else {
44+
throw Error("Some queries are in different packs");
45+
}
46+
}
47+
48+
if (uniquePackRoots[0] === undefined) {
49+
return findQlPackRootForQueriesWithNoPack(queryFiles, workspaceFolders);
50+
} else {
51+
// All in the same pack, return that pack's root
52+
return uniquePackRoots[0];
53+
}
54+
}
55+
56+
/**
57+
* For queries that are not in a pack, a potential pack root is the
58+
* common parent dir of all the queries. However, we only want to
59+
* return this if all the queries are in the same workspace folder.
60+
*/
61+
function findQlPackRootForQueriesWithNoPack(
62+
queryFiles: string[],
63+
workspaceFolders: string[],
64+
): string {
65+
const queryFileWorkspaceFolders = queryFiles.map((queryFile) =>
66+
workspaceFolders.find((workspaceFolder) =>
67+
queryFile.startsWith(workspaceFolder),
68+
),
69+
);
70+
71+
// Check if any query file is not part of the workspace.
72+
if (queryFileWorkspaceFolders.includes(undefined)) {
73+
throw Error(
74+
"Queries that are not part of a pack need to be part of the workspace",
75+
);
76+
}
77+
78+
// Check if query files are not in the same workspace folder.
79+
if (new Set(queryFileWorkspaceFolders).size > 1) {
80+
throw Error(
81+
"Queries that are not part of a pack need to be in the same workspace folder",
82+
);
83+
}
84+
85+
// They're in the same workspace folder, so we can find a common parent dir.
86+
return findCommonParentDir(...queryFiles);
87+
}

extensions/ql-vscode/src/variant-analysis/variant-analysis-manager.ts

+7-12
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,10 @@ import { handleRequestError } from "./custom-errors";
9090
import { createMultiSelectionCommand } from "../common/vscode/selection-commands";
9191
import { askForLanguage, findLanguage } from "../codeql-cli/query-language";
9292
import type { QlPackDetails } from "./ql-pack-details";
93-
import { findPackRoot, getQlPackFilePath } from "../common/ql";
93+
import { getQlPackFilePath } from "../common/ql";
9494
import { tryGetQueryMetadata } from "../codeql-cli/query-metadata";
95+
import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders";
96+
import { findVariantAnalysisQlPackRoot } from "./ql";
9597

9698
const maxRetryCount = 3;
9799

@@ -312,19 +314,12 @@ export class VariantAnalysisManager
312314
throw new Error("Please select a .ql file to run as a variant analysis");
313315
}
314316

315-
const qlPackRootPath = await findPackRoot(queryFiles[0].fsPath);
317+
const qlPackRootPath = await findVariantAnalysisQlPackRoot(
318+
queryFiles.map((f) => f.fsPath),
319+
getOnDiskWorkspaceFolders(),
320+
);
316321
const qlPackFilePath = await getQlPackFilePath(qlPackRootPath);
317322

318-
// Make sure that all remaining queries have the same pack root
319-
for (let i = 1; i < queryFiles.length; i++) {
320-
const packRoot = await findPackRoot(queryFiles[i].fsPath);
321-
if (packRoot !== qlPackRootPath) {
322-
throw new Error(
323-
"Please select queries that all belong to the same query pack",
324-
);
325-
}
326-
}
327-
328323
// Open popup to ask for language if not already hardcoded
329324
const language = qlPackFilePath
330325
? await findLanguage(this.cliServer, queryFiles[0])
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
select 42, 3.14159, "hello world", true
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
name: test-queries
2+
version: 0.0.0
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
select 42, 3.14159, "hello world", true
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
select 42, 3.14159, "hello world", true
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
name: test-queries
2+
version: 0.0.0
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
select 42, 3.14159, "hello world", true
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
select 42, 3.14159, "hello world", true
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
select 42, 3.14159, "hello world", true

extensions/ql-vscode/test/unit-tests/common/files.test.ts

+27
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { join } from "path";
22

33
import {
44
containsPath,
5+
findCommonParentDir,
56
gatherQlFiles,
67
getDirectoryNamesInsidePath,
78
pathsEqual,
@@ -417,3 +418,29 @@ describe("walkDirectory", () => {
417418
expect(files.sort()).toEqual([file1, file2, file3, file4, file5, file6]);
418419
});
419420
});
421+
422+
describe("findCommonParentDir", () => {
423+
it("should find the common parent dir for multiple paths with common parent", () => {
424+
const paths = ["/foo/bar/baz", "/foo/bar/qux", "/foo/bar/quux"];
425+
426+
const commonDir = findCommonParentDir(...paths);
427+
428+
expect(commonDir).toEqual("/foo/bar/");
429+
});
430+
431+
it("should return the root if paths have no common parent other than root", () => {
432+
const paths = ["/foo/bar/baz", "/qux/quux/corge", "/grault/garply"];
433+
434+
const commonDir = findCommonParentDir(...paths);
435+
436+
expect(commonDir).toEqual("/");
437+
});
438+
439+
it("should return empty string for relative paths with no common parent", () => {
440+
const paths = ["foo/bar/baz", "qux/quux/corge", "grault/garply"];
441+
442+
const commonDir = findCommonParentDir(...paths);
443+
444+
expect(commonDir).toEqual("");
445+
});
446+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { join } from "path";
2+
import { findVariantAnalysisQlPackRoot } from "../../../src/variant-analysis/ql";
3+
import "../../matchers/toEqualPath";
4+
5+
describe("findVariantAnalysisQlPackRoot", () => {
6+
const testDataDir = join(
7+
__dirname,
8+
"../../data/variant-analysis-query-packs",
9+
);
10+
11+
const workspaceFolders = [
12+
getFullPath("workspace1"),
13+
getFullPath("workspace2"),
14+
];
15+
16+
function getFullPath(relativePath: string) {
17+
return join(testDataDir, relativePath);
18+
}
19+
20+
it("should throw an error if no query files are provided", async () => {
21+
await expect(
22+
findVariantAnalysisQlPackRoot([], workspaceFolders),
23+
).rejects.toThrow("No query files provided");
24+
});
25+
26+
it("should return the pack root of a single query in a pack", async () => {
27+
const queryFiles = [getFullPath("workspace1/pack1/query1.ql")];
28+
29+
const packRoot = await findVariantAnalysisQlPackRoot(
30+
queryFiles,
31+
workspaceFolders,
32+
);
33+
34+
expect(packRoot).toEqualPath(getFullPath("workspace1/pack1"));
35+
});
36+
37+
it("should return the pack root of a single query not in a pack", async () => {
38+
const queryFiles = [getFullPath("workspace1/query1.ql")];
39+
40+
const packRoot = await findVariantAnalysisQlPackRoot(
41+
queryFiles,
42+
workspaceFolders,
43+
);
44+
45+
expect(packRoot).toEqualPath(getFullPath("workspace1"));
46+
});
47+
48+
it("should return the pack root of a single query not in a pack or workspace", async () => {
49+
const queryFiles = [getFullPath("dir1/query1.ql")];
50+
51+
const packRoot = await findVariantAnalysisQlPackRoot(
52+
queryFiles,
53+
workspaceFolders,
54+
);
55+
56+
expect(packRoot).toEqualPath(getFullPath("dir1"));
57+
});
58+
59+
it("should throw an error if some queries are in a pack and some are not", async () => {
60+
const queryFiles = [
61+
getFullPath("workspace1/pack1/query1.ql"),
62+
getFullPath("workspace1/query1.ql"),
63+
];
64+
65+
await expect(
66+
findVariantAnalysisQlPackRoot(queryFiles, workspaceFolders),
67+
).rejects.toThrow("Some queries are in a pack and some aren't");
68+
});
69+
70+
it("should throw an error if queries are in different packs", async () => {
71+
const queryFiles = [
72+
getFullPath("workspace1/pack1/query1.ql"),
73+
getFullPath("workspace1/pack2/query1.ql"),
74+
];
75+
76+
await expect(
77+
findVariantAnalysisQlPackRoot(queryFiles, workspaceFolders),
78+
).rejects.toThrow("Some queries are in different packs");
79+
});
80+
81+
it("should throw an error if query files are not in a pack and in different workspace folders", async () => {
82+
const queryFiles = [
83+
getFullPath("workspace1/query1.ql"),
84+
getFullPath("workspace2/query1.ql"),
85+
];
86+
87+
await expect(
88+
findVariantAnalysisQlPackRoot(queryFiles, workspaceFolders),
89+
).rejects.toThrow(
90+
"Queries that are not part of a pack need to be in the same workspace folder",
91+
);
92+
});
93+
94+
it("should throw an error if query files are not part of any workspace folder", async () => {
95+
const queryFiles = [
96+
getFullPath("workspace3/query1.ql"),
97+
getFullPath("workspace3/query2.ql"),
98+
];
99+
100+
await expect(
101+
findVariantAnalysisQlPackRoot(queryFiles, workspaceFolders),
102+
).rejects.toThrow(
103+
"Queries that are not part of a pack need to be part of the workspace",
104+
);
105+
});
106+
107+
it("should return the common parent directory if no queries are in a pack", async () => {
108+
const queryFiles = [
109+
getFullPath("workspace1/query1.ql"),
110+
getFullPath("workspace1/dir1/query1.ql"),
111+
];
112+
113+
const result = await findVariantAnalysisQlPackRoot(
114+
queryFiles,
115+
workspaceFolders,
116+
);
117+
118+
expect(result).toEqualPath(getFullPath("workspace1"));
119+
});
120+
121+
it("should return the pack root if all query files are in the same pack", async () => {
122+
const queryFiles = [
123+
getFullPath("workspace1/pack1/query1.ql"),
124+
getFullPath("workspace1/pack1/query2.ql"),
125+
];
126+
127+
const result = await findVariantAnalysisQlPackRoot(
128+
queryFiles,
129+
workspaceFolders,
130+
);
131+
132+
expect(result).toEqualPath(getFullPath("workspace1/pack1"));
133+
});
134+
});

0 commit comments

Comments
 (0)