Merge pull request #3113 from github/koesie10/compare-interpreted
Add SARIF result comparison to compare view
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
|
||||
- Add a prompt for downloading a GitHub database when opening a GitHub repository. [#3138](https://github.com/github/vscode-codeql/pull/3138)
|
||||
- Avoid showing a popup when hovering over source elements in database source files. [#3125](https://github.com/github/vscode-codeql/pull/3125)
|
||||
- Add comparison of alerts when comparing query results. This allows viewing path explanations for differences in alerts. [#3113](https://github.com/github/vscode-codeql/pull/3113)
|
||||
|
||||
## 1.11.0 - 13 December 2023
|
||||
|
||||
|
||||
@@ -371,7 +371,9 @@ export interface SetComparisonsMessage {
|
||||
readonly message: string | undefined;
|
||||
}
|
||||
|
||||
type QueryCompareResult = RawQueryCompareResult | InterpretedQueryCompareResult;
|
||||
export type QueryCompareResult =
|
||||
| RawQueryCompareResult
|
||||
| InterpretedQueryCompareResult;
|
||||
|
||||
/**
|
||||
* from is the set of rows that have changes in the "from" query.
|
||||
@@ -388,7 +390,7 @@ export type RawQueryCompareResult = {
|
||||
* from is the set of results that have changes in the "from" query.
|
||||
* to is the set of results that have changes in the "to" query.
|
||||
*/
|
||||
type InterpretedQueryCompareResult = {
|
||||
export type InterpretedQueryCompareResult = {
|
||||
kind: "interpreted";
|
||||
sourceLocationPrefix: string;
|
||||
from: sarif.Result[];
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ViewColumn } from "vscode";
|
||||
|
||||
import {
|
||||
ALERTS_TABLE_NAME,
|
||||
FromCompareViewMessage,
|
||||
InterpretedQueryCompareResult,
|
||||
QueryCompareResult,
|
||||
RawQueryCompareResult,
|
||||
ToCompareViewMessage,
|
||||
} from "../common/interface-types";
|
||||
@@ -25,15 +28,18 @@ import { App } from "../common/app";
|
||||
import { bqrsToResultSet } from "../common/bqrs-raw-results-mapper";
|
||||
import { RawResultSet } from "../common/raw-result-types";
|
||||
import {
|
||||
CompareQueryInfo,
|
||||
findCommonResultSetNames,
|
||||
findResultSetNames,
|
||||
getResultSetNames,
|
||||
} from "./result-set-names";
|
||||
import { compareInterpretedResults } from "./interpreted-results";
|
||||
|
||||
interface ComparePair {
|
||||
from: CompletedLocalQueryInfo;
|
||||
fromSchemas: BqrsInfo;
|
||||
fromInfo: CompareQueryInfo;
|
||||
to: CompletedLocalQueryInfo;
|
||||
toSchemas: BqrsInfo;
|
||||
toInfo: CompareQueryInfo;
|
||||
|
||||
commonResultSetNames: readonly string[];
|
||||
}
|
||||
@@ -62,23 +68,48 @@ export class CompareView extends AbstractWebview<
|
||||
to: CompletedLocalQueryInfo,
|
||||
selectedResultSetName?: string,
|
||||
) {
|
||||
const fromSchemas = await this.cliServer.bqrsInfo(
|
||||
from.completedQuery.query.resultsPaths.resultsPath,
|
||||
);
|
||||
const toSchemas = await this.cliServer.bqrsInfo(
|
||||
to.completedQuery.query.resultsPaths.resultsPath,
|
||||
);
|
||||
const [fromSchemas, toSchemas] = await Promise.all([
|
||||
this.cliServer.bqrsInfo(
|
||||
from.completedQuery.query.resultsPaths.resultsPath,
|
||||
),
|
||||
this.cliServer.bqrsInfo(to.completedQuery.query.resultsPaths.resultsPath),
|
||||
]);
|
||||
|
||||
const commonResultSetNames = await findCommonResultSetNames(
|
||||
fromSchemas,
|
||||
toSchemas,
|
||||
const [fromSchemaNames, toSchemaNames] = await Promise.all([
|
||||
getResultSetNames(
|
||||
fromSchemas,
|
||||
from.completedQuery.query.metadata,
|
||||
from.completedQuery.query.resultsPaths.interpretedResultsPath,
|
||||
),
|
||||
getResultSetNames(
|
||||
toSchemas,
|
||||
to.completedQuery.query.metadata,
|
||||
to.completedQuery.query.resultsPaths.interpretedResultsPath,
|
||||
),
|
||||
]);
|
||||
|
||||
const commonResultSetNames = findCommonResultSetNames(
|
||||
fromSchemaNames,
|
||||
toSchemaNames,
|
||||
);
|
||||
|
||||
this.comparePair = {
|
||||
from,
|
||||
fromSchemas,
|
||||
fromInfo: {
|
||||
schemas: fromSchemas,
|
||||
schemaNames: fromSchemaNames,
|
||||
metadata: from.completedQuery.query.metadata,
|
||||
interpretedResultsPath:
|
||||
from.completedQuery.query.resultsPaths.interpretedResultsPath,
|
||||
},
|
||||
to,
|
||||
toSchemas,
|
||||
toInfo: {
|
||||
schemas: toSchemas,
|
||||
schemaNames: toSchemaNames,
|
||||
metadata: to.completedQuery.query.metadata,
|
||||
interpretedResultsPath:
|
||||
to.completedQuery.query.resultsPaths.interpretedResultsPath,
|
||||
},
|
||||
commonResultSetNames,
|
||||
};
|
||||
|
||||
@@ -119,16 +150,28 @@ export class CompareView extends AbstractWebview<
|
||||
panel.reveal(undefined, true);
|
||||
|
||||
await this.waitForPanelLoaded();
|
||||
const { currentResultSetDisplayName, fromResultSet, toResultSet } =
|
||||
await this.findResultSetsToCompare(
|
||||
this.comparePair,
|
||||
selectedResultSetName,
|
||||
);
|
||||
const {
|
||||
currentResultSetName,
|
||||
currentResultSetDisplayName,
|
||||
fromResultSetName,
|
||||
toResultSetName,
|
||||
} = await this.findResultSetsToCompare(
|
||||
this.comparePair,
|
||||
selectedResultSetName,
|
||||
);
|
||||
if (currentResultSetDisplayName) {
|
||||
let result: RawQueryCompareResult | undefined;
|
||||
let result: QueryCompareResult | undefined;
|
||||
let message: string | undefined;
|
||||
try {
|
||||
result = this.compareResults(fromResultSet, toResultSet);
|
||||
if (currentResultSetName === ALERTS_TABLE_NAME) {
|
||||
result = await this.compareInterpretedResults(this.comparePair);
|
||||
} else {
|
||||
result = await this.compareResults(
|
||||
this.comparePair,
|
||||
fromResultSetName,
|
||||
toResultSetName,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
message = getErrorMessage(e);
|
||||
}
|
||||
@@ -205,31 +248,27 @@ export class CompareView extends AbstractWebview<
|
||||
}
|
||||
|
||||
private async findResultSetsToCompare(
|
||||
{ from, fromSchemas, to, toSchemas, commonResultSetNames }: ComparePair,
|
||||
{ fromInfo, toInfo, commonResultSetNames }: ComparePair,
|
||||
selectedResultSetName: string | undefined,
|
||||
) {
|
||||
const { currentResultSetDisplayName, fromResultSetName, toResultSetName } =
|
||||
await findResultSetNames(
|
||||
fromSchemas,
|
||||
toSchemas,
|
||||
commonResultSetNames,
|
||||
selectedResultSetName,
|
||||
);
|
||||
|
||||
const fromResultSet = await this.getResultSet(
|
||||
fromSchemas,
|
||||
fromResultSetName,
|
||||
from.completedQuery.query.resultsPaths.resultsPath,
|
||||
);
|
||||
const toResultSet = await this.getResultSet(
|
||||
toSchemas,
|
||||
toResultSetName,
|
||||
to.completedQuery.query.resultsPaths.resultsPath,
|
||||
);
|
||||
return {
|
||||
const {
|
||||
currentResultSetName,
|
||||
currentResultSetDisplayName,
|
||||
fromResultSet,
|
||||
toResultSet,
|
||||
fromResultSetName,
|
||||
toResultSetName,
|
||||
} = await findResultSetNames(
|
||||
fromInfo,
|
||||
toInfo,
|
||||
commonResultSetNames,
|
||||
selectedResultSetName,
|
||||
);
|
||||
|
||||
return {
|
||||
commonResultSetNames,
|
||||
currentResultSetName,
|
||||
currentResultSetDisplayName,
|
||||
fromResultSetName,
|
||||
toResultSetName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,12 +291,37 @@ export class CompareView extends AbstractWebview<
|
||||
return bqrsToResultSet(schema, chunk);
|
||||
}
|
||||
|
||||
private compareResults(
|
||||
fromResults: RawResultSet,
|
||||
toResults: RawResultSet,
|
||||
): RawQueryCompareResult {
|
||||
// Only compare columns that have the same name
|
||||
return resultsDiff(fromResults, toResults);
|
||||
private async compareResults(
|
||||
{ from, fromInfo, to, toInfo }: ComparePair,
|
||||
fromResultSetName: string,
|
||||
toResultSetName: string,
|
||||
): Promise<RawQueryCompareResult> {
|
||||
const [fromResultSet, toResultSet] = await Promise.all([
|
||||
this.getResultSet(
|
||||
fromInfo.schemas,
|
||||
fromResultSetName,
|
||||
from.completedQuery.query.resultsPaths.resultsPath,
|
||||
),
|
||||
this.getResultSet(
|
||||
toInfo.schemas,
|
||||
toResultSetName,
|
||||
to.completedQuery.query.resultsPaths.resultsPath,
|
||||
),
|
||||
]);
|
||||
|
||||
return resultsDiff(fromResultSet, toResultSet);
|
||||
}
|
||||
|
||||
private async compareInterpretedResults({
|
||||
from,
|
||||
to,
|
||||
}: ComparePair): Promise<InterpretedQueryCompareResult> {
|
||||
return compareInterpretedResults(
|
||||
this.databaseManager,
|
||||
this.cliServer,
|
||||
from,
|
||||
to,
|
||||
);
|
||||
}
|
||||
|
||||
private async openQuery(kind: "from" | "to") {
|
||||
|
||||
72
extensions/ql-vscode/src/compare/interpreted-results.ts
Normal file
72
extensions/ql-vscode/src/compare/interpreted-results.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Uri } from "vscode";
|
||||
import * as sarif from "sarif";
|
||||
import { pathExists } from "fs-extra";
|
||||
import { sarifParser } from "../common/sarif-parser";
|
||||
import { CompletedLocalQueryInfo } from "../query-results";
|
||||
import { DatabaseManager } from "../databases/local-databases";
|
||||
import { CodeQLCliServer } from "../codeql-cli/cli";
|
||||
import { InterpretedQueryCompareResult } from "../common/interface-types";
|
||||
|
||||
import { sarifDiff } from "./sarif-diff";
|
||||
|
||||
async function getInterpretedResults(
|
||||
interpretedResultsPath: string,
|
||||
): Promise<sarif.Log | undefined> {
|
||||
if (!(await pathExists(interpretedResultsPath))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return await sarifParser(interpretedResultsPath);
|
||||
}
|
||||
|
||||
export async function compareInterpretedResults(
|
||||
databaseManager: DatabaseManager,
|
||||
cliServer: CodeQLCliServer,
|
||||
fromQuery: CompletedLocalQueryInfo,
|
||||
toQuery: CompletedLocalQueryInfo,
|
||||
): Promise<InterpretedQueryCompareResult> {
|
||||
const database = databaseManager.findDatabaseItem(
|
||||
Uri.parse(toQuery.initialInfo.databaseInfo.databaseUri),
|
||||
);
|
||||
if (!database) {
|
||||
throw new Error(
|
||||
"Could not find database the queries. Please check that the database still exists.",
|
||||
);
|
||||
}
|
||||
|
||||
const [fromResultSet, toResultSet, sourceLocationPrefix] = await Promise.all([
|
||||
getInterpretedResults(
|
||||
fromQuery.completedQuery.query.resultsPaths.interpretedResultsPath,
|
||||
),
|
||||
getInterpretedResults(
|
||||
toQuery.completedQuery.query.resultsPaths.interpretedResultsPath,
|
||||
),
|
||||
database.getSourceLocationPrefix(cliServer),
|
||||
]);
|
||||
|
||||
if (!fromResultSet || !toResultSet) {
|
||||
throw new Error(
|
||||
"Could not find interpreted results for one or both queries.",
|
||||
);
|
||||
}
|
||||
|
||||
const fromResults = fromResultSet.runs[0].results;
|
||||
const toResults = toResultSet.runs[0].results;
|
||||
|
||||
if (!fromResults) {
|
||||
throw new Error("No results found in the 'from' query.");
|
||||
}
|
||||
|
||||
if (!toResults) {
|
||||
throw new Error("No results found in the 'to' query.");
|
||||
}
|
||||
|
||||
const { from, to } = sarifDiff(fromResults, toResults);
|
||||
|
||||
return {
|
||||
kind: "interpreted",
|
||||
sourceLocationPrefix,
|
||||
from,
|
||||
to,
|
||||
};
|
||||
}
|
||||
@@ -1,28 +1,49 @@
|
||||
import { pathExists } from "fs-extra";
|
||||
import { BqrsInfo } from "../common/bqrs-cli-types";
|
||||
import { getDefaultResultSetName } from "../common/interface-types";
|
||||
import {
|
||||
ALERTS_TABLE_NAME,
|
||||
getDefaultResultSetName,
|
||||
QueryMetadata,
|
||||
} from "../common/interface-types";
|
||||
|
||||
export async function findCommonResultSetNames(
|
||||
fromSchemas: BqrsInfo,
|
||||
toSchemas: BqrsInfo,
|
||||
export async function getResultSetNames(
|
||||
schemas: BqrsInfo,
|
||||
metadata: QueryMetadata | undefined,
|
||||
interpretedResultsPath: string | undefined,
|
||||
): Promise<string[]> {
|
||||
const fromSchemaNames = fromSchemas["result-sets"].map(
|
||||
(schema) => schema.name,
|
||||
);
|
||||
const toSchemaNames = toSchemas["result-sets"].map((schema) => schema.name);
|
||||
const schemaNames = schemas["result-sets"].map((schema) => schema.name);
|
||||
|
||||
if (metadata?.kind !== "graph" && interpretedResultsPath) {
|
||||
if (await pathExists(interpretedResultsPath)) {
|
||||
schemaNames.push(ALERTS_TABLE_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
return schemaNames;
|
||||
}
|
||||
|
||||
export function findCommonResultSetNames(
|
||||
fromSchemaNames: string[],
|
||||
toSchemaNames: string[],
|
||||
): string[] {
|
||||
return fromSchemaNames.filter((name) => toSchemaNames.includes(name));
|
||||
}
|
||||
|
||||
export type CompareQueryInfo = {
|
||||
schemas: BqrsInfo;
|
||||
schemaNames: string[];
|
||||
metadata: QueryMetadata | undefined;
|
||||
interpretedResultsPath: string;
|
||||
};
|
||||
|
||||
export async function findResultSetNames(
|
||||
fromSchemas: BqrsInfo,
|
||||
toSchemas: BqrsInfo,
|
||||
from: CompareQueryInfo,
|
||||
to: CompareQueryInfo,
|
||||
commonResultSetNames: readonly string[],
|
||||
selectedResultSetName: string | undefined,
|
||||
) {
|
||||
const fromSchemaNames = fromSchemas["result-sets"].map(
|
||||
(schema) => schema.name,
|
||||
);
|
||||
const toSchemaNames = toSchemas["result-sets"].map((schema) => schema.name);
|
||||
const fromSchemaNames = from.schemaNames;
|
||||
const toSchemaNames = to.schemaNames;
|
||||
|
||||
// Fall back on the default result set names if there are no common ones.
|
||||
const defaultFromResultSetName = fromSchemaNames.find((name) =>
|
||||
@@ -47,6 +68,7 @@ export async function findResultSetNames(
|
||||
const toResultSetName = currentResultSetName || defaultToResultSetName!;
|
||||
|
||||
return {
|
||||
currentResultSetName,
|
||||
currentResultSetDisplayName:
|
||||
currentResultSetName ||
|
||||
`${defaultFromResultSetName} <-> ${defaultToResultSetName}`,
|
||||
|
||||
50
extensions/ql-vscode/src/compare/sarif-diff.ts
Normal file
50
extensions/ql-vscode/src/compare/sarif-diff.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as sarif from "sarif";
|
||||
|
||||
/**
|
||||
* Compare the alerts of two queries. Use deep equality to determine if
|
||||
* results have been added or removed across two invocations of a query.
|
||||
*
|
||||
* Assumptions:
|
||||
*
|
||||
* 1. Queries have the same sort order
|
||||
* 2. Results are not changed or re-ordered, they are only added or removed
|
||||
*
|
||||
* @param fromResults the source query
|
||||
* @param toResults the target query
|
||||
*
|
||||
* @throws Error when:
|
||||
* 1. If either query is empty
|
||||
* 2. If the queries are 100% disjoint
|
||||
*/
|
||||
export function sarifDiff(
|
||||
fromResults: sarif.Result[],
|
||||
toResults: sarif.Result[],
|
||||
) {
|
||||
if (!fromResults.length) {
|
||||
throw new Error("CodeQL Compare: Source query has no results.");
|
||||
}
|
||||
|
||||
if (!toResults.length) {
|
||||
throw new Error("CodeQL Compare: Target query has no results.");
|
||||
}
|
||||
|
||||
const results = {
|
||||
from: arrayDiff(fromResults, toResults),
|
||||
to: arrayDiff(toResults, fromResults),
|
||||
};
|
||||
|
||||
if (
|
||||
fromResults.length === results.from.length &&
|
||||
toResults.length === results.to.length
|
||||
) {
|
||||
throw new Error("CodeQL Compare: No overlap between the selected queries.");
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function arrayDiff<T>(source: readonly T[], toRemove: readonly T[]): T[] {
|
||||
// Stringify the object so that we can compare hashes in the set
|
||||
const rest = new Set(toRemove.map((item) => JSON.stringify(item)));
|
||||
return source.filter((element) => !rest.has(JSON.stringify(element)));
|
||||
}
|
||||
Reference in New Issue
Block a user