diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca515aa36..3a78234da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: - run: yarn generate - run: yarn lint - run: yarn lint-deps + - run: yarn lint-license-comments - run: git diff --exit-code macos: diff --git a/package.json b/package.json index b8b2b4d5c..d7fef5381 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "eslint": "eslint .", "lint": "yarn format --list-different && yarn eslint", "lint-deps": "node ts/util/lint/linter.js", + "lint-license-comments": "ts-node ts/util/lint/license_comments.ts", "format": "prettier --write \"*.{css,js,json,md,scss,ts,tsx}\" \"./**/*.{css,js,json,md,scss,ts,tsx}\"", "transpile": "tsc", "clean-transpile": "rimraf ts/**/*.js && rimraf ts/*.js", diff --git a/ts/test-node/license_comments_test.ts b/ts/test-node/license_comments_test.ts index 8e806fb36..ea132c54f 100644 --- a/ts/test-node/license_comments_test.ts +++ b/ts/test-node/license_comments_test.ts @@ -1,115 +1,52 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +// This file is meant to be run frequently, so it doesn't check the license year. See the +// imported `license_comments` file for a job that does this, to be run on CI. + import { assert } from 'chai'; -import * as path from 'path'; -import * as fs from 'fs'; -import { promisify } from 'util'; -import * as readline from 'readline'; -import * as childProcess from 'child_process'; -import pMap from 'p-map'; -const exec = promisify(childProcess.exec); - -const EXTENSIONS_TO_CHECK = new Set([ - '.eslintignore', - '.gitattributes', - '.gitignore', - '.nvmrc', - '.prettierignore', - '.sh', - '.snyk', - '.yarnclean', - '.yml', - '.js', - '.scss', - '.ts', - '.tsx', - '.html', - '.md', - '.plist', -]); -const FILES_TO_IGNORE = new Set([ - 'ISSUE_TEMPLATE.md', - 'Mp3LameEncoder.min.js', - 'PULL_REQUEST_TEMPLATE.md', - 'WebAudioRecorderMp3.js', -]); - -const rootPath = path.join(__dirname, '..', '..'); - -async function getGitFiles(): Promise> { - return (await exec('git ls-files', { cwd: rootPath, env: {} })).stdout - .split(/\n/g) - .map(line => line.trim()) - .filter(Boolean) - .map(file => path.join(rootPath, file)); -} - -// This is not technically the real extension. -function getExtension(file: string): string { - if (file.startsWith('.')) { - return getExtension(`x.${file}`); - } - return path.extname(file); -} - -function readFirstTwoLines(file: string): Promise> { - return new Promise(resolve => { - const lines: Array = []; - - const lineReader = readline.createInterface({ - input: fs.createReadStream(file), - }); - lineReader.on('line', line => { - lines.push(line); - if (lines.length >= 2) { - lineReader.close(); - } - }); - lineReader.on('close', () => { - resolve(lines); - }); - }); -} +import { + forEachRelevantFile, + readFirstLines, +} from '../util/lint/license_comments'; describe('license comments', () => { it('includes a license comment at the top of every relevant file', async function test() { // This usually executes quickly but can be slow in some cases, such as Windows CI. this.timeout(10000); - const currentYear = new Date().getFullYear(); + await forEachRelevantFile(async file => { + const [firstLine, secondLine] = await readFirstLines(file, 2); - await pMap( - await getGitFiles(), - async (file: string) => { - if ( - FILES_TO_IGNORE.has(path.basename(file)) || - path.relative(rootPath, file).startsWith('components') - ) { - return; - } + const { groups = {} } = + firstLine.match( + /Copyright (?\d{4}-)?(?\d{4}) Signal Messenger, LLC/ + ) || []; + const { startYearWithDash, endYearString } = groups; + const endYear = Number(endYearString); - const extension = getExtension(file); - if (!EXTENSIONS_TO_CHECK.has(extension)) { - return; - } + // We added these comments in 2020. + assert.isAtLeast( + endYear, + 2020, + `First line of ${file} is missing correct license header comment` + ); - const [firstLine, secondLine] = await readFirstTwoLines(file); - - assert.match( - firstLine, - RegExp(`Copyright (?:\\d{4}-)?${currentYear} Signal Messenger, LLC`), - `First line of ${file} is missing correct license header comment` + if (startYearWithDash) { + const startYear = Number(startYearWithDash.slice(0, -1)); + assert.isBelow( + startYear, + endYear, + `Starting license year of ${file} is not below the ending year` ); - assert.include( - secondLine, - 'SPDX-License-Identifier: AGPL-3.0-only', - `Second line of ${file} is missing correct license header comment` - ); - }, - // Without this, we may run into "too many open files" errors. - { concurrency: 100 } - ); + } + + assert.include( + secondLine, + 'SPDX-License-Identifier: AGPL-3.0-only', + `Second line of ${file} is missing correct license header comment` + ); + }); }); }); diff --git a/ts/util/lint/license_comments.ts b/ts/util/lint/license_comments.ts new file mode 100644 index 000000000..2bc3a3fcc --- /dev/null +++ b/ts/util/lint/license_comments.ts @@ -0,0 +1,165 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +// This file doesn't check the format of license files, just the end year. See +// `license_comments_test.ts` for those checks, which are meant to be run more often. + +import assert from 'assert'; +import * as readline from 'readline'; +import * as path from 'path'; +import * as fs from 'fs'; +import { promisify } from 'util'; +import * as childProcess from 'child_process'; +import pMap from 'p-map'; + +const exec = promisify(childProcess.exec); + +const rootPath = path.join(__dirname, '..', '..'); + +const EXTENSIONS_TO_CHECK = new Set([ + '.eslintignore', + '.gitattributes', + '.gitignore', + '.nvmrc', + '.prettierignore', + '.sh', + '.snyk', + '.yarnclean', + '.yml', + '.js', + '.scss', + '.ts', + '.tsx', + '.html', + '.md', + '.plist', +]); +const FILES_TO_IGNORE = new Set([ + 'ISSUE_TEMPLATE.md', + 'Mp3LameEncoder.min.js', + 'PULL_REQUEST_TEMPLATE.md', + 'WebAudioRecorderMp3.js', +]); + +// This is not technically the real extension. +function getExtension(file: string): string { + if (file.startsWith('.')) { + return getExtension(`x.${file}`); + } + return path.extname(file); +} + +export async function forEachRelevantFile( + fn: (_: string) => Promise +): Promise { + const gitFiles = ( + await exec('git ls-files', { cwd: rootPath, env: {} }) + ).stdout + .split(/\n/g) + .map(line => line.trim()) + .filter(Boolean) + .map(file => path.join(rootPath, file)); + + await pMap( + gitFiles, + async (file: string) => { + if ( + FILES_TO_IGNORE.has(path.basename(file)) || + path.relative(rootPath, file).startsWith('components') + ) { + return; + } + + const extension = getExtension(file); + if (!EXTENSIONS_TO_CHECK.has(extension)) { + return; + } + + await fn(file); + }, + // Without this, we may run into "too many open files" errors. + { concurrency: 100 } + ); +} + +export function readFirstLines( + file: string, + count: number +): Promise> { + return new Promise(resolve => { + const lines: Array = []; + + const lineReader = readline.createInterface({ + input: fs.createReadStream(file), + }); + lineReader.on('line', line => { + lines.push(line); + if (lines.length >= count) { + lineReader.close(); + } + }); + lineReader.on('close', () => { + resolve(lines); + }); + }); +} + +async function getLatestCommitYearForFile(file: string): Promise { + const dateString = ( + await new Promise((resolve, reject) => { + let result = ''; + // We use the more verbose `spawn` to avoid command injection, in case the filename + // has strange characters. + const gitLog = childProcess.spawn( + 'git', + ['log', '-1', '--format=%as', file], + { + cwd: rootPath, + env: { PATH: process.env.PATH }, + } + ); + gitLog.stdout?.on('data', data => { + result += data.toString('utf8'); + }); + gitLog.on('close', code => { + if (code === 0) { + resolve(result); + } else { + reject(new Error(`git log failed with exit code ${code}`)); + } + }); + }) + ).trim(); + + const result = new Date(dateString).getFullYear(); + assert(!Number.isNaN(result), `Could not read commit year for ${file}`); + return result; +} + +async function main() { + const currentYear = new Date().getFullYear() + 1; + + await forEachRelevantFile(async file => { + const [firstLine] = await readFirstLines(file, 1); + const { groups = {} } = + firstLine.match(/(?:\d{4}-)?(?\d{4})/) || []; + const { endYearString } = groups; + const endYear = Number(endYearString); + + assert( + endYear === currentYear || + endYear === (await getLatestCommitYearForFile(file)), + `${file} has an invalid end license year` + ); + }); +} + +// Note: this check will fail if we switch to ES modules. See +// . +if (require.main === module) { + main().catch(err => { + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); + }); +}