Skip to content

Commit 96a3f31

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

File tree

15 files changed

+356
-20
lines changed

15 files changed

+356
-20
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

+82-2
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,
@@ -11,6 +12,7 @@ import {
1112
import type { DirResult } from "tmp";
1213
import { dirSync } from "tmp";
1314
import { ensureDirSync, symlinkSync, writeFileSync } from "fs-extra";
15+
import "../../matchers/toEqualPath";
1416

1517
describe("files", () => {
1618
const dataDir = join(__dirname, "../../data");
@@ -62,9 +64,29 @@ describe("files", () => {
6264
const file4 = join(dataDir, "multiple-result-sets.ql");
6365
const file5 = join(dataDir, "query.ql");
6466

67+
const vaDir = join(dataDir, "variant-analysis-query-packs");
68+
const file6 = join(vaDir, "workspace1", "dir1", "query1.ql");
69+
const file7 = join(vaDir, "workspace1", "pack1", "query1.ql");
70+
const file8 = join(vaDir, "workspace1", "pack1", "query2.ql");
71+
const file9 = join(vaDir, "workspace1", "pack2", "query1.ql");
72+
const file10 = join(vaDir, "workspace1", "query1.ql");
73+
const file11 = join(vaDir, "workspace2", "query1.ql");
74+
6575
const result = await gatherQlFiles([dataDir]);
6676
expect(result.sort()).toEqual([
67-
[file1, file2, file3, file4, file5],
77+
[
78+
file1,
79+
file2,
80+
file3,
81+
file4,
82+
file5,
83+
file6,
84+
file7,
85+
file8,
86+
file9,
87+
file10,
88+
file11,
89+
],
6890
true,
6991
]);
7092
});
@@ -88,10 +110,30 @@ describe("files", () => {
88110
const file4 = join(dataDir, "multiple-result-sets.ql");
89111
const file5 = join(dataDir, "query.ql");
90112

113+
const vaDir = join(dataDir, "variant-analysis-query-packs");
114+
const file6 = join(vaDir, "workspace1", "dir1", "query1.ql");
115+
const file7 = join(vaDir, "workspace1", "pack1", "query1.ql");
116+
const file8 = join(vaDir, "workspace1", "pack1", "query2.ql");
117+
const file9 = join(vaDir, "workspace1", "pack2", "query1.ql");
118+
const file10 = join(vaDir, "workspace1", "query1.ql");
119+
const file11 = join(vaDir, "workspace2", "query1.ql");
120+
91121
const result = await gatherQlFiles([file1, dataDir, file3, file4, file5]);
92122
result[0].sort();
93123
expect(result.sort()).toEqual([
94-
[file1, file2, file3, file4, file5],
124+
[
125+
file1,
126+
file2,
127+
file3,
128+
file4,
129+
file5,
130+
file6,
131+
file7,
132+
file8,
133+
file9,
134+
file10,
135+
file11,
136+
],
95137
true,
96138
]);
97139
});
@@ -417,3 +459,41 @@ describe("walkDirectory", () => {
417459
expect(files.sort()).toEqual([file1, file2, file3, file4, file5, file6]);
418460
});
419461
});
462+
463+
describe("findCommonParentDir", () => {
464+
it("should find the common parent dir for multiple paths with common parent", () => {
465+
const paths = [
466+
join("foo", "bar", "baz"),
467+
join("foo", "bar", "qux"),
468+
join("foo", "bar", "quux"),
469+
];
470+
471+
const commonDir = findCommonParentDir(...paths);
472+
473+
expect(commonDir).toEqualPath(join("foo", "bar"));
474+
});
475+
476+
it("should return the root if paths have no common parent other than root", () => {
477+
const paths = [
478+
join("foo", "bar", "baz"),
479+
join("qux", "quux", "corge"),
480+
join("grault", "garply"),
481+
];
482+
483+
const commonDir = findCommonParentDir(...paths);
484+
485+
expect(commonDir).toEqualPath("");
486+
});
487+
488+
it("should return empty string for relative paths with no common parent", () => {
489+
const paths = [
490+
join("foo", "bar", "baz"),
491+
join("qux", "quux", "corge"),
492+
join("grault", "garply"),
493+
];
494+
495+
const commonDir = findCommonParentDir(...paths);
496+
497+
expect(commonDir).toEqualPath("");
498+
});
499+
});

0 commit comments

Comments
 (0)