From db55e9cd42e0a35c6b60d48a91f610a19ad83d66 Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Mon, 25 Sep 2023 15:12:54 +0200 Subject: [PATCH] Generate schema for extension pack metadata After the upgrade to the correct types for js-yaml, the return type of `load` is correctly typed as `unknown`. This means that we can't use the return value directly, but need to validate it first. This adds such validation by generating a JSON schema for a newly created typed. The JSON schema generation is very similar to how we do it in https://github.com/github/codeql-variant-analysis-action. --- .github/workflows/main.yml | 36 +++++++++++++++ extensions/ql-vscode/package.json | 2 + .../ql-vscode/scripts/generate-schemas.ts | 45 +++++++++++++++++++ .../extension-pack-metadata.schema.json | 42 +++++++++++++++++ .../model-editor/extension-pack-metadata.ts | 6 +++ .../src/model-editor/extension-pack-picker.ts | 27 +++++++++++ .../extension-pack-picker.test.ts | 37 +++++++++++++++ 7 files changed, 195 insertions(+) create mode 100644 extensions/ql-vscode/scripts/generate-schemas.ts create mode 100644 extensions/ql-vscode/src/model-editor/extension-pack-metadata.schema.json create mode 100644 extensions/ql-vscode/src/model-editor/extension-pack-metadata.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 98a1b4913..7246f429a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -99,6 +99,42 @@ jobs: run: | npm run find-deadcode + generated: + name: Check generated code + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - uses: actions/setup-node@v3 + with: + node-version: '16.17.1' + cache: 'npm' + cache-dependency-path: extensions/ql-vscode/package-lock.json + + - name: Install dependencies + working-directory: extensions/ql-vscode + run: | + npm ci + shell: bash + + - name: Check that repo is clean + run: | + git diff --exit-code + git diff --exit-code --cached + + - name: Generate code + working-directory: extensions/ql-vscode + run: | + npm run generate + + - name: Check for changes + run: | + git diff --exit-code + git diff --exit-code --cached + unit-test: name: Unit Test runs-on: ${{ matrix.os }} diff --git a/extensions/ql-vscode/package.json b/extensions/ql-vscode/package.json index 9f9b8b58c..ce6d2b05c 100644 --- a/extensions/ql-vscode/package.json +++ b/extensions/ql-vscode/package.json @@ -1842,6 +1842,8 @@ "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "lint:scenarios": "ts-node scripts/lint-scenarios.ts", + "generate": "npm-run-all -p generate:*", + "generate:schemas": "ts-node scripts/generate-schemas.ts", "check-types": "find . -type f -name \"tsconfig.json\" -not -path \"./node_modules/*\" | sed -r 's|/[^/]+$||' | sort | uniq | xargs -I {} sh -c \"echo Checking types in {} && cd {} && npx tsc --noEmit\"", "postinstall": "patch-package", "prepare": "cd ../.. && husky install" diff --git a/extensions/ql-vscode/scripts/generate-schemas.ts b/extensions/ql-vscode/scripts/generate-schemas.ts new file mode 100644 index 000000000..26e48bd8f --- /dev/null +++ b/extensions/ql-vscode/scripts/generate-schemas.ts @@ -0,0 +1,45 @@ +import { createGenerator } from "ts-json-schema-generator"; +import { join, resolve } from "path"; +import { outputJSON } from "fs-extra"; + +const extensionDirectory = resolve(__dirname, ".."); + +const schemas = [ + { + path: join( + extensionDirectory, + "src", + "model-editor", + "extension-pack-metadata.ts", + ), + type: "ExtensionPackMetadata", + schemaPath: join( + extensionDirectory, + "src", + "model-editor", + "extension-pack-metadata.schema.json", + ), + }, +]; + +async function generateSchemas() { + for (const schemaDefinition of schemas) { + const schema = createGenerator({ + path: schemaDefinition.path, + tsconfig: resolve(extensionDirectory, "tsconfig.json"), + type: schemaDefinition.type, + skipTypeCheck: true, + topRef: true, + additionalProperties: true, + }).createSchema(schemaDefinition.type); + + await outputJSON(schemaDefinition.schemaPath, schema, { + spaces: 2, + }); + } +} + +generateSchemas().catch((e: unknown) => { + console.error(e); + process.exit(2); +}); diff --git a/extensions/ql-vscode/src/model-editor/extension-pack-metadata.schema.json b/extensions/ql-vscode/src/model-editor/extension-pack-metadata.schema.json new file mode 100644 index 000000000..0a284f6a3 --- /dev/null +++ b/extensions/ql-vscode/src/model-editor/extension-pack-metadata.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/ExtensionPackMetadata", + "definitions": { + "ExtensionPackMetadata": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "dataExtensions": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "extensionTargets": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "name", + "version", + "dataExtensions", + "extensionTargets" + ] + } + } +} diff --git a/extensions/ql-vscode/src/model-editor/extension-pack-metadata.ts b/extensions/ql-vscode/src/model-editor/extension-pack-metadata.ts new file mode 100644 index 000000000..c9737848f --- /dev/null +++ b/extensions/ql-vscode/src/model-editor/extension-pack-metadata.ts @@ -0,0 +1,6 @@ +export type ExtensionPackMetadata = { + name: string; + version: string; + dataExtensions: string | string[]; + extensionTargets: Record; +}; diff --git a/extensions/ql-vscode/src/model-editor/extension-pack-picker.ts b/extensions/ql-vscode/src/model-editor/extension-pack-picker.ts index bb9205fc1..a02251723 100644 --- a/extensions/ql-vscode/src/model-editor/extension-pack-picker.ts +++ b/extensions/ql-vscode/src/model-editor/extension-pack-picker.ts @@ -2,6 +2,7 @@ import { join } from "path"; import { outputFile, pathExists, readFile } from "fs-extra"; import { dump as dumpYaml, load as loadYaml } from "js-yaml"; import { Uri } from "vscode"; +import Ajv from "ajv"; import { CodeQLCliServer } from "../codeql-cli/cli"; import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders"; import { ProgressCallback } from "../common/vscode/progress"; @@ -18,6 +19,12 @@ import { } from "./extension-pack-name"; import { autoPickExtensionsDirectory } from "./extensions-workspace-folder"; +import { ExtensionPackMetadata } from "./extension-pack-metadata"; +import * as extensionPackMetadataSchemaJson from "./extension-pack-metadata.schema.json"; + +const ajv = new Ajv({ allErrors: true }); +const extensionPackValidate = ajv.compile(extensionPackMetadataSchemaJson); + export async function pickExtensionPack( cliServer: Pick, databaseItem: Pick, @@ -170,6 +177,22 @@ async function writeExtensionPack( return extensionPack; } +function validateExtensionPack( + extensionPack: unknown, +): extensionPack is ExtensionPackMetadata { + extensionPackValidate(extensionPack); + + if (extensionPackValidate.errors) { + throw new Error( + `Invalid extension pack YAML: ${extensionPackValidate.errors + .map((error) => `${error.instancePath} ${error.message}`) + .join(", ")}`, + ); + } + + return true; +} + async function readExtensionPack( path: string, language: string, @@ -188,6 +211,10 @@ async function readExtensionPack( throw new Error(`Could not parse ${qlpackPath}`); } + if (!validateExtensionPack(qlpack)) { + throw new Error(`Could not validate ${qlpackPath}`); + } + const dataExtensionValue = qlpack.dataExtensions; if ( !( diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/extension-pack-picker.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/extension-pack-picker.test.ts index fb8da4c7e..4ae3415ba 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/extension-pack-picker.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/extension-pack-picker.test.ts @@ -363,6 +363,43 @@ describe("pickExtensionPack", () => { expect(cliServer.resolveQlpacks).toHaveBeenCalled(); }); + it("shows an error when the pack YAML does not contain name", async () => { + const tmpDir = await dir({ + unsafeCleanup: true, + }); + + const cliServer = mockCliServer({ + "github/vscode-codeql-java": [tmpDir.path], + }); + + await outputFile( + join(tmpDir.path, "codeql-pack.yml"), + dumpYaml({ + version: "0.0.0", + library: true, + extensionTargets: { + "codeql/java-all": "*", + }, + dataExtensions: ["models/**/*.yml"], + }), + ); + + expect( + await pickExtensionPack( + cliServer, + databaseItem, + logger, + progress, + maxStep, + ), + ).toEqual(undefined); + expect(logger.showErrorMessage).toHaveBeenCalledTimes(1); + expect(logger.showErrorMessage).toHaveBeenCalledWith( + "Could not read extension pack github/vscode-codeql-java", + ); + expect(cliServer.resolveQlpacks).toHaveBeenCalled(); + }); + it("shows an error when the pack YAML does not contain dataExtensions", async () => { const tmpDir = await dir({ unsafeCleanup: true,