"lint" command for flipper-pkg tool

Summary:
Implemented json schema for flipper plugin package.json and used it for validation in "flipper-pkg lint" command.

Nice thing about json schema is that it not only allows to validate json, but also can be referenced using "$schema" property in json so IDEs like VSCode can find it and use for code completion, validation and to show properties documentation. I'm going to deploy the schema as a part of documentation website so it can be referenced as https://fbflipper.com/schemas/plugin-package/v2.json.

Also the "$schema" field can be used instead of "specVersion" to determine the specification according to which the plugin is defined. E.g., if specification version 3 would be created, it will be described in schema https://fbflipper.com/schemas/plugin-package/v3.json, etc.

Reviewed By: passy

Differential Revision: D21228294

fbshipit-source-id: f21351e584ef936a7d6b314436448489691f83a6
This commit is contained in:
Anton Nikolaev
2020-04-27 17:31:39 -07:00
committed by Facebook GitHub Bot
parent 01f8d80402
commit 21c574ac80
24 changed files with 1062 additions and 84 deletions

View File

@@ -1,12 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
test('tests are working', () => {
expect(true).toBeTruthy();
});

View File

@@ -0,0 +1,151 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import runLint from '../utils/runLint';
import fs from 'fs-extra';
const validPackageJson = {
$schema: 'https://fbflipper.com/schemas/plugin-package/v2.json',
name: 'flipper-plugin-network',
id: 'Network',
flipperBundlerEntry: 'index.tsx',
main: 'dist/index.js',
title: 'Network',
description:
'Use the Network inspector to inspect outgoing network traffic in your apps.',
icon: 'internet',
version: '1.0.0',
license: 'MIT',
keywords: ['network', 'flipper-plugin'],
bugs: {
email: 'oncall+flipper@xmail.facebook.com',
url: 'https://fb.workplace.com/groups/flippersupport/',
},
};
beforeEach(() => {
jest.mock('fs-extra', () => jest.fn());
fs.pathExists = jest.fn().mockResolvedValue(true);
fs.pathExistsSync = jest.fn().mockReturnValue(true);
fs.lstatSync = jest.fn().mockReturnValue({
isFile: function () {
return true;
},
});
});
test('valid package json', async () => {
const json = JSON.stringify(validPackageJson);
fs.readFile = jest.fn().mockResolvedValue(new Buffer(json));
const result = await runLint('dir');
expect(result).toBe(null);
});
test('$schema field is required', async () => {
const testPackageJson = Object.assign({}, validPackageJson);
delete testPackageJson.$schema;
const json = JSON.stringify(testPackageJson);
fs.readFile = jest.fn().mockResolvedValue(new Buffer(json));
const result = await runLint('dir');
expect(result).toMatchInlineSnapshot(`
Array [
". should have required property \\"$schema\\" pointing to a supported schema URI, e.g.:
{
\\"$schema\\": \\"https://fbflipper.com/schemas/plugin-package/v2.json\\",
\\"name\\": \\"flipper-plugin-example\\",
...
}",
]
`);
});
test('supported schema is required', async () => {
const testPackageJson = Object.assign({}, validPackageJson);
testPackageJson.$schema =
'https://fbflipper.com/schemas/plugin-package/v1.json';
const json = JSON.stringify(testPackageJson);
fs.readFile = jest.fn().mockResolvedValue(new Buffer(json));
const result = await runLint('dir');
expect(result).toMatchInlineSnapshot(`
Array [
".$schema should point to a supported schema. Currently supported schemas:
- https://fbflipper.com/schemas/plugin-package/v2.json",
]
`);
});
test('name is required', async () => {
const testPackageJson = Object.assign({}, validPackageJson);
delete testPackageJson.name;
const json = JSON.stringify(testPackageJson);
fs.readFile = jest.fn().mockResolvedValue(new Buffer(json));
const result = await runLint('dir');
expect(result).toMatchInlineSnapshot(`
Array [
". should have required property 'name'",
]
`);
});
test('name must start with "flipper-plugin-"', async () => {
const testPackageJson = Object.assign({}, validPackageJson);
testPackageJson.name = 'test-plugin';
const json = JSON.stringify(testPackageJson);
fs.readFile = jest.fn().mockResolvedValue(new Buffer(json));
const result = await runLint('dir');
expect(result).toMatchInlineSnapshot(`
Array [
"/name should start with \\"flipper-plugin-\\", e.g. \\"flipper-plugin-example\\"",
]
`);
});
test('keywords must contain "flipper-plugin"', async () => {
const testPackageJson = Object.assign({}, validPackageJson);
testPackageJson.keywords = ['flipper', 'network'];
const json = JSON.stringify(testPackageJson);
fs.readFile = jest.fn().mockResolvedValue(new Buffer(json));
const result = await runLint('dir');
expect(result).toMatchInlineSnapshot(`
Array [
"/keywords should contain keyword \\"flipper-plugin\\"",
]
`);
});
test('flippeBundlerEntry must point to an existing file', async () => {
const testPackageJson = Object.assign({}, validPackageJson);
testPackageJson.flipperBundlerEntry = 'unexisting/file';
fs.pathExistsSync = jest
.fn()
.mockImplementation((path) => !path.includes('unexisting/file'));
const json = JSON.stringify(testPackageJson);
fs.readFile = jest.fn().mockResolvedValue(new Buffer(json));
const result = await runLint('dir');
expect(result).toMatchInlineSnapshot(`
Array [
"/flipperBundlerEntry should point to a valid file",
]
`);
});
test('multiple validation errors reported', async () => {
const testPackageJson = Object.assign({}, validPackageJson);
testPackageJson.keywords = ['flipper'];
delete testPackageJson.flipperBundlerEntry;
const json = JSON.stringify(testPackageJson);
fs.readFile = jest.fn().mockResolvedValue(new Buffer(json));
const result = await runLint('dir');
expect(result).toMatchInlineSnapshot(`
Array [
". should have required property 'flipperBundlerEntry'",
"/keywords should contain keyword \\"flipper-plugin\\"",
]
`);
});

