Skip to content

Commit 116ff74

Browse files
EvanHahn-Signaljosh-signal
authored andcommitted
Update license tests in preparation for new year
1 parent 1225d45 commit 116ff74

File tree

4 files changed

+205
-101
lines changed

4 files changed

+205
-101
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ jobs:
3030
- run: yarn generate
3131
- run: yarn lint
3232
- run: yarn lint-deps
33+
- run: yarn lint-license-comments
3334
- run: git diff --exit-code
3435

3536
macos:

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"eslint": "eslint .",
3535
"lint": "yarn format --list-different && yarn eslint",
3636
"lint-deps": "node ts/util/lint/linter.js",
37+
"lint-license-comments": "ts-node ts/util/lint/license_comments.ts",
3738
"format": "prettier --write \"*.{css,js,json,md,scss,ts,tsx}\" \"./**/*.{css,js,json,md,scss,ts,tsx}\"",
3839
"transpile": "tsc",
3940
"clean-transpile": "rimraf ts/**/*.js && rimraf ts/*.js",
Lines changed: 38 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,115 +1,52 @@
11
// Copyright 2020 Signal Messenger, LLC
22
// SPDX-License-Identifier: AGPL-3.0-only
33

4-
import { assert } from 'chai';
5-
import * as path from 'path';
6-
import * as fs from 'fs';
7-
import { promisify } from 'util';
8-
import * as readline from 'readline';
9-
import * as childProcess from 'child_process';
10-
import pMap from 'p-map';
11-
12-
const exec = promisify(childProcess.exec);
13-
14-
const EXTENSIONS_TO_CHECK = new Set([
15-
'.eslintignore',
16-
'.gitattributes',
17-
'.gitignore',
18-
'.nvmrc',
19-
'.prettierignore',
20-
'.sh',
21-
'.snyk',
22-
'.yarnclean',
23-
'.yml',
24-
'.js',
25-
'.scss',
26-
'.ts',
27-
'.tsx',
28-
'.html',
29-
'.md',
30-
'.plist',
31-
]);
32-
const FILES_TO_IGNORE = new Set([
33-
'ISSUE_TEMPLATE.md',
34-
'Mp3LameEncoder.min.js',
35-
'PULL_REQUEST_TEMPLATE.md',
36-
'WebAudioRecorderMp3.js',
37-
]);
38-
39-
const rootPath = path.join(__dirname, '..', '..');
40-
41-
async function getGitFiles(): Promise<Array<string>> {
42-
return (await exec('git ls-files', { cwd: rootPath, env: {} })).stdout
43-
.split(/\n/g)
44-
.map(line => line.trim())
45-
.filter(Boolean)
46-
.map(file => path.join(rootPath, file));
47-
}
4+
// This file is meant to be run frequently, so it doesn't check the license year. See the
5+
// imported `license_comments` file for a job that does this, to be run on CI.
486

49-
// This is not technically the real extension.
50-
function getExtension(file: string): string {
51-
if (file.startsWith('.')) {
52-
return getExtension(`x.${file}`);
53-
}
54-
return path.extname(file);
55-
}
56-
57-
function readFirstTwoLines(file: string): Promise<Array<string>> {
58-
return new Promise(resolve => {
59-
const lines: Array<string> = [];
7+
import { assert } from 'chai';
608

61-
const lineReader = readline.createInterface({
62-
input: fs.createReadStream(file),
63-
});
64-
lineReader.on('line', line => {
65-
lines.push(line);
66-
if (lines.length >= 2) {
67-
lineReader.close();
68-
}
69-
});
70-
lineReader.on('close', () => {
71-
resolve(lines);
72-
});
73-
});
74-
}
9+
import {
10+
forEachRelevantFile,
11+
readFirstLines,
12+
} from '../util/lint/license_comments';
7513

