Skip to content

Commit

Permalink
Add project linting and reporting code
Browse files Browse the repository at this point in the history
Once we have the ability to execute arbitrary rules on a known project,
we now need to go up one level and add the ability to receive a
reference to some kind of project (a shortname, like "utils", or a
directory path), resolve that reference to a Git repository, clone the
repository if necessary, execute the rules on that repository, and then
print out a report. This code implements those sequence of steps.
  • Loading branch information
mcmire committed Nov 12, 2023
1 parent a8a6d5f commit 5cedf8f
Show file tree
Hide file tree
Showing 10 changed files with 476 additions and 39 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
},
"dependencies": {
"@metamask/utils": "^8.2.0",
"chalk": "^4.1.2",
"dependency-graph": "^0.11.0",
"execa": "^5.1.1"
},
Expand Down Expand Up @@ -82,6 +83,7 @@
"prettier-plugin-packagejson": "^2.3.0",
"rimraf": "^3.0.2",
"stdio-mock": "^1.2.0",
"strip-ansi": "^6.0.0",
"ts-jest": "^28.0.7",
"ts-node": "^10.7.0",
"typedoc": "^0.23.15",
Expand Down
2 changes: 1 addition & 1 deletion src/establish-metamask-repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { establishMetaMaskRepository } from './establish-metamask-repository';
import { FakeOutputLogger } from '../tests/fake-output-logger';
import type { PrimaryExecaFunction } from '../tests/helpers';
import { fakeDateOnly, withinSandbox } from '../tests/helpers';
import { setupToolWithMockRepository } from '../tests/setup-tool-with-mock-repository';
import { setupToolWithMockRepository } from '../tests/setup-tool-with-mock-repositories';

jest.mock('execa');

Expand Down
108 changes: 108 additions & 0 deletions src/lint-project.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import execa from 'execa';
import { mockDeep } from 'jest-mock-extended';
import { MockWritable } from 'stdio-mock';

import type { MetaMaskRepository } from './establish-metamask-repository';
import type { Rule } from './execute-rules';
import { lintProject } from './lint-project';
import { OutputLogger } from './output-logger';
import { fakeDateOnly, withinSandbox } from '../tests/helpers';
import type { PrimaryExecaFunction } from '../tests/helpers';
import { setupToolWithMockRepositories } from '../tests/setup-tool-with-mock-repositories';

jest.mock('execa');

const execaMock = jest.mocked<PrimaryExecaFunction>(execa);

describe('lintProject', () => {
beforeEach(() => {
fakeDateOnly();
});

afterEach(() => {
jest.useRealTimers();
});

it('executes all of the given rules against the given project, calculating the time including and excluding linting', async () => {
jest.setSystemTime(new Date('2023-01-01T00:00:00Z'));

await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => {
const { cachedRepositoriesDirectoryPath } =
await setupToolWithMockRepositories({
execaMock,
sandboxDirectoryPath,
repositories: [
{
name: 'some-project',
},
],
});
const template = mockDeep<MetaMaskRepository>();
const rules: Rule[] = [
{
name: 'rule-1',
description: 'Description for rule 1',
dependencies: ['rule-2'],
execute: async () => {
jest.setSystemTime(new Date('2023-01-01T00:00:02Z'));
return {
passed: false,
failures: [{ message: 'Oops' }],
};
},
},
{
name: 'rule-2',
description: 'Description for rule 2',
dependencies: [],
execute: async () => {
jest.setSystemTime(new Date('2023-01-01T00:00:01Z'));
return {
passed: true,
};
},
},
];
const stdout = new MockWritable();
const stderr = new MockWritable();
const outputLogger = new OutputLogger({ stdout, stderr });

const projectLintResult = await lintProject({
projectReference: 'some-project',
template,
rules,
workingDirectoryPath: sandboxDirectoryPath,
cachedRepositoriesDirectoryPath,
outputLogger,
});

expect(projectLintResult).toStrictEqual({
projectName: 'some-project',
elapsedTimeExcludingLinting: 0,
elapsedTimeIncludingLinting: 2000,
ruleExecutionResultTree: {
children: [
expect.objectContaining({
result: {
ruleName: 'rule-2',
ruleDescription: 'Description for rule 2',
passed: true,
},
children: [
expect.objectContaining({
result: {
ruleName: 'rule-1',
ruleDescription: 'Description for rule 1',
passed: false,
failures: [{ message: 'Oops' }],
},
children: [],
}),
],
}),
],
},
});
});
});
});
71 changes: 71 additions & 0 deletions src/lint-project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { MetaMaskRepository } from './establish-metamask-repository';
import { establishMetaMaskRepository } from './establish-metamask-repository';
import type { Rule, RootRuleExecutionResultNode } from './execute-rules';
import { executeRules } from './execute-rules';
import type { OutputLogger } from './output-logger';

/**
* Data collected from linting a project.
*/
export type ProjectLintResult = {
projectName: string;
elapsedTimeExcludingLinting: number;
elapsedTimeIncludingLinting: number;
ruleExecutionResultTree: RootRuleExecutionResultNode;
};