View File

@@ -16,7 +16,7 @@ import {runBuild, getPluginDetails} from 'flipper-pkg-lib';
export default class Bundle extends Command {
public static description = 'transpiles and bundles plugin';
public static examples = [`$ flipper-pkg bundle optional/path/to/directory`];
public static examples = [`$ flipper-pkg bundle path/to/plugin`];
public static args: args.IArg[] = [
{

View File

@@ -0,0 +1,48 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Command} from '@oclif/command';
import {args} from '@oclif/parser';
import path from 'path';
import runLint from '../utils/runLint';
export default class Lint extends Command {
public static description = 'validates a plugin package directory';
public static examples = [`$ flipper-pkg lint path/to/plugin`];
public static args: args.IArg[] = [
{
name: 'directory',
required: false,
default: '.',
description:
'Path to plugin package directory for linting. Defaults to the current working directory.',
},
];
public async run() {
const {args} = this.parse(Lint);
const inputDirectory: string = path.resolve(process.cwd(), args.directory);
try {
console.log(`⚙️ Validating ${inputDirectory}`);
const errors = await runLint(inputDirectory);
if (errors) {
this.error(
`Plugin package definition is invalid. See https://fbflipper.com/docs/extending/js-setup.html#plugin-definition for details.\n${errors.join(
'\n',
)}`,
);
}
} catch (error) {
this.error(error);
}
console.log('✅ Plugin package definition is valid');
}
}

View File

@@ -0,0 +1,96 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import fs from 'fs-extra';
import path from 'path';
import Ajv from 'ajv';
import filePathExists from './validation/filePathExists';
const pluginPackageJsonSchemaUrl =
'https://fbflipper.com/schemas/plugin-package/v2.json';
const packageJsonSchemaUrl =
'https://schemastore.azurewebsites.net/schemas/json/package.json';
const schemasDir = path.resolve(__dirname, '..', '..', 'schemas');
const packageJsonSchemaPath = path.join(schemasDir, 'package.json');
const pluginPackageJsonSchemaPath = path.join(
schemasDir,
'plugin-package-v2.json',
);
export default async function runLint(
inputDirectory: string,
): Promise<null | string[]> {
const packageJsonPath = path.join(inputDirectory, 'package.json');
if (!(await fs.pathExists(packageJsonPath))) {
return [
`package.json not found in plugin source directory ${inputDirectory}.`,
];
}
const packageJsonString = (await fs.readFile(packageJsonPath)).toString();
const packageJson = JSON.parse(packageJsonString);
if (!packageJson.$schema) {
return [
[
`. should have required property "$schema" pointing to a supported schema URI, e.g.:`,
`{`,
` "$schema": "${pluginPackageJsonSchemaUrl}",`,
` "name": "flipper-plugin-example",`,
` ...`,
`}`,
].join('\n'),
];
}
if (packageJson.$schema != pluginPackageJsonSchemaUrl) {
return [
[
`.$schema should point to a supported schema. Currently supported schemas:`,
`- ${pluginPackageJsonSchemaUrl}`,
].join('\n'),
];
}
const packageJsonSchema = await fs.readJson(packageJsonSchemaPath);
const pluginPackageJsonSchema = await fs.readJson(
pluginPackageJsonSchemaPath,
);
const ajv = new Ajv({
allErrors: true,
loadSchema,
schemaId: 'auto',
meta: true,
jsonPointers: true,
});
require('ajv-errors')(ajv);
ajv
.addMetaSchema(require('ajv/lib/refs/json-schema-draft-04.json'))
.addSchema(packageJsonSchema, packageJsonSchemaUrl)
.addSchema(pluginPackageJsonSchema, pluginPackageJsonSchemaUrl)
.addKeyword('filePathExists', filePathExists(inputDirectory));
const validate = await ajv.compileAsync(pluginPackageJsonSchema);
const valid = await validate(packageJson);
if (!valid) {
return validate.errors
? validate.errors.map(
(error) =>
`${error.dataPath === '' ? '.' : error.dataPath} ${
error.message || 'unspecified error'
}`,
)
: [];
}
return null;
}
async function loadSchema(_uri: string) {
return false;
}

View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import ajv from 'ajv';
import path from 'path';
import fs from 'fs-extra';
export default function (inputDirectory: string) {
const filePathExists: ajv.KeywordDefinition = {
errors: true,
compile: function validatePathExists(schema: any) {
function filePathExistsValidator(value: any, dataPath: any) {
const it = filePathExistsValidator as ajv.SchemaValidateFunction;
if (!schema) {
return true;
}
it.errors = [];
const tpl = {
keyword: 'filePathExists',
dataPath,
schemaPath: '',
params: [],
};
const fullPath = path.resolve(inputDirectory, value);
if (!fs.pathExistsSync(fullPath) || !fs.lstatSync(fullPath).isFile()) {
it.errors.push({
...tpl,
message: `should point to a valid file`,
});
}
return it.errors.length === 0;
}
return filePathExistsValidator;
},
};
return filePathExists;
}