@avi2dg/checks 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +363 -0
- package/bunfig.toml +2 -0
- package/commitlint.config.js +22 -0
- package/dependency-cruiser.config.js +72 -0
- package/dist/index.js +80 -0
- package/oxlintrc.json +21 -0
- package/package.json +70 -0
- package/scripts/backtest.ts +117 -0
- package/scripts/comment-gate.ts +116 -0
- package/scripts/comments.ts +298 -0
- package/scripts/commit-identity.ts +153 -0
- package/scripts/lint-coverage.sh +27 -0
- package/scripts/test-layout.ts +316 -0
- package/tsconfig.effect.json +23 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { comments, refused, SYNTAXES } from "./comments.ts";
|
|
3
|
+
|
|
4
|
+
type Landed = {
|
|
5
|
+
readonly sha: string;
|
|
6
|
+
readonly subject: string;
|
|
7
|
+
readonly added: number;
|
|
8
|
+
readonly comments: number;
|
|
9
|
+
readonly refusals: readonly string[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const USAGE = "usage: backtest.ts [commit-count]";
|
|
13
|
+
|
|
14
|
+
const EXCLUDED = ["generated/", "vendor/", "repos/", "node_modules/", "dist/"];
|
|
15
|
+
|
|
16
|
+
function die(message: string): never {
|
|
17
|
+
console.error(message);
|
|
18
|
+
process.exit(2);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function git(...args: readonly string[]): string {
|
|
22
|
+
const result = Bun.spawnSync(["git", ...args], { stdout: "pipe", stderr: "pipe" });
|
|
23
|
+
if (!result.success) {
|
|
24
|
+
die(`backtest: git ${args.join(" ")}: ${result.stderr.toString().trim()}`);
|
|
25
|
+
}
|
|
26
|
+
return result.stdout.toString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readable(path: string): boolean {
|
|
30
|
+
if (EXCLUDED.some((prefix) => path.startsWith(prefix))) return false;
|
|
31
|
+
const extension = path.slice(path.lastIndexOf(".") + 1);
|
|
32
|
+
return extension in SYNTAXES;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function at(sha: string, path: string): Promise<string | undefined> {
|
|
36
|
+
const result = Bun.spawnSync(["git", "show", `${sha}:${path}`], { stdout: "pipe", stderr: "pipe" });
|
|
37
|
+
return result.success ? result.stdout.toString() : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function withoutLine(refusal: string): string {
|
|
41
|
+
return refusal.replace(/^(\S+?):\d+ /, "$1 ");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function backtest(count: string): Promise<string> {
|
|
45
|
+
const lines: string[] = [];
|
|
46
|
+
const commits = git("log", "--first-parent", "--format=%H%x09%P%x09%s", "-n", count)
|
|
47
|
+
.trim()
|
|
48
|
+
.split("\n")
|
|
49
|
+
.map((row) => {
|
|
50
|
+
const [sha = "", parents = "", subject = ""] = row.split("\t");
|
|
51
|
+
return { sha, parent: parents.split(" ")[0] ?? "", subject };
|
|
52
|
+
})
|
|
53
|
+
.filter((commit) => commit.sha !== "" && commit.parent !== "");
|
|
54
|
+
|
|
55
|
+
const landed: Landed[] = [];
|
|
56
|
+
|
|
57
|
+
for (const { sha, parent, subject } of commits) {
|
|
58
|
+
const changed = git("diff", "--name-only", "--diff-filter=d", parent, sha)
|
|
59
|
+
.trim()
|
|
60
|
+
.split("\n")
|
|
61
|
+
.filter((path) => path !== "" && readable(path));
|
|
62
|
+
if (changed.length === 0) continue;
|
|
63
|
+
|
|
64
|
+
const numstat = git("diff", "--numstat", parent, sha, "--", ...changed).trim();
|
|
65
|
+
const added =
|
|
66
|
+
numstat === ""
|
|
67
|
+
? 0
|
|
68
|
+
: numstat.split("\n").reduce((sum, row) => sum + Number(row.split("\t")[0] ?? 0), 0);
|
|
69
|
+
|
|
70
|
+
let commentLines = 0;
|
|
71
|
+
const refusals: string[] = [];
|
|
72
|
+
|
|
73
|
+
for (const path of changed) {
|
|
74
|
+
const after = await at(sha, path);
|
|
75
|
+
if (after === undefined) continue;
|
|
76
|
+
const before = (await at(parent, path)) ?? "";
|
|
77
|
+
|
|
78
|
+
const wasRefused = new Set(refused(path, before).map(withoutLine));
|
|
79
|
+
for (const refusal of refused(path, after)) {
|
|
80
|
+
if (!wasRefused.has(withoutLine(refusal))) refusals.push(refusal);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const wasComment = new Set(comments(path, before).map((one) => one.text));
|
|
84
|
+
for (const one of comments(path, after)) {
|
|
85
|
+
if (!wasComment.has(one.text)) commentLines += one.text.split("\n").length;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
landed.push({ sha: sha.slice(0, 7), subject, added, comments: commentLines, refusals });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const total = (pick: (one: Landed) => number): number => landed.reduce((sum, one) => sum + pick(one), 0);
|
|
93
|
+
const addedLines = total((one) => one.added);
|
|
94
|
+
const commentLines = total((one) => one.comments);
|
|
95
|
+
const refusalCount = total((one) => one.refusals.length);
|
|
96
|
+
|
|
97
|
+
for (const one of landed) {
|
|
98
|
+
lines.push(`${one.sha}\t${one.added}\t${one.comments}\t${one.refusals.length}\t${one.subject.slice(0, 58)}`);
|
|
99
|
+
}
|
|
100
|
+
lines.push(`${landed.length} commits touching code, ${addedLines} added lines`);
|
|
101
|
+
lines.push(
|
|
102
|
+
`comment lines added: ${commentLines} (${((commentLines / Math.max(addedLines, 1)) * 100).toFixed(1)}% of added lines)`,
|
|
103
|
+
);
|
|
104
|
+
lines.push(`refusals introduced: ${refusalCount}`);
|
|
105
|
+
for (const one of landed) {
|
|
106
|
+
if (one.refusals.length === 0) continue;
|
|
107
|
+
lines.push(`\n ${one.sha} ${one.subject}`);
|
|
108
|
+
for (const refusal of one.refusals) lines.push(` ${refusal}`);
|
|
109
|
+
}
|
|
110
|
+
return lines.join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (import.meta.main) {
|
|
114
|
+
const [count = "60", ...extra] = process.argv.slice(2);
|
|
115
|
+
if (extra.length > 0) die(USAGE);
|
|
116
|
+
console.log(await backtest(count));
|
|
117
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { refused, SYNTAXES } from "./comments.ts";
|
|
3
|
+
|
|
4
|
+
export type GateResult = {
|
|
5
|
+
readonly files: number;
|
|
6
|
+
readonly addedLines: number;
|
|
7
|
+
readonly violations: readonly string[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const USAGE = "usage: comment-gate.ts <ref> | <base-ref> <head-ref>";
|
|
11
|
+
|
|
12
|
+
function die(message: string): never {
|
|
13
|
+
console.error(message);
|
|
14
|
+
process.exit(2);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function git(root: string, ...args: readonly string[]): string {
|
|
18
|
+
const result = Bun.spawnSync(["git", ...args], { cwd: root, stdout: "pipe", stderr: "pipe" });
|
|
19
|
+
if (!result.success) {
|
|
20
|
+
die(`comment-gate: git ${args.join(" ")}: ${result.stderr.toString().trim()}`);
|
|
21
|
+
}
|
|
22
|
+
return result.stdout.toString();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readable(path: string): boolean {
|
|
26
|
+
return path.slice(path.lastIndexOf(".") + 1) in SYNTAXES;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function newPathOf(line: string): string | undefined {
|
|
30
|
+
const path = line.startsWith("+++ b/") ? line.slice("+++ b/".length) : line.slice("+++ ".length);
|
|
31
|
+
return path === "/dev/null" ? undefined : path;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
|
|
35
|
+
|
|
36
|
+
export function parseAddedLines(diff: string): Map<string, Set<number>> {
|
|
37
|
+
const added = new Map<string, Set<number>>();
|
|
38
|
+
let path: string | undefined;
|
|
39
|
+
let line = 0;
|
|
40
|
+
let inHunk = false;
|
|
41
|
+
for (const row of diff.split("\n")) {
|
|
42
|
+
if (row.startsWith("+++ ")) {
|
|
43
|
+
path = newPathOf(row);
|
|
44
|
+
inHunk = false;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const hunk = HUNK.exec(row);
|
|
48
|
+
if (hunk !== null) {
|
|
49
|
+
line = Number(hunk[1]);
|
|
50
|
+
inHunk = true;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!inHunk || path === undefined) continue;
|
|
54
|
+
if (row.startsWith("+")) {
|
|
55
|
+
let lines = added.get(path);
|
|
56
|
+
if (lines === undefined) {
|
|
57
|
+
lines = new Set<number>();
|
|
58
|
+
added.set(path, lines);
|
|
59
|
+
}
|
|
60
|
+
lines.add(line);
|
|
61
|
+
line += 1;
|
|
62
|
+
} else if (row.startsWith("-")) {
|
|
63
|
+
continue;
|
|
64
|
+
} else {
|
|
65
|
+
line += 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return added;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function show(rev: string, path: string, root: string): string | undefined {
|
|
72
|
+
const result = Bun.spawnSync(["git", "show", `${rev}:${path}`], {
|
|
73
|
+
cwd: root,
|
|
74
|
+
stdout: "pipe",
|
|
75
|
+
stderr: "pipe",
|
|
76
|
+
});
|
|
77
|
+
return result.success ? result.stdout.toString() : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function runRange(root: string, base: string, head: string): GateResult {
|
|
81
|
+
const diff = git(root, "-c", "core.quotePath=false", "diff", "-U0", "--no-color", "--no-prefix", base, head);
|
|
82
|
+
const added = parseAddedLines(diff);
|
|
83
|
+
const violations: string[] = [];
|
|
84
|
+
let addedLines = 0;
|
|
85
|
+
for (const [path, lines] of added) {
|
|
86
|
+
addedLines += lines.size;
|
|
87
|
+
if (!readable(path)) continue;
|
|
88
|
+
const source = show(head, path, root);
|
|
89
|
+
if (source === undefined) continue;
|
|
90
|
+
violations.push(...refused(path, source, lines));
|
|
91
|
+
}
|
|
92
|
+
return { files: added.size, addedLines, violations };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function report({ files, addedLines, violations }: GateResult): string {
|
|
96
|
+
if (violations.length === 0) {
|
|
97
|
+
return `comment-gate: ${addedLines} added line(s) across ${files} file(s) carry no refused comment`;
|
|
98
|
+
}
|
|
99
|
+
return [`comment-gate: ${violations.length} violation(s):`, ...violations.map((one) => ` ${one}`)].join("\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parentOf(root: string, rev: string): string {
|
|
103
|
+
return git(root, "rev-parse", "--verify", `${rev}^`).trim();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (import.meta.main) {
|
|
107
|
+
const [first, second, ...extra] = process.argv.slice(2);
|
|
108
|
+
if (first === undefined || extra.length > 0) die(USAGE);
|
|
109
|
+
|
|
110
|
+
const root = git(process.cwd(), "rev-parse", "--show-toplevel").trim();
|
|
111
|
+
const result =
|
|
112
|
+
second !== undefined ? runRange(root, first, second) : runRange(root, parentOf(root, first), first);
|
|
113
|
+
|
|
114
|
+
console.log(report(result));
|
|
115
|
+
process.exit(result.violations.length === 0 ? 0 : 1);
|
|
116
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
export type Comment = { line: number; text: string };
|
|
2
|
+
|
|
3
|
+
type Marker = { readonly token: string; readonly afterAWordBreak: boolean };
|
|
4
|
+
type Quote = { readonly token: string; readonly backslashEscapes: boolean; readonly opensMidWord: boolean };
|
|
5
|
+
|
|
6
|
+
type Syntax = {
|
|
7
|
+
readonly line: readonly Marker[];
|
|
8
|
+
readonly block: readonly (readonly [open: string, close: string])[];
|
|
9
|
+
readonly quotes: readonly Quote[];
|
|
10
|
+
readonly regexLiterals: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const anywhere = (token: string): Marker => ({ token, afterAWordBreak: false });
|
|
14
|
+
const startingAWord = (token: string): Marker => ({ token, afterAWordBreak: true });
|
|
15
|
+
const escaping = (token: string): Quote => ({ token, backslashEscapes: true, opensMidWord: true });
|
|
16
|
+
const literal = (token: string): Quote => ({ token, backslashEscapes: false, opensMidWord: true });
|
|
17
|
+
const outsideAWord = (quote: Quote): Quote => ({ ...quote, opensMidWord: false });
|
|
18
|
+
|
|
19
|
+
const SLASHES: readonly (readonly [string, string])[] = [["/*", "*/"]];
|
|
20
|
+
const SLASH_SLASH = [startingAWord("//")];
|
|
21
|
+
const DASHES = [anywhere("--")];
|
|
22
|
+
const STRINGS = [escaping('"'), escaping("'")];
|
|
23
|
+
|
|
24
|
+
const JS: Syntax = {
|
|
25
|
+
line: SLASH_SLASH,
|
|
26
|
+
block: SLASHES,
|
|
27
|
+
quotes: [escaping('"'), outsideAWord(escaping("'")), escaping("`")],
|
|
28
|
+
regexLiterals: true,
|
|
29
|
+
};
|
|
30
|
+
const CURLY: Syntax = { ...JS, quotes: [...STRINGS, escaping("`")], regexLiterals: false };
|
|
31
|
+
const PHP: Syntax = { line: [...SLASH_SLASH, anywhere("#")], block: SLASHES, quotes: STRINGS, regexLiterals: false };
|
|
32
|
+
const HASH: Syntax = {
|
|
33
|
+
line: [startingAWord("#")],
|
|
34
|
+
block: [],
|
|
35
|
+
quotes: [escaping('"'), literal("'")],
|
|
36
|
+
regexLiterals: false,
|
|
37
|
+
};
|
|
38
|
+
const YAML: Syntax = { ...HASH, quotes: [escaping('"'), outsideAWord(literal("'"))] };
|
|
39
|
+
const SQL: Syntax = { line: DASHES, block: SLASHES, quotes: STRINGS, regexLiterals: false };
|
|
40
|
+
const LUA: Syntax = { line: DASHES, block: [["--[[", "]]"]], quotes: STRINGS, regexLiterals: false };
|
|
41
|
+
const HASKELL: Syntax = { line: DASHES, block: [["{-", "-}"]], quotes: [escaping('"')], regexLiterals: false };
|
|
42
|
+
const ML: Syntax = { line: [], block: [["(*", "*)"]], quotes: [escaping('"')], regexLiterals: false };
|
|
43
|
+
|
|
44
|
+
export const SYNTAXES: Readonly<Record<string, Syntax>> = {
|
|
45
|
+
ts: JS,
|
|
46
|
+
tsx: JS,
|
|
47
|
+
mts: JS,
|
|
48
|
+
cts: JS,
|
|
49
|
+
js: JS,
|
|
50
|
+
jsx: JS,
|
|
51
|
+
mjs: JS,
|
|
52
|
+
cjs: JS,
|
|
53
|
+
go: CURLY,
|
|
54
|
+
rs: CURLY,
|
|
55
|
+
java: CURLY,
|
|
56
|
+
kt: CURLY,
|
|
57
|
+
swift: CURLY,
|
|
58
|
+
scala: CURLY,
|
|
59
|
+
c: CURLY,
|
|
60
|
+
h: CURLY,
|
|
61
|
+
cc: CURLY,
|
|
62
|
+
cpp: CURLY,
|
|
63
|
+
hpp: CURLY,
|
|
64
|
+
cs: CURLY,
|
|
65
|
+
php: PHP,
|
|
66
|
+
py: HASH,
|
|
67
|
+
rb: HASH,
|
|
68
|
+
ex: HASH,
|
|
69
|
+
exs: HASH,
|
|
70
|
+
sh: HASH,
|
|
71
|
+
bash: HASH,
|
|
72
|
+
zsh: HASH,
|
|
73
|
+
nix: HASH,
|
|
74
|
+
sql: SQL,
|
|
75
|
+
lua: LUA,
|
|
76
|
+
hs: HASKELL,
|
|
77
|
+
yaml: YAML,
|
|
78
|
+
yml: YAML,
|
|
79
|
+
toml: HASH,
|
|
80
|
+
ml: ML,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function syntaxFor(path: string): Syntax {
|
|
84
|
+
const extension = path.slice(path.lastIndexOf(".") + 1);
|
|
85
|
+
const syntax = SYNTAXES[extension];
|
|
86
|
+
if (!syntax) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`${path} is code the comment checks cannot read: add a comment syntax for .${extension} to scripts/comments.ts`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return syntax;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const WORD = /[\w$]/;
|
|
95
|
+
|
|
96
|
+
const KEYWORDS = new Set([
|
|
97
|
+
"return",
|
|
98
|
+
"typeof",
|
|
99
|
+
"instanceof",
|
|
100
|
+
"in",
|
|
101
|
+
"of",
|
|
102
|
+
"new",
|
|
103
|
+
"delete",
|
|
104
|
+
"void",
|
|
105
|
+
"throw",
|
|
106
|
+
"case",
|
|
107
|
+
"do",
|
|
108
|
+
"else",
|
|
109
|
+
"yield",
|
|
110
|
+
"await",
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
const AFTER_A_VALUE = /[)\]}"'`]/;
|
|
114
|
+
|
|
115
|
+
function opensRegex(previous: string): boolean {
|
|
116
|
+
if (previous === "") return true;
|
|
117
|
+
if (WORD.test(previous[0]!)) return KEYWORDS.has(previous);
|
|
118
|
+
return !AFTER_A_VALUE.test(previous);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const WORD_BREAK = /[\s;|&(]/;
|
|
122
|
+
|
|
123
|
+
function endOfQuoted(source: string, start: number, quote: Quote): number {
|
|
124
|
+
let i = start + quote.token.length;
|
|
125
|
+
while (i < source.length) {
|
|
126
|
+
if (quote.backslashEscapes && source[i] === "\\") i += 2;
|
|
127
|
+
else if (source.startsWith(quote.token, i)) return i + quote.token.length;
|
|
128
|
+
else i += 1;
|
|
129
|
+
}
|
|
130
|
+
return start + quote.token.length;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function endOfRegex(source: string, start: number): number {
|
|
134
|
+
let i = start + 1;
|
|
135
|
+
let inClass = false;
|
|
136
|
+
while (i < source.length) {
|
|
137
|
+
const char = source[i]!;
|
|
138
|
+
if (char === "\\") i += 2;
|
|
139
|
+
else if (char === "\n") return i;
|
|
140
|
+
else {
|
|
141
|
+
if (char === "[") inClass = true;
|
|
142
|
+
else if (char === "]") inClass = false;
|
|
143
|
+
else if (char === "/" && !inClass) return i + 1;
|
|
144
|
+
i += 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return source.length;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function comments(path: string, source: string): Comment[] {
|
|
151
|
+
const syntax = syntaxFor(path);
|
|
152
|
+
const found: Comment[] = [];
|
|
153
|
+
let i = 0;
|
|
154
|
+
let line = 1;
|
|
155
|
+
let previous = "";
|
|
156
|
+
|
|
157
|
+
const advance = (to: number): void => {
|
|
158
|
+
for (let k = i; k < to; k++) if (source[k] === "\n") line += 1;
|
|
159
|
+
i = to;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
while (i < source.length) {
|
|
163
|
+
const char = source[i]!;
|
|
164
|
+
|
|
165
|
+
const block = syntax.block.find(([open]) => source.startsWith(open, i));
|
|
166
|
+
if (block) {
|
|
167
|
+
const [open, close] = block;
|
|
168
|
+
const closed = source.indexOf(close, i + open.length);
|
|
169
|
+
const stop = closed < 0 ? source.length : closed + close.length;
|
|
170
|
+
found.push({ line, text: source.slice(i, stop) });
|
|
171
|
+
advance(stop);
|
|
172
|
+
previous = "";
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const marker = syntax.line.find(
|
|
177
|
+
({ token, afterAWordBreak }) =>
|
|
178
|
+
source.startsWith(token, i) && (!afterAWordBreak || i === 0 || WORD_BREAK.test(source[i - 1]!)),
|
|
179
|
+
);
|
|
180
|
+
if (marker) {
|
|
181
|
+
const newline = source.indexOf("\n", i);
|
|
182
|
+
const stop = newline < 0 ? source.length : newline;
|
|
183
|
+
found.push({ line, text: source.slice(i, stop) });
|
|
184
|
+
advance(stop);
|
|
185
|
+
previous = "";
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const quote = syntax.quotes.find(
|
|
190
|
+
({ token, opensMidWord }) =>
|
|
191
|
+
source.startsWith(token, i) && (opensMidWord || i === 0 || !WORD.test(source[i - 1]!)),
|
|
192
|
+
);
|
|
193
|
+
if (quote) {
|
|
194
|
+
advance(endOfQuoted(source, i, quote));
|
|
195
|
+
previous = quote.token;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (syntax.regexLiterals && char === "/" && opensRegex(previous)) {
|
|
200
|
+
advance(endOfRegex(source, i));
|
|
201
|
+
previous = "/";
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (WORD.test(char)) {
|
|
206
|
+
let end = i;
|
|
207
|
+
while (end < source.length && WORD.test(source[end]!)) end += 1;
|
|
208
|
+
previous = source.slice(i, end);
|
|
209
|
+
advance(end);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!/\s/.test(char)) previous = char;
|
|
214
|
+
advance(i + 1);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return found;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const LICENCE = /copyright|spdx-license-identifier|all rights reserved|licen[cs]ed under|permission is hereby granted/i;
|
|
221
|
+
|
|
222
|
+
// A single sentence wraps to at most a few lines at a normal width, so a longer run at the
|
|
223
|
+
// file's own opening is the shape of a paragraph rather than of one fact.
|
|
224
|
+
const A_WRAPPED_SENTENCE_FITS_WITHIN = 3;
|
|
225
|
+
|
|
226
|
+
function beginsItsLine(comment: Comment, lines: readonly string[]): boolean {
|
|
227
|
+
return lines[comment.line - 1]!.trimStart().startsWith(comment.text.split("\n")[0]!);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function openingBlock(found: readonly Comment[], source: string): Comment[] {
|
|
231
|
+
const lines = source.split("\n");
|
|
232
|
+
if (found.length === 0 || found[0]!.line !== 1 || !beginsItsLine(found[0]!, lines)) return [];
|
|
233
|
+
const block = [found[0]!];
|
|
234
|
+
let next = found[0]!.line + found[0]!.text.split("\n").length;
|
|
235
|
+
for (let i = 1; i < found.length && found[i]!.line === next && beginsItsLine(found[i]!, lines); i++) {
|
|
236
|
+
block.push(found[i]!);
|
|
237
|
+
next = found[i]!.line + found[i]!.text.split("\n").length;
|
|
238
|
+
}
|
|
239
|
+
return block;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
type Check = {
|
|
243
|
+
readonly find: RegExp;
|
|
244
|
+
readonly refusal: (match: string) => string;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const CHECKS: readonly Check[] = [
|
|
248
|
+
{
|
|
249
|
+
find: /@ts-expect-error|@ts-ignore|\bprettier-ignore\b|\beslint-disable[\w-]*|\bbiome-ignore\b/,
|
|
250
|
+
refusal: (match) =>
|
|
251
|
+
`carries the machine-read directive \`${match}\`. Fix what the tool is reporting, or stop running the tool on this file`,
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
find: /\bADR[\s-]?\d+|\bdocs\/adr\b|\bRFC[\s-]?\d+|(?:^|[\s(["'`])#\d+\b|[\w.-]+\/[\w.-]+#\d+|\/(?:issues|pull)\/\d+/i,
|
|
255
|
+
refusal: (match) =>
|
|
256
|
+
`points at a record or a ticket (${JSON.stringify(match.trim())}). Drop the pointer: a record is reached by searching docs/adr, and the story of the change goes in the commit message`,
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
find: /^\/\*\*/,
|
|
260
|
+
refusal: () =>
|
|
261
|
+
"opens a doc block. Make the code say it, or put what the types cannot hold on a line comment in the fewest words that carry it",
|
|
262
|
+
},
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
function spans(from: number, lineCount: number, within: ReadonlySet<number> | undefined): boolean {
|
|
266
|
+
if (within === undefined) return true;
|
|
267
|
+
for (let line = from; line < from + lineCount; line += 1) if (within.has(line)) return true;
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function refused(path: string, source: string, within?: ReadonlySet<number>): string[] {
|
|
272
|
+
const out: string[] = [];
|
|
273
|
+
const found = comments(path, source);
|
|
274
|
+
|
|
275
|
+
const opening = openingBlock(found, source);
|
|
276
|
+
const openingText = opening.map((comment) => comment.text).join("\n");
|
|
277
|
+
const openingLines = opening.reduce((sum, comment) => sum + comment.text.split("\n").length, 0);
|
|
278
|
+
if (
|
|
279
|
+
opening.length > 0 &&
|
|
280
|
+
openingLines > A_WRAPPED_SENTENCE_FITS_WITHIN &&
|
|
281
|
+
!LICENCE.test(openingText) &&
|
|
282
|
+
spans(1, openingLines, within)
|
|
283
|
+
) {
|
|
284
|
+
out.push(
|
|
285
|
+
`${path}:1 opens with a ${openingLines}-line rationale block. A record this long belongs in docs/adr or the repo's decision log, and the code does not point at it`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
for (const comment of found) {
|
|
290
|
+
if (LICENCE.test(comment.text)) continue;
|
|
291
|
+
if (!spans(comment.line, comment.text.split("\n").length, within)) continue;
|
|
292
|
+
for (const check of CHECKS) {
|
|
293
|
+
const [match] = check.find.exec(comment.text) ?? [];
|
|
294
|
+
if (match !== undefined) out.push(`${path}:${comment.line} ${check.refusal(match)}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
type Identity = { readonly name: string; readonly email: string };
|
|
5
|
+
|
|
6
|
+
type Commit = {
|
|
7
|
+
readonly sha: string;
|
|
8
|
+
readonly subject: string;
|
|
9
|
+
readonly author: Identity;
|
|
10
|
+
readonly committer: Identity;
|
|
11
|
+
readonly coAuthoredBy: readonly string[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type Offence = { readonly commit: Commit; readonly reasons: readonly string[] };
|
|
15
|
+
|
|
16
|
+
const DEFAULT_AUTHORS: readonly Identity[] = [{ name: "avi2d", email: "avi2dg@gmail.com" }];
|
|
17
|
+
|
|
18
|
+
// GitHub writes the squash commit, so it commits what the owner authored and never authors.
|
|
19
|
+
const SQUASH_COMMITTER: Identity = { name: "GitHub", email: "noreply@github.com" };
|
|
20
|
+
|
|
21
|
+
// git's own trailer parser, so only the trailer block counts and prose never does.
|
|
22
|
+
const CO_AUTHORED_BY_FORMAT = "%(trailers:key=Co-authored-by)";
|
|
23
|
+
|
|
24
|
+
// argv cannot carry a NUL, so git spells the separators itself.
|
|
25
|
+
const FIELD_FORMAT = "%x00";
|
|
26
|
+
const RECORD_FORMAT = "%x1e";
|
|
27
|
+
const FIELD = "\u0000";
|
|
28
|
+
const RECORD = "\u001e";
|
|
29
|
+
|
|
30
|
+
const USAGE = "usage: commit-identity.ts <ref> | <base-ref> <head-ref>";
|
|
31
|
+
|
|
32
|
+
function die(message: string): never {
|
|
33
|
+
console.error(message);
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function git(...args: readonly string[]): string {
|
|
38
|
+
const result = Bun.spawnSync(["git", ...args], { stdout: "pipe", stderr: "pipe" });
|
|
39
|
+
if (!result.success) {
|
|
40
|
+
die(`commit-identity: git ${args.join(" ")}: ${result.stderr.toString().trim()}`);
|
|
41
|
+
}
|
|
42
|
+
return result.stdout.toString();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function render(identity: Identity): string {
|
|
46
|
+
return `${identity.name} <${identity.email}>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isIdentity(value: unknown): value is Identity {
|
|
50
|
+
if (typeof value !== "object" || value === null) return false;
|
|
51
|
+
const candidate = value as { name?: unknown; email?: unknown };
|
|
52
|
+
return typeof candidate.name === "string" && typeof candidate.email === "string";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function allowedAuthors(): readonly Identity[] {
|
|
56
|
+
const root = git("rev-parse", "--show-toplevel").trim();
|
|
57
|
+
const manifestPath = `${root}/package.json`;
|
|
58
|
+
if (!existsSync(manifestPath)) return DEFAULT_AUTHORS;
|
|
59
|
+
|
|
60
|
+
const parsed: unknown = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
61
|
+
const configured = (parsed as { commitIdentity?: unknown }).commitIdentity;
|
|
62
|
+
if (configured === undefined) return DEFAULT_AUTHORS;
|
|
63
|
+
|
|
64
|
+
const authors = (configured as { authors?: unknown }).authors;
|
|
65
|
+
if (!Array.isArray(authors) || authors.length === 0 || !authors.every(isIdentity)) {
|
|
66
|
+
die(
|
|
67
|
+
`commit-identity: ${manifestPath} sets commitIdentity without a non-empty authors array of {name, email}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return authors;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readCommits(revisions: readonly string[]): readonly Commit[] {
|
|
74
|
+
const format =
|
|
75
|
+
["%H", "%an", "%ae", "%cn", "%ce", "%s", CO_AUTHORED_BY_FORMAT].join(FIELD_FORMAT) +
|
|
76
|
+
RECORD_FORMAT;
|
|
77
|
+
const log = git("log", `--format=${format}`, ...revisions);
|
|
78
|
+
|
|
79
|
+
return log
|
|
80
|
+
.split(RECORD)
|
|
81
|
+
.map((record) => record.replace(/^\n/, ""))
|
|
82
|
+
.filter((record) => record !== "")
|
|
83
|
+
.map((record) => {
|
|
84
|
+
const [sha, authorName, authorEmail, committerName, committerEmail, subject, trailers] =
|
|
85
|
+
record.split(FIELD);
|
|
86
|
+
if (
|
|
87
|
+
sha === undefined ||
|
|
88
|
+
authorName === undefined ||
|
|
89
|
+
authorEmail === undefined ||
|
|
90
|
+
committerName === undefined ||
|
|
91
|
+
committerEmail === undefined ||
|
|
92
|
+
subject === undefined ||
|
|
93
|
+
trailers === undefined
|
|
94
|
+
) {
|
|
95
|
+
return die(`commit-identity: cannot parse git log record: ${JSON.stringify(record)}`);
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
sha,
|
|
99
|
+
subject,
|
|
100
|
+
author: { name: authorName, email: authorEmail },
|
|
101
|
+
committer: { name: committerName, email: committerEmail },
|
|
102
|
+
coAuthoredBy: trailers.split("\n").filter((line) => line !== ""),
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function allows(allowed: readonly Identity[], identity: Identity): boolean {
|
|
108
|
+
return allowed.some(
|
|
109
|
+
(entry) =>
|
|
110
|
+
entry.name === identity.name &&
|
|
111
|
+
entry.email.toLowerCase() === identity.email.toLowerCase(),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function inspect(commit: Commit, allowed: readonly Identity[]): Offence | undefined {
|
|
116
|
+
const reasons: string[] = [];
|
|
117
|
+
if (!allows(allowed, commit.author)) {
|
|
118
|
+
reasons.push(`author ${render(commit.author)}`);
|
|
119
|
+
}
|
|
120
|
+
if (!allows([...allowed, SQUASH_COMMITTER], commit.committer)) {
|
|
121
|
+
reasons.push(`committer ${render(commit.committer)}`);
|
|
122
|
+
}
|
|
123
|
+
for (const trailer of commit.coAuthoredBy) {
|
|
124
|
+
reasons.push(`trailer ${trailer}`);
|
|
125
|
+
}
|
|
126
|
+
return reasons.length === 0 ? undefined : { commit, reasons };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const [first, second, ...extra] = process.argv.slice(2);
|
|
130
|
+
if (first === undefined || extra.length > 0) die(USAGE);
|
|
131
|
+
const range = second === undefined ? first : `${first}..${second}`;
|
|
132
|
+
const revisions = second === undefined ? ["-1", range] : [range];
|
|
133
|
+
|
|
134
|
+
const allowed = allowedAuthors();
|
|
135
|
+
const commits = readCommits(revisions);
|
|
136
|
+
const offences = commits
|
|
137
|
+
.map((commit) => inspect(commit, allowed))
|
|
138
|
+
.filter((offence) => offence !== undefined);
|
|
139
|
+
|
|
140
|
+
if (offences.length > 0) {
|
|
141
|
+
console.error(
|
|
142
|
+
`commit-identity: ${offences.length} of ${commits.length} commit(s) in ${range} carry a foreign identity:`,
|
|
143
|
+
);
|
|
144
|
+
for (const { commit, reasons } of offences) {
|
|
145
|
+
console.error(` ${commit.sha.slice(0, 12)} ${commit.subject}`);
|
|
146
|
+
for (const reason of reasons) console.error(` ${reason}`);
|
|
147
|
+
}
|
|
148
|
+
console.error(` allowed: ${allowed.map(render).join(", ")}`);
|
|
149
|
+
console.error(` allowed as committer only: ${render(SQUASH_COMMITTER)}`);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
console.log(`commit-identity: ${commits.length} commit(s) in ${range} carry only allowed identities`);
|