/**
* Executes the given lint rules against a project (either a MetaMask repository
* or a local repository).
*
* @param args - The arguments to this function.
* @param args.projectReference - Either the name of a MetaMask repository,
* such as "utils", or the path to a local Git repository.
* @param args.template - The repository to which the project should be
* compared.
* @param args.rules - The set of checks that should be applied against the
* project.
* @param args.workingDirectoryPath - The directory where this tool was run.
* @param args.cachedRepositoriesDirectoryPath - The directory where MetaMask
* repositories will be (or have been) cloned.
* @param args.outputLogger - Writable streams for output messages.
*/
export async function lintProject({
projectReference,
template,
rules,
workingDirectoryPath,
cachedRepositoriesDirectoryPath,
outputLogger,
}: {
projectReference: string;
template: MetaMaskRepository;
rules: readonly Rule[];
workingDirectoryPath: string;
cachedRepositoriesDirectoryPath: string;
outputLogger: OutputLogger;
}): Promise<ProjectLintResult> {
const startDate = new Date();
const repository = await establishMetaMaskRepository({
repositoryReference: projectReference,
workingDirectoryPath,
cachedRepositoriesDirectoryPath,
outputLogger,
});
const endDateExcludingLinting = new Date();
const ruleExecutionResultTree = await executeRules({
rules,
project: repository,
template,
});
const endDateIncludingLinting = new Date();

return {
projectName: repository.shortname,
elapsedTimeExcludingLinting:
endDateExcludingLinting.getTime() - startDate.getTime(),
elapsedTimeIncludingLinting:
endDateIncludingLinting.getTime() - startDate.getTime(),
ruleExecutionResultTree,
};
}
8 changes: 7 additions & 1 deletion src/misc-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { writeFile } from '@metamask/utils/node';
import fs from 'fs';
import path from 'path';

import { getEntryStats, indent } from './misc-utils';
import { getEntryStats, indent, repeat } from './misc-utils';
import { withinSandbox } from '../tests/helpers';

describe('getEntryStats', () => {
Expand Down Expand Up @@ -63,6 +63,12 @@ describe('getEntryStats', () => {
});
});

describe('repeat', () => {
it('returns a string of the given character that is of the given length', () => {
expect(repeat('-', 10)).toBe('----------');
});
});

describe('indent', () => {
it('returns the given string with the given number of spaces (times 2) before it', () => {
expect(indent('hello', 4)).toBe(' hello');
Expand Down
20 changes: 16 additions & 4 deletions src/misc-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ export async function getEntryStats(
}
}

/**
* Builds a string by repeating the same character some number of times.
*
* @param character - The character.
* @param length - The number of times to repeat the character.
* @returns The resulting string.
*/
export function repeat(character: string, length: number): string {
let string = '';
for (let i = 0; i < length; i++) {
string += character;
}
return string;
}

/**
* Applies indentation to the given text.
*
Expand All @@ -34,9 +49,6 @@ export async function getEntryStats(
* @returns The indented string.
*/
export function indent(text: string, level: number) {
let indentation = '';
for (let i = 0; i < level * 2; i++) {
indentation += ' ';
}
const indentation = repeat(' ', level * 2);
return `${indentation}${text}`;
}
82 changes: 82 additions & 0 deletions src/report-project-lint-result.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { MockWritable } from 'stdio-mock';
import stripAnsi from 'strip-ansi';

import type { ProjectLintResult } from './lint-project';
import { OutputLogger } from './output-logger';
import { reportProjectLintResult } from './report-project-lint-result';

describe('reportProjectLintResult', () => {
it('outputs the rules executed against a project, in the same hierarchy as they exist, and whether they passed or failed', () => {
const projectLintResult: ProjectLintResult = {
projectName: 'some-project',
elapsedTimeIncludingLinting: 30,
elapsedTimeExcludingLinting: 0,
ruleExecutionResultTree: {
children: [
{
result: {
ruleName: 'rule-1',
ruleDescription: 'Description for rule 1',
passed: true,
},
elapsedTimeExcludingChildren: 0,
elapsedTimeIncludingChildren: 0,
children: [
{
result: {
ruleName: 'rule-2',
ruleDescription: 'Description for rule 2',
passed: false,
failures: [
{ message: 'Failure 1' },
{ message: 'Failure 2' },
],
},
elapsedTimeExcludingChildren: 0,
elapsedTimeIncludingChildren: 0,
children: [],
},
],
},
{
result: {
ruleName: 'rule-3',
ruleDescription: 'Description for rule 3',
passed: true,
},
elapsedTimeExcludingChildren: 0,
elapsedTimeIncludingChildren: 0,
children: [],
},
],
},
};
const stdout = new MockWritable();
const stderr = new MockWritable();
const outputLogger = new OutputLogger({ stdout, stderr });

reportProjectLintResult({
projectLintResult,
outputLogger,
});

const output = stdout.data().map(stripAnsi).join('');

expect(output).toBe(
`
some-project
------------
Linted project in 30 ms.
- Description for rule 1 ✅
- Description for rule 2 ❌
- Failure 1
- Failure 2
- Description for rule 3 ✅
`,
);
});
});
Loading

0 comments on commit 5cedf8f

Please sign in to comment.