Merge branch 'main' into koesie10/access-paths-query-bqrs

This commit is contained in:
Koen Vlaswinkel
2024-02-06 16:21:11 +01:00
committed by GitHub
11 changed files with 346 additions and 19 deletions

View File

@@ -1,5 +1,40 @@
import type { Result } from "sarif"; import type { Result } from "sarif";
function toCanonicalResult(result: Result): Result {
const canonicalResult = {
...result,
};
if (canonicalResult.locations) {
canonicalResult.locations = canonicalResult.locations.map((location) => {
if (location.physicalLocation?.artifactLocation?.index !== undefined) {
const canonicalLocation = {
...location,
};
canonicalLocation.physicalLocation = {
...canonicalLocation.physicalLocation,
};
canonicalLocation.physicalLocation.artifactLocation = {
...canonicalLocation.physicalLocation.artifactLocation,
};
// The index is dependent on the result of the SARIF file and usually doesn't really tell
// us anything useful, so we remove it from the comparison.
delete canonicalLocation.physicalLocation.artifactLocation.index;
return canonicalLocation;
}
// Don't create a new object if we don't need to
return location;
});
}
return canonicalResult;
}
/** /**
* Compare the alerts of two queries. Use deep equality to determine if * Compare the alerts of two queries. Use deep equality to determine if
* results have been added or removed across two invocations of a query. * results have been added or removed across two invocations of a query.
@@ -25,9 +60,12 @@ export function sarifDiff(fromResults: Result[], toResults: Result[]) {
throw new Error("CodeQL Compare: Target query has no results."); throw new Error("CodeQL Compare: Target query has no results.");
} }
const canonicalFromResults = fromResults.map(toCanonicalResult);
const canonicalToResults = toResults.map(toCanonicalResult);
const results = { const results = {
from: arrayDiff(fromResults, toResults), from: arrayDiff(canonicalFromResults, canonicalToResults),
to: arrayDiff(toResults, fromResults), to: arrayDiff(canonicalToResults, canonicalFromResults),
}; };
if ( if (

View File

@@ -44,6 +44,7 @@ type ModelsAsDataLanguageModelGeneration = {
}; };
type ModelsAsDataLanguageAccessPathSuggestions = { type ModelsAsDataLanguageAccessPathSuggestions = {
queryConstraints: (mode: Mode) => QueryConstraints;
parseResults: ( parseResults: (
// The results of a single predicate of the query. // The results of a single predicate of the query.
bqrs: DecodedBqrsChunk, bqrs: DecodedBqrsChunk,

View File

@@ -12,6 +12,7 @@ import {
rubyPath, rubyPath,
} from "./access-paths"; } from "./access-paths";
import { parseAccessPathSuggestionsResults } from "./suggestions"; import { parseAccessPathSuggestionsResults } from "./suggestions";
import { modeTag } from "../../mode-tag";
export const ruby: ModelsAsDataLanguage = { export const ruby: ModelsAsDataLanguage = {
availableModes: [Mode.Framework], availableModes: [Mode.Framework],
@@ -170,6 +171,10 @@ export const ruby: ModelsAsDataLanguage = {
parseResults: parseGenerateModelResults, parseResults: parseGenerateModelResults,
}, },
accessPathSuggestions: { accessPathSuggestions: {
queryConstraints: (mode) => ({
kind: "table",
"tags contain all": ["modeleditor", "access-paths", modeTag(mode)],
}),
parseResults: parseAccessPathSuggestionsResults, parseResults: parseAccessPathSuggestionsResults,
}, },
getArgumentOptions: (method) => { getArgumentOptions: (method) => {

View File

@@ -529,6 +529,7 @@ export class ModelEditorView extends AbstractWebview<
modelsAsDataLanguage, modelsAsDataLanguage,
this.app.logger, this.app.logger,
), ),
queryConstraints: accessPathSuggestions.queryConstraints(mode),
cliServer: this.cliServer, cliServer: this.cliServer,
queryRunner: this.queryRunner, queryRunner: this.queryRunner,
queryStorageDir: this.queryStorageDir, queryStorageDir: this.queryStorageDir,

View File

@@ -1,7 +1,7 @@
import type { CodeQLCliServer } from "../codeql-cli/cli"; import type { CodeQLCliServer } from "../codeql-cli/cli";
import type { Mode } from "./shared/mode"; import type { Mode } from "./shared/mode";
import type { QueryConstraints } from "../local-queries";
import { resolveQueriesFromPacks } from "../local-queries"; import { resolveQueriesFromPacks } from "../local-queries";
import { modeTag } from "./mode-tag";
import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders"; import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders";
import type { NotificationLogger } from "../common/logging"; import type { NotificationLogger } from "../common/logging";
import { showAndLogExceptionWithTelemetry } from "../common/logging"; import { showAndLogExceptionWithTelemetry } from "../common/logging";
@@ -22,6 +22,7 @@ type RunQueryOptions = {
parseResults: ( parseResults: (
results: DecodedBqrsChunk, results: DecodedBqrsChunk,
) => AccessPathSuggestionRow[] | Promise<AccessPathSuggestionRow[]>; ) => AccessPathSuggestionRow[] | Promise<AccessPathSuggestionRow[]>;
queryConstraints: QueryConstraints;
cliServer: CodeQLCliServer; cliServer: CodeQLCliServer;
queryRunner: QueryRunner; queryRunner: QueryRunner;
@@ -39,6 +40,7 @@ export async function runSuggestionsQuery(
mode: Mode, mode: Mode,
{ {
parseResults, parseResults,
queryConstraints,
cliServer, cliServer,
queryRunner, queryRunner,
logger, logger,
@@ -68,6 +70,7 @@ export async function runSuggestionsQuery(
cliServer, cliServer,
databaseItem.language, databaseItem.language,
mode, mode,
queryConstraints,
); );
if (!queryPath) { if (!queryPath) {
void showAndLogExceptionWithTelemetry( void showAndLogExceptionWithTelemetry(
@@ -141,6 +144,7 @@ export async function runSuggestionsQuery(
* @param cliServer The CodeQL CLI server to use. * @param cliServer The CodeQL CLI server to use.
* @param language The language of the query pack to use. * @param language The language of the query pack to use.
* @param mode The mode to resolve the query for. * @param mode The mode to resolve the query for.
* @param queryConstraints Constraints to apply to the query.
* @param additionalPackNames Additional pack names to search. * @param additionalPackNames Additional pack names to search.
* @param additionalPackPaths Additional pack paths to search. * @param additionalPackPaths Additional pack paths to search.
*/ */
@@ -148,6 +152,7 @@ async function resolveSuggestionsQuery(
cliServer: CodeQLCliServer, cliServer: CodeQLCliServer,
language: string, language: string,
mode: Mode, mode: Mode,
queryConstraints: QueryConstraints,
additionalPackNames: string[] = [], additionalPackNames: string[] = [],
additionalPackPaths: string[] = [], additionalPackPaths: string[] = [],
): Promise<string | undefined> { ): Promise<string | undefined> {
@@ -156,14 +161,7 @@ async function resolveSuggestionsQuery(
const queries = await resolveQueriesFromPacks( const queries = await resolveQueriesFromPacks(
cliServer, cliServer,
packsToSearch, packsToSearch,
{ queryConstraints,
kind: "table",
"tags contain all": [
"modeleditor",
"access-path-suggestions",
modeTag(mode),
],
},
additionalPackPaths, additionalPackPaths,
); );
if (queries.length > 1) { if (queries.length > 1) {

View File

@@ -278,6 +278,7 @@ const ModelableMethodRow = forwardRef<HTMLElement | undefined, MethodRowProps>(
<ModelInputSuggestBox <ModelInputSuggestBox
modeledMethod={modeledMethod} modeledMethod={modeledMethod}
suggestions={inputAccessPathSuggestions} suggestions={inputAccessPathSuggestions}
typePathSuggestions={outputAccessPathSuggestions ?? []}
onChange={modeledMethodChangedHandlers[index]} onChange={modeledMethodChangedHandlers[index]}
/> />
)} )}

View File

@@ -4,7 +4,6 @@ import {
calculateNewProvenance, calculateNewProvenance,
modeledMethodSupportsInput, modeledMethodSupportsInput,
} from "../../model-editor/modeled-method"; } from "../../model-editor/modeled-method";
import { ReadonlyDropdown } from "../common/ReadonlyDropdown";
import type { AccessPathOption } from "../../model-editor/suggestions"; import type { AccessPathOption } from "../../model-editor/suggestions";
import { SuggestBox } from "../common/SuggestBox"; import { SuggestBox } from "../common/SuggestBox";
import { useDebounceCallback } from "../common/useDebounceCallback"; import { useDebounceCallback } from "../common/useDebounceCallback";
@@ -14,10 +13,12 @@ import {
validateAccessPath, validateAccessPath,
} from "../../model-editor/shared/access-paths"; } from "../../model-editor/shared/access-paths";
import { ModelSuggestionIcon } from "./ModelSuggestionIcon"; import { ModelSuggestionIcon } from "./ModelSuggestionIcon";
import { ModelTypePathSuggestBox } from "./ModelTypePathSuggestBox";
type Props = { type Props = {
modeledMethod: ModeledMethod | undefined; modeledMethod: ModeledMethod | undefined;
suggestions: AccessPathOption[]; suggestions: AccessPathOption[];
typePathSuggestions: AccessPathOption[];
onChange: (modeledMethod: ModeledMethod) => void; onChange: (modeledMethod: ModeledMethod) => void;
}; };
@@ -33,6 +34,7 @@ const getDetails = (option: AccessPathOption) => option.details;
export const ModelInputSuggestBox = ({ export const ModelInputSuggestBox = ({
modeledMethod, modeledMethod,
suggestions, suggestions,
typePathSuggestions,
onChange, onChange,
}: Props) => { }: Props) => {
const [value, setValue] = useState<string | undefined>( const [value, setValue] = useState<string | undefined>(
@@ -75,7 +77,13 @@ export const ModelInputSuggestBox = ({
); );
if (modeledMethod?.type === "type") { if (modeledMethod?.type === "type") {
return <ReadonlyDropdown value={modeledMethod.path} aria-label="Path" />; return (
<ModelTypePathSuggestBox
modeledMethod={modeledMethod}
suggestions={typePathSuggestions}
onChange={onChange}
/>
);
} }
return ( return (

View File

@@ -4,7 +4,6 @@ import {
calculateNewProvenance, calculateNewProvenance,
modeledMethodSupportsOutput, modeledMethodSupportsOutput,
} from "../../model-editor/modeled-method"; } from "../../model-editor/modeled-method";
import { ReadonlyDropdown } from "../common/ReadonlyDropdown";
import type { AccessPathOption } from "../../model-editor/suggestions"; import type { AccessPathOption } from "../../model-editor/suggestions";
import { SuggestBox } from "../common/SuggestBox"; import { SuggestBox } from "../common/SuggestBox";
import { useDebounceCallback } from "../common/useDebounceCallback"; import { useDebounceCallback } from "../common/useDebounceCallback";
@@ -14,6 +13,7 @@ import {
validateAccessPath, validateAccessPath,
} from "../../model-editor/shared/access-paths"; } from "../../model-editor/shared/access-paths";
import { ModelSuggestionIcon } from "./ModelSuggestionIcon"; import { ModelSuggestionIcon } from "./ModelSuggestionIcon";
import { ModelTypeTextbox } from "./ModelTypeTextbox";
type Props = { type Props = {
modeledMethod: ModeledMethod | undefined; modeledMethod: ModeledMethod | undefined;
@@ -76,8 +76,10 @@ export const ModelOutputSuggestBox = ({
if (modeledMethod?.type === "type") { if (modeledMethod?.type === "type") {
return ( return (
<ReadonlyDropdown <ModelTypeTextbox
value={modeledMethod.relatedTypeName} modeledMethod={modeledMethod}
typeInfo="relatedTypeName"
onChange={onChange}
aria-label="Related type name" aria-label="Related type name"
/> />
); );

View File

@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import type { TypeModeledMethod } from "../../model-editor/modeled-method";
import type { AccessPathOption } from "../../model-editor/suggestions";
import { SuggestBox } from "../common/SuggestBox";
import { useDebounceCallback } from "../common/useDebounceCallback";
import type { AccessPathDiagnostic } from "../../model-editor/shared/access-paths";
import {
parseAccessPathTokens,
validateAccessPath,
} from "../../model-editor/shared/access-paths";
import { ModelSuggestionIcon } from "./ModelSuggestionIcon";
type Props = {
modeledMethod: TypeModeledMethod;
suggestions: AccessPathOption[];
onChange: (modeledMethod: TypeModeledMethod) => void;
};
const parseValueToTokens = (value: string) =>
parseAccessPathTokens(value).map((t) => t.text);
const getIcon = (option: AccessPathOption) => (
<ModelSuggestionIcon name={option.icon} />
);
const getDetails = (option: AccessPathOption) => option.details;
export const ModelTypePathSuggestBox = ({
modeledMethod,
suggestions,
onChange,
}: Props) => {
const [value, setValue] = useState<string | undefined>(modeledMethod.path);
useEffect(() => {
setValue(modeledMethod.path);
}, [modeledMethod]);
// Debounce the callback to avoid updating the model too often.
// Not doing this results in a lot of lag when typing.
useDebounceCallback(
value,
(path: string | undefined) => {
if (path === undefined) {
return;
}
onChange({
...modeledMethod,
path,
});
},
500,
);
return (
<SuggestBox<AccessPathOption, AccessPathDiagnostic>
value={value}
options={suggestions}
onChange={setValue}
parseValueToTokens={parseValueToTokens}
validateValue={validateAccessPath}
getIcon={getIcon}
getDetails={getDetails}
aria-label="Path"
/>
);
};

View File

@@ -0,0 +1,183 @@
import type { Result } from "sarif";
import { sarifDiff } from "../../../src/compare/sarif-diff";
describe("sarifDiff", () => {
const result1: Result = {
message: {
text: "result1",
},
};
const result2: Result = {
message: {
text: "result2",
},
};
const result3: Result = {
message: {
text: "result3",
},
};
it("throws an error when the source query has no results", () => {
expect(() => sarifDiff([], [result1, result2])).toThrow(
"CodeQL Compare: Source query has no results.",
);
});
it("throws an error when the target query has no results", () => {
expect(() => sarifDiff([result1, result2], [])).toThrow(
"CodeQL Compare: Target query has no results.",
);
});
it("throws an error when there is no overlap between the source and target queries", () => {
expect(() => sarifDiff([result1], [result2])).toThrow(
"CodeQL Compare: No overlap between the selected queries.",
);
});
it("return an empty comparison when the results are the same", () => {
expect(sarifDiff([result1, result2], [result1, result2])).toEqual({
from: [],
to: [],
});
});
it("returns the added and removed results", () => {
expect(sarifDiff([result1, result3], [result1, result2])).toEqual({
from: [result3],
to: [result2],
});
});
it("does not use reference equality to compare results", () => {
const result = {
message: {
text: "result1",
},
};
expect(sarifDiff([result], [result1])).toEqual({
from: [],
to: [],
});
});
it("does not take into account the index of the artifact location", () => {
const result1: Result = {
message: {
text: "result1",
},
locations: [
{
physicalLocation: {
artifactLocation: {
uri: "file:///path/to/file1",
uriBaseId: "%SRCROOT%",
index: 1,
},
},
},
],
};
const result2: Result = {
message: {
text: "result1",
},
locations: [
{
physicalLocation: {
artifactLocation: {
uri: "file:///path/to/file1",
uriBaseId: "%SRCROOT%",
index: 2,
},
},
},
],
};
expect(sarifDiff([result1], [result2])).toEqual({
from: [],
to: [],
});
});
it("takes into account the other properties of the artifact location", () => {
const result1: Result = {
message: {
text: "result1",
},
locations: [
{
physicalLocation: {
artifactLocation: {
uri: "file:///path/to/file1",
uriBaseId: "%SRCROOT%",
},
},
},
],
};
const result2: Result = {
message: {
text: "result1",
},
locations: [
{
physicalLocation: {
artifactLocation: {
uri: "file:///path/to/file2",
uriBaseId: "%SRCROOT%",
},
},
},
],
};
expect(sarifDiff([result1], [result1, result2])).toEqual({
from: [],
to: [result2],
});
});
it("does not modify the input", () => {
const result1: Result = {
message: {
text: "result1",
},
locations: [
{
physicalLocation: {
artifactLocation: {
uri: "file:///path/to/file1",
uriBaseId: "%SRCROOT%",
index: 1,
},
},
},
],
};
const result1Copy = JSON.parse(JSON.stringify(result1));
const result2: Result = {
message: {
text: "result1",
},
locations: [
{
physicalLocation: {
artifactLocation: {
uri: "file:///path/to/file1",
uriBaseId: "%SRCROOT%",
index: 2,
},
},
},
],
};
const result2Copy = JSON.parse(JSON.stringify(result2));
expect(sarifDiff([result1], [result2])).toEqual({
from: [],
to: [],
});
expect(result1).toEqual(result1Copy);
expect(result2).toEqual(result2Copy);
});
});

View File

@@ -1,3 +1,5 @@
import { load } from "js-yaml";
import { readFile } from "fs-extra";
import { createMockLogger } from "../../../__mocks__/loggerMock"; import { createMockLogger } from "../../../__mocks__/loggerMock";
import type { DatabaseItem } from "../../../../src/databases/local-databases"; import type { DatabaseItem } from "../../../../src/databases/local-databases";
import { DatabaseKind } from "../../../../src/databases/local-databases"; import { DatabaseKind } from "../../../../src/databases/local-databases";
@@ -144,15 +146,21 @@ describe("runSuggestionsQuery", () => {
.mockResolvedValueOnce(mockInputSuggestions) .mockResolvedValueOnce(mockInputSuggestions)
.mockResolvedValueOnce(mockOutputSuggestions); .mockResolvedValueOnce(mockOutputSuggestions);
const resolveQueriesInSuite = jest
.fn()
.mockResolvedValue(["/a/b/c/FrameworkModeAccessPathSuggestions.ql"]);
const options = { const options = {
parseResults, parseResults,
queryConstraints: {
kind: "table",
"tags all": ["modeleditor", "access-paths", "ruby", "foo"],
},
cliServer: mockedObject<CodeQLCliServer>({ cliServer: mockedObject<CodeQLCliServer>({
resolveQlpacks: jest.fn().mockResolvedValue({ resolveQlpacks: jest.fn().mockResolvedValue({
"my/extensions": "/a/b/c/", "my/extensions": "/a/b/c/",
}), }),
resolveQueriesInSuite: jest resolveQueriesInSuite,
.fn()
.mockResolvedValue(["/a/b/c/FrameworkModeAccessPathSuggestions.ql"]),
packPacklist: jest packPacklist: jest
.fn() .fn()
.mockResolvedValue([ .mockResolvedValue([
@@ -211,6 +219,20 @@ describe("runSuggestionsQuery", () => {
undefined, undefined,
undefined, undefined,
); );
expect(options.cliServer.resolveQueriesInSuite).toHaveBeenCalledTimes(1);
expect(
load(await readFile(resolveQueriesInSuite.mock.calls[0][0], "utf-8")),
).toEqual([
{
from: "codeql/ruby-queries",
include: {
kind: "table",
"tags all": ["modeleditor", "access-paths", "ruby", "foo"],
},
queries: ".",
},
]);
expect(options.parseResults).toHaveBeenCalledTimes(2); expect(options.parseResults).toHaveBeenCalledTimes(2);