Merge pull request #3113 from github/koesie10/compare-interpreted

Add SARIF result comparison to compare view
This commit is contained in:
Koen Vlaswinkel
2023-12-15 10:56:04 +01:00
committed by GitHub
6 changed files with 275 additions and 64 deletions

View File

@@ -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

View File

@@ -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[];

View File

@@ -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") {

View 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,
};
}

View File

@@ -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}`,

View 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)));
}