7614
describe('license comments', () => {
7715
it('includes a license comment at the top of every relevant file', async function test() {
7816
// This usually executes quickly but can be slow in some cases, such as Windows CI.
7917
this.timeout(10000);
8018

81-
const currentYear = new Date().getFullYear();
82-
83-
await pMap(
84-
await getGitFiles(),
85-
async (file: string) => {
86-
if (
87-
FILES_TO_IGNORE.has(path.basename(file)) ||
88-
path.relative(rootPath, file).startsWith('components')
89-
) {
90-
return;
91-
}
92-
93-
const extension = getExtension(file);
94-
if (!EXTENSIONS_TO_CHECK.has(extension)) {
95-
return;
96-
}
97-
98-
const [firstLine, secondLine] = await readFirstTwoLines(file);
99-
100-
assert.match(
101-
firstLine,
102-
RegExp(`Copyright (?:\\d{4}-)?${currentYear} Signal Messenger, LLC`),
103-
`First line of ${file} is missing correct license header comment`
104-
);
105-
assert.include(
106-
secondLine,
107-
'SPDX-License-Identifier: AGPL-3.0-only',
108-
`Second line of ${file} is missing correct license header comment`
19+
await forEachRelevantFile(async file => {
20+
const [firstLine, secondLine] = await readFirstLines(file, 2);
21+
22+
const { groups = {} } =
23+
firstLine.match(
24+
/Copyright (?<startYearWithDash>\d{4}-)?(?<endYearString>\d{4}) Signal Messenger, LLC/
25+
) || [];
26+
const { startYearWithDash, endYearString } = groups;
27+
const endYear = Number(endYearString);
28+
29+
// We added these comments in 2020.
30+
assert.isAtLeast(
31+
endYear,
32+
2020,
33+
`First line of ${file} is missing correct license header comment`
34+
);
35+
36+
if (startYearWithDash) {
37+
const startYear = Number(startYearWithDash.slice(0, -1));
38+
assert.isBelow(
39+
startYear,
40+
endYear,
41+
`Starting license year of ${file} is not below the ending year`
10942
);
110-
},
111-
// Without this, we may run into "too many open files" errors.
112-
{ concurrency: 100 }
113-
);
43+
}
44+
45+
assert.include(
46+
secondLine,
47+
'SPDX-License-Identifier: AGPL-3.0-only',
48+
`Second line of ${file} is missing correct license header comment`
49+
);
50+
});
11451
});
11552
});

ts/util/lint/license_comments.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// Copyright 2020 Signal Messenger, LLC
2+
// SPDX-License-Identifier: AGPL-3.0-only
3+
4+
// This file doesn't check the format of license files, just the end year. See
5+
// `license_comments_test.ts` for those checks, which are meant to be run more often.
6+
7+
import assert from 'assert';
8+
import * as readline from 'readline';
9+
import * as path from 'path';
10+
import * as fs from 'fs';
11+
import { promisify } from 'util';
12+
import * as childProcess from 'child_process';
13+
import pMap from 'p-map';
14+
15+
const exec = promisify(childProcess.exec);
16+
17+
const rootPath = path.join(__dirname, '..', '..');
18+
19+
const EXTENSIONS_TO_CHECK = new Set([
20+
'.eslintignore',
21+
'.gitattributes',
22+
'.gitignore',
23+
'.nvmrc',
24+
'.prettierignore',
25+
'.sh',
26+
'.snyk',
27+
'.yarnclean',
28+
'.yml',
29+
'.js',
30+
'.scss',
31+
'.ts',
32+
'.tsx',
33+
'.html',
34+
'.md',
35+
'.plist',
36+
]);
37+
const FILES_TO_IGNORE = new Set([
38+
'ISSUE_TEMPLATE.md',
39+
'Mp3LameEncoder.min.js',
40+
'PULL_REQUEST_TEMPLATE.md',
41+
'WebAudioRecorderMp3.js',
42+
]);
43+
44+
// This is not technically the real extension.
45+
function getExtension(file: string): string {
46+
if (file.startsWith('.')) {
47+
return getExtension(`x.${file}`);
48+
}
49+
return path.extname(file);
50+
}
51+
52+
export async function forEachRelevantFile(
53+
fn: (_: string) => Promise<unknown>
54+
): Promise<void> {
55+
const gitFiles = (
56+
await exec('git ls-files', { cwd: rootPath, env: {} })
57+
).stdout
58+
.split(/\n/g)
59+
.map(line => line.trim())
60+
.filter(Boolean)
61+
.map(file => path.join(rootPath, file));
62+
63+
await pMap(
64+
gitFiles,
65+
async (file: string) => {
66+
if (
67+
FILES_TO_IGNORE.has(path.basename(file)) ||
68+
path.relative(rootPath, file).startsWith('components')
69+
) {
70+
return;
71+
}
72+
73+
const extension = getExtension(file);
74+
if (!EXTENSIONS_TO_CHECK.has(extension)) {
75+
return;
76+
}
77+
78+
await fn(file);
79+
},
80+
// Without this, we may run into "too many open files" errors.
81+
{ concurrency: 100 }
82+
);
83+
}
84+
85+
export function readFirstLines(
86+
file: string,
87+
count: number
88+
): Promise<Array<string>> {
89+
return new Promise(resolve => {
90+
const lines: Array<string> = [];
91+
92+
const lineReader = readline.createInterface({
93+
input: fs.createReadStream(file),
94+
});
95+
lineReader.on('line', line => {
96+
lines.push(line);
97+
if (lines.length >= count) {
98+
lineReader.close();
99+
}
100+
});
101+
lineReader.on('close', () => {
102+
resolve(lines);
103+
});
104+
});
105+
}
106+
107+
async function getLatestCommitYearForFile(file: string): Promise<number> {
108+
const dateString = (
109+
await new Promise<string>((resolve, reject) => {
110+
let result = '';
111+
// We use the more verbose `spawn` to avoid command injection, in case the filename
112+
// has strange characters.
113+
const gitLog = childProcess.spawn(
114+
'git',
115+
['log', '-1', '--format=%as', file],
116+
{
117+
cwd: rootPath,
118+
env: { PATH: process.env.PATH },
119+
}
120+
);
121+
gitLog.stdout?.on('data', data => {
122+
result += data.toString('utf8');
123+
});
124+
gitLog.on('close', code => {
125+
if (code === 0) {
126+
resolve(result);
127+
} else {
128+
reject(new Error(`git log failed with exit code ${code}`));
129+
}
130+
});
131+
})
132+
).trim();
133+
134+
const result = new Date(dateString).getFullYear();
135+
assert(!Number.isNaN(result), `Could not read commit year for ${file}`);
136+
return result;
137+
}
138+
139+
async function main() {
140+
const currentYear = new Date().getFullYear() + 1;
141+
142+
await forEachRelevantFile(async file => {
143+
const [firstLine] = await readFirstLines(file, 1);
144+
const { groups = {} } =
145+
firstLine.match(/(?:\d{4}-)?(?<endYearString>\d{4})/) || [];
146+
const { endYearString } = groups;
147+
const endYear = Number(endYearString);
148+
149+
assert(
150+
endYear === currentYear ||
151+
endYear === (await getLatestCommitYearForFile(file)),
152+
`${file} has an invalid end license year`
153+
);
154+
});
155+
}
156+
157+
// Note: this check will fail if we switch to ES modules. See
158+
// <https://stackoverflow.com/a/60309682>.
159+
if (require.main === module) {
160+
main().catch(err => {
161+
// eslint-disable-next-line no-console
162+
console.error(err);
163+
process.exit(1);
164+
});
165+
}

0 commit comments

Comments
 (0)