wip: show sarif diffs in compare view

This commit is contained in:
Koen Vlaswinkel 2023-12-04 16:41:12 +01:00
Родитель d28cc6e3f2
Коммит 895d56637e
8 изменённых файлов: 429 добавлений и 83 удалений

Просмотреть файл

@ -353,14 +353,17 @@ export interface SetComparisonsMessage {
time: string;
};
};
readonly columns: readonly Column[];
readonly commonResultSetNames: string[];
readonly currentResultSetName: string;
readonly rows: QueryCompareResult | undefined;
readonly result: QueryCompareResult | undefined;
readonly message: string | undefined;
readonly databaseUri: string;
}
export type QueryCompareResult =
| RawQueryCompareResult
| InterpretedQueryCompareResult;
/**
* from is the set of rows that have changes in the "from" query.
* to is the set of rows that have changes in the "to" query.
@ -370,11 +373,29 @@ export interface SetComparisonsMessage {
* If an array element is null, that means that the element was removed
* (or added) in the comparison.
*/
export type QueryCompareResult = {
export type RawQueryCompareResult = {
type: "raw";
readonly columns: readonly Column[];
from: ResultRow[];
to: ResultRow[];
};
/**
* from is the set of rows that have changes in the "from" query.
* to is the set of rows that have changes in the "to" query.
* They are in the same order, so element 1 in "from" corresponds to
* element 1 in "to".
*
* If an array element is null, that means that the element was removed
* (or added) in the comparison.
*/
export type InterpretedQueryCompareResult = {
type: "interpreted";
sourceLocationPrefix: string;
from: sarif.Result[];
to: sarif.Result[];
};
/**
* Extract the name of the default result. Prefer returning
* 'alerts', or '#select'. Otherwise return the first in the list.

Просмотреть файл

@ -1,9 +1,13 @@
import { ViewColumn } from "vscode";
import { Uri, ViewColumn } from "vscode";
import * as sarif from "sarif";
import {
FromCompareViewMessage,
ToCompareViewMessage,
RawQueryCompareResult,
ALERTS_TABLE_NAME,
QueryCompareResult,
InterpretedQueryCompareResult,
} from "../common/interface-types";
import { Logger, showAndLogExceptionWithTelemetry } from "../common/logging";
import { extLogger } from "../common/logging/vscode";
@ -26,12 +30,31 @@ import {
import { telemetryListener } from "../common/vscode/telemetry";
import { redactableError } from "../common/errors";
import { App } from "../common/app";
import { pathExists } from "fs-extra";
import { sarifParser } from "../common/sarif-parser";
import sarifDiff from "./sarif-diff";
interface ComparePair {
from: CompletedLocalQueryInfo;
to: CompletedLocalQueryInfo;
}
type CommonResultSet =
| {
type: "raw";
commonResultSetNames: string[];
currentResultSetName: string;
fromSchemas: BQRSInfo;
toSchemas: BQRSInfo;
fromResultSetName: string;
toResultSetName: string;
}
| {
type: "interpreted";
commonResultSetNames: string[];
currentResultSetName: string;
};
export class CompareView extends AbstractWebview<
ToCompareViewMessage,
FromCompareViewMessage
@ -61,19 +84,35 @@ export class CompareView extends AbstractWebview<
panel.reveal(undefined, true);
await this.waitForPanelLoaded();
const [
commonResultSetNames,
currentResultSetName,
fromResultSet,
toResultSet,
] = await this.findCommonResultSetNames(from, to, selectedResultSetName);
const commonResultSet = await this.findCommonResultSet(
from,
to,
selectedResultSetName,
);
const { commonResultSetNames, currentResultSetName } = commonResultSet;
if (currentResultSetName) {
let rows: QueryCompareResult | undefined;
let result: QueryCompareResult | undefined;
let message: string | undefined;
try {
rows = this.compareResults(fromResultSet, toResultSet);
} catch (e) {
message = getErrorMessage(e);
if (commonResultSet.type === "interpreted") {
try {
result = await this.compareInterpretedResults(from, to);
} catch (e) {
message = getErrorMessage(e);
}
} else {
try {
result = await this.compareRawResults(
from,
to,
commonResultSet.fromSchemas,
commonResultSet.toSchemas,
commonResultSet.fromResultSetName,
commonResultSet.toResultSetName,
);
} catch (e) {
message = getErrorMessage(e);
}
}
await this.postMessage({
@ -93,10 +132,9 @@ export class CompareView extends AbstractWebview<
time: to.startTime,
},
},
columns: fromResultSet.schema.columns,
commonResultSetNames,
currentResultSetName,
rows,
result,
message,
databaseUri: to.initialInfo.databaseInfo.databaseUri,
});
@ -165,11 +203,11 @@ export class CompareView extends AbstractWebview<
}
}
private async findCommonResultSetNames(
private async findCommonResultSet(
from: CompletedLocalQueryInfo,
to: CompletedLocalQueryInfo,
selectedResultSetName: string | undefined,
): Promise<[string[], string, RawResultSet, RawResultSet]> {
): Promise<CommonResultSet> {
const fromSchemas = await this.cliServer.bqrsInfo(
from.completedQuery.query.resultsPaths.resultsPath,
);
@ -180,6 +218,22 @@ export class CompareView extends AbstractWebview<
(schema) => schema.name,
);
const toSchemaNames = toSchemas["result-sets"].map((schema) => schema.name);
if (
await pathExists(
from.completedQuery.query.resultsPaths.interpretedResultsPath,
)
) {
fromSchemaNames.push(ALERTS_TABLE_NAME);
}
if (
await pathExists(
to.completedQuery.query.resultsPaths.interpretedResultsPath,
)
) {
toSchemaNames.push(ALERTS_TABLE_NAME);
}
const commonResultSetNames = fromSchemaNames.filter((name) =>
toSchemaNames.includes(name),
);
@ -203,23 +257,28 @@ export class CompareView extends AbstractWebview<
const currentResultSetName =
selectedResultSetName || commonResultSetNames[0];
const fromResultSet = await this.getResultSet(
fromSchemas,
currentResultSetName || defaultFromResultSetName!,
from.completedQuery.query.resultsPaths.resultsPath,
);
const toResultSet = await this.getResultSet(
toSchemas,
currentResultSetName || defaultToResultSetName!,
to.completedQuery.query.resultsPaths.resultsPath,
);
return [
commonResultSetNames,
const displayCurrentResultSetName =
currentResultSetName ||
`${defaultFromResultSetName} <-> ${defaultToResultSetName}`,
fromResultSet,
toResultSet,
];
`${defaultFromResultSetName} <-> ${defaultToResultSetName}`;
if (currentResultSetName === ALERTS_TABLE_NAME) {
return {
type: "interpreted",
commonResultSetNames,
currentResultSetName: displayCurrentResultSetName,
};
} else {
return {
type: "raw",
commonResultSetNames,
currentResultSetName: displayCurrentResultSetName,
fromSchemas,
toSchemas,
fromResultSetName: currentResultSetName || defaultFromResultSetName!,
toResultSetName: currentResultSetName || defaultToResultSetName!,
};
}
}
private async changeTable(newResultSetName: string) {
@ -248,14 +307,92 @@ export class CompareView extends AbstractWebview<
return transformBqrsResultSet(schema, chunk);
}
private compareResults(
fromResults: RawResultSet,
toResults: RawResultSet,
): QueryCompareResult {
private async getInterpretedResults(
interpretedResultsPath: string,
): Promise<sarif.Log | undefined> {
if (!(await pathExists(interpretedResultsPath))) {
return undefined;
}
return await sarifParser(interpretedResultsPath);
}
private async compareRawResults(
fromQuery: CompletedLocalQueryInfo,
toQuery: CompletedLocalQueryInfo,
fromSchemas: BQRSInfo,
toSchemas: BQRSInfo,
fromResultSetName: string,
toResultSetName: string,
): Promise<RawQueryCompareResult> {
const fromResults = await this.getResultSet(
fromSchemas,
fromResultSetName,
fromQuery.completedQuery.query.resultsPaths.resultsPath,
);
const toResults = await this.getResultSet(
toSchemas,
toResultSetName,
toQuery.completedQuery.query.resultsPaths.resultsPath,
);
// Only compare columns that have the same name
return resultsDiff(fromResults, toResults);
}
private async compareInterpretedResults(
fromQuery: CompletedLocalQueryInfo,
toQuery: CompletedLocalQueryInfo,
): Promise<InterpretedQueryCompareResult> {
const fromResultSet = await this.getInterpretedResults(
fromQuery.completedQuery.query.resultsPaths.interpretedResultsPath,
);
const toResultSet = await this.getInterpretedResults(
toQuery.completedQuery.query.resultsPaths.interpretedResultsPath,
);
if (!fromResultSet || !toResultSet) {
throw new Error(
"Could not find interpreted results for one or both queries.",
);
}
const database = this.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 sourceLocationPrefix = await database.getSourceLocationPrefix(
this.cliServer,
);
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 {
type: "interpreted",
sourceLocationPrefix,
from,
to,
};
}
private async openQuery(kind: "from" | "to") {
const toOpen =
kind === "from" ? this.comparePair?.from : this.comparePair?.to;

Просмотреть файл

@ -1,5 +1,5 @@
import { RawResultSet } from "../common/bqrs-cli-types";
import { QueryCompareResult } from "../common/interface-types";
import { RawQueryCompareResult } from "../common/interface-types";
/**
* Compare the rows of two queries. Use deep equality to determine if
@ -22,7 +22,7 @@ import { QueryCompareResult } from "../common/interface-types";
export default function resultsDiff(
fromResults: RawResultSet,
toResults: RawResultSet,
): QueryCompareResult {
): RawQueryCompareResult {
if (fromResults.schema.columns.length !== toResults.schema.columns.length) {
throw new Error("CodeQL Compare: Columns do not match.");
}
@ -35,7 +35,9 @@ export default function resultsDiff(
throw new Error("CodeQL Compare: Target query has no results.");
}
const results = {
const results: RawQueryCompareResult = {
type: "raw",
columns: fromResults.schema.columns,
from: arrayDiff(fromResults.rows, toResults.rows),
to: arrayDiff(toResults.rows, fromResults.rows),
};

Просмотреть файл

@ -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 default 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)));
}

Просмотреть файл

@ -14,8 +14,7 @@ import "../results/resultsView.css";
const emptyComparison: SetComparisonsMessage = {
t: "setComparisons",
stats: {},
rows: undefined,
columns: [],
result: undefined,
commonResultSetNames: [],
currentResultSetName: "",
databaseUri: "",
@ -28,8 +27,8 @@ export function Compare(_: Record<string, never>): JSX.Element {
const message = comparison.message || "Empty comparison";
const hasRows =
comparison.rows &&
(comparison.rows.to.length || comparison.rows.from.length);
comparison.result &&
(comparison.result.to.length || comparison.result.from.length);
useEffect(() => {
const listener = (evt: MessageEvent) => {

Просмотреть файл

@ -1,14 +1,12 @@
import * as React from "react";
import { SetComparisonsMessage } from "../../common/interface-types";
import RawTableHeader from "../results/RawTableHeader";
import { className } from "../results/result-table-utils";
import { ResultRow } from "../../common/bqrs-cli-types";
import RawTableRow from "../results/RawTableRow";
import { vscode } from "../vscode-api";
import { sendTelemetry } from "../common/telemetry";
import TextButton from "../common/TextButton";
import { styled } from "styled-components";
import { RawCompareResultTable } from "./RawCompareResultTable";
import { InterpretedCompareResultTable } from "./InterpretedCompareResultTable";
interface Props {
comparison: SetComparisonsMessage;
@ -22,7 +20,7 @@ const OpenButton = styled(TextButton)`
export default function CompareTable(props: Props) {
const comparison = props.comparison;
const rows = props.comparison.rows!;
const result = props.comparison.result!;
async function openQuery(kind: "from" | "to") {
vscode.postMessage({
@ -31,24 +29,6 @@ export default function CompareTable(props: Props) {
});
}
function createRows(rows: ResultRow[], databaseUri: string) {
return (
<tbody>
{rows.map((row, rowIndex) => (
<RawTableRow
key={rowIndex}
rowIndex={rowIndex}
row={row}
databaseUri={databaseUri}
onSelected={() => {
sendTelemetry("comapre-view-result-clicked");
}}
/>
))}
</tbody>
);
}
return (
<table className="vscode-codeql__compare-body">
<thead>
@ -69,30 +49,50 @@ export default function CompareTable(props: Props) {
<td>{comparison.stats.toQuery?.time}</td>
</tr>
<tr>
<th>{rows.from.length} rows removed</th>
<th>{rows.to.length} rows added</th>
<th>{result.from.length} rows removed</th>
<th>{result.to.length} rows added</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<table className={className}>
<RawTableHeader
columns={comparison.columns}
{result.type === "raw" && (
<RawCompareResultTable
rows={result.from}
columns={result.columns}
schemaName={comparison.currentResultSetName}
preventSort={true}
databaseUri={comparison.databaseUri}
className={className}
/>
{createRows(rows.from, comparison.databaseUri)}
</table>
)}
{result.type === "interpreted" && (
<InterpretedCompareResultTable
results={result.from}
databaseUri={comparison.databaseUri}
sourceLocationPrefix={result.sourceLocationPrefix}
className={className}
/>
)}
</td>
<td>
<table className={className}>
<RawTableHeader
columns={comparison.columns}
schemaName={comparison.currentResultSetName}
preventSort={true}
/>
{createRows(rows.to, comparison.databaseUri)}
{result.type === "raw" && (
<RawCompareResultTable
rows={result.to}
columns={result.columns}
schemaName={comparison.currentResultSetName}
databaseUri={comparison.databaseUri}
className={className}
/>
)}
{result.type === "interpreted" && (
<InterpretedCompareResultTable
results={result.to}
databaseUri={comparison.databaseUri}
sourceLocationPrefix={result.sourceLocationPrefix}
className={className}
/>
)}
</table>
</td>
</tr>

Просмотреть файл

@ -0,0 +1,92 @@
import * as React from "react";
import { useCallback, useRef, useState } from "react";
import * as sarif from "sarif";
import { AlertTableResultRow } from "../results/AlertTableResultRow";
import * as Keys from "../results/result-keys";
import { useScrollIntoView } from "../results/useScrollIntoView";
import { sendTelemetry } from "../common/telemetry";
type Props = {
results: sarif.Result[];
databaseUri: string;
sourceLocationPrefix: string;
className?: string;
};
export const InterpretedCompareResultTable = ({
results,
databaseUri,
sourceLocationPrefix,
className,
}: Props) => {
const [expanded, setExpanded] = useState<Set<string>>(new Set<string>());
const [selectedItem, setSelectedItem] = useState<Keys.ResultKey | undefined>(
undefined,
);
const selectedItemRef = useRef<any>();
useScrollIntoView(selectedItem, selectedItemRef);
/**
* Given a list of `keys`, toggle the first, and if we 'open' the
* first item, open all the rest as well. This mimics vscode's file
* explorer tree view behavior.
*/
const toggle = useCallback((e: React.MouseEvent, keys: Keys.ResultKey[]) => {
const keyStrings = keys.map(Keys.keyToString);
setExpanded((previousExpanded) => {
const expanded = new Set(previousExpanded);
if (previousExpanded.has(keyStrings[0])) {
expanded.delete(keyStrings[0]);
} else {
for (const str of keyStrings) {
expanded.add(str);
}
}
if (expanded) {
sendTelemetry("local-results-alert-table-path-expanded");
}
return expanded;
});
e.stopPropagation();
e.preventDefault();
}, []);
const updateSelectionCallback = useCallback(
(resultKey: Keys.PathNode | Keys.Result | undefined) => {
setSelectedItem(resultKey);
sendTelemetry("local-results-alert-table-path-selected");
},
[],
);
return (
<table className={className}>
<thead>
<tr>
<th colSpan={2}></th>
<th className={`vscode-codeql__alert-message-cell`} colSpan={3}>
Message
</th>
</tr>
</thead>
<tbody>
{results.map((result, resultIndex) => (
<AlertTableResultRow
key={resultIndex}
result={result}
resultIndex={resultIndex}
expanded={expanded}
selectedItem={selectedItem}
selectedItemRef={selectedItemRef}
databaseUri={databaseUri}
sourceLocationPrefix={sourceLocationPrefix}
updateSelectionCallback={updateSelectionCallback}
toggleExpanded={toggle}
/>
))}
</tbody>
</table>
);
};

Просмотреть файл

@ -0,0 +1,45 @@
import * as React from "react";
import { Column, ResultRow } from "../../common/bqrs-cli-types";
import RawTableHeader from "../results/RawTableHeader";
import RawTableRow from "../results/RawTableRow";
import { sendTelemetry } from "../common/telemetry";
type Props = {
rows: ResultRow[];
columns: readonly Column[];
schemaName: string;
databaseUri: string;
className?: string;
};
export const RawCompareResultTable = ({
rows,
columns,
schemaName,
databaseUri,
className,
}: Props) => {
return (
<table className={className}>
<RawTableHeader
columns={columns}
schemaName={schemaName}
preventSort={true}
/>
<tbody>
{rows.map((row, rowIndex) => (
<RawTableRow
key={rowIndex}
rowIndex={rowIndex}
row={row}
databaseUri={databaseUri}
onSelected={() => {
sendTelemetry("comapre-view-result-clicked");
}}
/>
))}
</tbody>
</table>
);
};