nollm 0.1.0 → 0.3.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/README.md +71 -24
- package/package.json +3 -3
- package/src/cli.js +14 -22
- package/src/config.js +15 -3
- package/src/diff.js +272 -0
- package/src/files.js +102 -30
- package/src/index.d.ts +11 -0
- package/src/index.js +1 -0
- package/src/lint.js +59 -22
- package/src/rules.js +211 -6
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ Add a script to `package.json`:
|
|
|
30
30
|
}
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Requires Node
|
|
33
|
+
Requires Node 24 or newer.
|
|
34
34
|
|
|
35
35
|
## Usage
|
|
36
36
|
|
|
@@ -39,12 +39,24 @@ nollm [options] [paths...]
|
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
With no paths, `nollm` checks the current directory.
|
|
42
|
-
Paths can be files or directories
|
|
42
|
+
Paths can be files or directories, and they can be relative or absolute:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
nollm docs/guide.md
|
|
46
|
+
nollm /srv/site/docs/guide.md
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
A file under the current directory is reported by its relative path.
|
|
50
|
+
A file outside it keeps its absolute path, so the report never points at it
|
|
51
|
+
through a row of `..`.
|
|
52
|
+
Ignore patterns from the config describe the project, so they apply under the
|
|
53
|
+
current directory and leave paths outside it alone.
|
|
43
54
|
|
|
44
55
|
| Option | Effect |
|
|
45
56
|
| ----------------- | ------------------------------------------------------------------ |
|
|
46
57
|
| `--jobs <n>` | Number of worker threads. Defaults to the CPU count. |
|
|
47
58
|
| `--config <path>` | Config file to use. |
|
|
59
|
+
| `--diff <ref>` | Check only the lines this branch adds or changes since `<ref>`. |
|
|
48
60
|
| `--no-git` | Do not ask git for the file list. Read `.gitignore` files instead. |
|
|
49
61
|
| `--quiet` | Print only the summary. |
|
|
50
62
|
| `--list-rules` | Print every rule and exit. |
|
|
@@ -68,6 +80,40 @@ src/index.js
|
|
|
68
80
|
4 problems in 2 files (5 files checked, 0.07s)
|
|
69
81
|
```
|
|
70
82
|
|
|
83
|
+
## Checking only a pull request
|
|
84
|
+
|
|
85
|
+
A large codebase written before you added `nollm` has findings everywhere.
|
|
86
|
+
`--diff` reports only the lines the current branch touches, so a pull request
|
|
87
|
+
is judged on what it adds:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
nollm --diff origin/main
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The comparison starts at the merge base, the same range the pull request shows.
|
|
94
|
+
Commits that landed on `origin/main` after you branched do not count as yours.
|
|
95
|
+
Uncommitted edits and new files count, so the command works before you push.
|
|
96
|
+
|
|
97
|
+
A finding is kept by the line it points at.
|
|
98
|
+
A rule that reports at the top of a block, such as `wall-of-text`, stays quiet
|
|
99
|
+
when the branch grows a paragraph further down.
|
|
100
|
+
|
|
101
|
+
The diff is read in the repository the paths point at, not the one you happen
|
|
102
|
+
to stand in, so `nollm --diff main /srv/site` works from anywhere.
|
|
103
|
+
|
|
104
|
+
The base ref has to be in that clone. A shallow checkout, or one that fetched
|
|
105
|
+
a single branch, does not have it, and `nollm` then names the command that
|
|
106
|
+
fetches it.
|
|
107
|
+
|
|
108
|
+
In GitHub Actions, fetch the base branch first:
|
|
109
|
+
|
|
110
|
+
```yaml
|
|
111
|
+
- uses: actions/checkout@v4
|
|
112
|
+
with:
|
|
113
|
+
fetch-depth: 0
|
|
114
|
+
- run: npx nollm --diff origin/${{ github.base_ref }}
|
|
115
|
+
```
|
|
116
|
+
|
|
71
117
|
## What gets checked
|
|
72
118
|
|
|
73
119
|
Prose files: markdown, text, reStructuredText, AsciiDoc, and files named `README`, `CHANGELOG`, `LICENSE`, and similar.
|
|
@@ -80,28 +126,28 @@ Files of other types, binary files, lockfiles, minified files, and files over 2
|
|
|
80
126
|
|
|
81
127
|
## Rules
|
|
82
128
|
|
|
83
|
-
| Rule
|
|
84
|
-
|
|
|
85
|
-
| `banned-word`
|
|
86
|
-
| `em-dash`
|
|
87
|
-
| `bold-list-item`
|
|
88
|
-
| `filler-word`
|
|
89
|
-
| `llm-vocabulary`
|
|
90
|
-
| `chat-opener`
|
|
91
|
-
| `chat-closer`
|
|
92
|
-
| `ai-disclosure`
|
|
93
|
-
| `contrast-cliche`
|
|
94
|
-
| `rhetorical-question`
|
|
95
|
-
| `emoji-list`
|
|
96
|
-
| `
|
|
97
|
-
| `what-comment`
|
|
98
|
-
| `quoted-error`
|
|
99
|
-
| `dramatic-verb`
|
|
100
|
-
| `parenthetical-aside`
|
|
101
|
-
| `long-sentence`
|
|
102
|
-
| `wall-of-text`
|
|
103
|
-
| `uniform-paragraphs`
|
|
104
|
-
| `uniform-sentences`
|
|
129
|
+
| Rule | Catches |
|
|
130
|
+
| ------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
131
|
+
| `banned-word` | genuinely, load-bearing, crutch, spearheaded, fails loudly, and friends |
|
|
132
|
+
| `em-dash` | The em dash character |
|
|
133
|
+
| `bold-list-item` | List items like `- **Label:** plain text` |
|
|
134
|
+
| `filler-word` | simply, seamlessly, seamless, robust |
|
|
135
|
+
| `llm-vocabulary` | delve, tapestry, crucial, game-changer, battle-tested, and more |
|
|
136
|
+
| `chat-opener` | Lines that start with "Great question", "Certainly", "Let me", and more |
|
|
137
|
+
| `chat-closer` | "Hope this helps", "Let me know if", "Feel free to", and more |
|
|
138
|
+
| `ai-disclosure` | "As an AI", "my training data", and more |
|
|
139
|
+
| `contrast-cliche` | "not just X, but Y" and "it's not X, it's Y" |
|
|
140
|
+
| `rhetorical-question` | "Why? Because" and "The result?" |
|
|
141
|
+
| `emoji-list` | List items that start with an emoji |
|
|
142
|
+
| `no-short-term-relevance` | Comments that stop making sense once the change lands: "no longer", "no behavior change", "for now", "Previously," |
|
|
143
|
+
| `what-comment` | Comments that narrate the code: "This function returns", "Loop over" |
|
|
144
|
+
| `quoted-error` | Comments that quote an error message: `"Cannot read properties of..."` |
|
|
145
|
+
| `dramatic-verb` | blows up, dies with, falls over, chokes on, and friends |
|
|
146
|
+
| `parenthetical-aside` | Asides like `(and their compiled handles)` |
|
|
147
|
+
| `long-sentence` | A sentence over 30 words |
|
|
148
|
+
| `wall-of-text` | A paragraph over 120 words or 7 sentences |
|
|
149
|
+
| `uniform-paragraphs` | Three or more paragraphs in a row of about the same length |
|
|
150
|
+
| `uniform-sentences` | Four or more sentences of about the same length |
|
|
105
151
|
|
|
106
152
|
Run `nollm --list-rules` for the full list with the scope of each rule.
|
|
107
153
|
|
|
@@ -175,6 +221,7 @@ const findings = check("README.md", "This is simply the best.");
|
|
|
175
221
|
|
|
176
222
|
const summary = await lint({
|
|
177
223
|
roots: ["src", "docs"],
|
|
224
|
+
diff: "origin/main",
|
|
178
225
|
onResult({ file, findings }) {
|
|
179
226
|
// runs once per file, as soon as it is done
|
|
180
227
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nollm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "lint against LLMisms in your codebase",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"comments",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"tinypool": "^2.2.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@tsconfig/
|
|
40
|
+
"@tsconfig/node24": "^24.0.1",
|
|
41
41
|
"oxfmt": "^0.68.0",
|
|
42
42
|
"oxlint": "^1.83.0",
|
|
43
43
|
"publint": "^0.3.24",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"vitest": "^5.0.1"
|
|
46
46
|
},
|
|
47
47
|
"engines": {
|
|
48
|
-
"node": ">=
|
|
48
|
+
"node": ">= 24"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|
|
51
51
|
"format": "oxfmt",
|
package/src/cli.js
CHANGED
|
@@ -6,17 +6,22 @@ import { rules } from "./rules.js";
|
|
|
6
6
|
const HELP = `Usage: nollm [options] [paths...]
|
|
7
7
|
|
|
8
8
|
Checks files for LLMisms and prints each finding as soon as it is found.
|
|
9
|
-
Files that git ignores are skipped.
|
|
9
|
+
Paths may be relative or absolute. Files that git ignores are skipped.
|
|
10
10
|
|
|
11
11
|
Options:
|
|
12
12
|
--jobs, -j <n> Number of worker threads (default: cpu count)
|
|
13
13
|
--config <path> Config file (default: nollm.config.js in the current directory)
|
|
14
|
+
--diff <ref> Check only the lines this branch adds or changes since <ref>
|
|
14
15
|
--no-git Do not ask git for the file list. Read .gitignore files instead
|
|
15
16
|
--quiet, -q Print only the summary
|
|
16
17
|
--list-rules Print every rule and exit
|
|
17
18
|
--version, -v Print the version and exit
|
|
18
19
|
--help, -h Print this help and exit
|
|
19
20
|
|
|
21
|
+
Examples:
|
|
22
|
+
nollm docs/guide.md a path relative to the current directory
|
|
23
|
+
nollm /srv/site/docs/guide.md an absolute path
|
|
24
|
+
|
|
20
25
|
Exit code 1 when there are findings. Exit code 2 on a usage error.
|
|
21
26
|
`;
|
|
22
27
|
|
|
@@ -33,6 +38,7 @@ export async function main(
|
|
|
33
38
|
options: {
|
|
34
39
|
jobs: { type: "string", short: "j" },
|
|
35
40
|
config: { type: "string" },
|
|
41
|
+
diff: { type: "string" },
|
|
36
42
|
git: { type: "boolean", default: true },
|
|
37
43
|
quiet: { type: "boolean", short: "q", default: false },
|
|
38
44
|
"list-rules": { type: "boolean", default: false },
|
|
@@ -61,7 +67,7 @@ export async function main(
|
|
|
61
67
|
if (values["list-rules"]) {
|
|
62
68
|
for (let i = 0; i < rules.length; i++) {
|
|
63
69
|
const scope = (rules[i].scope ?? "text").padEnd(10);
|
|
64
|
-
stdout.write(`${rules[i].id.padEnd(
|
|
70
|
+
stdout.write(`${rules[i].id.padEnd(24)} ${scope} ${rules[i].message}\n`);
|
|
65
71
|
}
|
|
66
72
|
return 0;
|
|
67
73
|
}
|
|
@@ -85,6 +91,7 @@ export async function main(
|
|
|
85
91
|
cwd,
|
|
86
92
|
configPath: values.config,
|
|
87
93
|
git: values.git,
|
|
94
|
+
diff: values.diff,
|
|
88
95
|
jobs,
|
|
89
96
|
onResult(result) {
|
|
90
97
|
if (values.quiet || result.findings.length === 0) return;
|
|
@@ -116,29 +123,14 @@ export async function main(
|
|
|
116
123
|
* Findings are grouped by rule, in order of first appearance.
|
|
117
124
|
*/
|
|
118
125
|
function formatFile(file, findings, paint) {
|
|
119
|
-
const groups = new Map();
|
|
120
|
-
for (let i = 0; i < findings.length; i++) {
|
|
121
|
-
const finding = findings[i];
|
|
122
|
-
let group = groups.get(finding.ruleId);
|
|
123
|
-
if (!group) {
|
|
124
|
-
group = { message: finding.message, items: [] };
|
|
125
|
-
groups.set(finding.ruleId, group);
|
|
126
|
-
}
|
|
127
|
-
group.items.push(finding);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
126
|
let out = `${paint("underline", file)}\n`;
|
|
131
|
-
for (const [ruleId, group] of groups) {
|
|
132
|
-
out += ` ${paint("yellow", ruleId)} ${group.message}\n`;
|
|
133
127
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const item = group.items[i];
|
|
137
|
-
width = Math.max(width, `${item.line}:${item.column}`.length);
|
|
138
|
-
}
|
|
128
|
+
for (const [ruleId, items] of Map.groupBy(findings, (finding) => finding.ruleId)) {
|
|
129
|
+
out += ` ${paint("yellow", ruleId)} ${items[0].message}\n`;
|
|
139
130
|
|
|
140
|
-
|
|
141
|
-
|
|
131
|
+
const width = Math.max(...items.map((item) => `${item.line}:${item.column}`.length));
|
|
132
|
+
for (let i = 0; i < items.length; i++) {
|
|
133
|
+
const item = items[i];
|
|
142
134
|
const where = `${item.line}:${item.column}`.padEnd(width);
|
|
143
135
|
out += ` ${paint("dim", where)} ${JSON.stringify(item.text)}\n`;
|
|
144
136
|
}
|
package/src/config.js
CHANGED
|
@@ -62,7 +62,7 @@ function resolveRules(overrides = {}, words = []) {
|
|
|
62
62
|
|
|
63
63
|
for (let i = 0; i < builtinRules.length; i++) {
|
|
64
64
|
const rule = builtinRules[i];
|
|
65
|
-
const override = overrides
|
|
65
|
+
const override = overrideFor(overrides, rule);
|
|
66
66
|
if (override === false) continue;
|
|
67
67
|
rules.push(isObject(override) ? customRule(rule.id, override, rule) : rule);
|
|
68
68
|
}
|
|
@@ -70,12 +70,12 @@ function resolveRules(overrides = {}, words = []) {
|
|
|
70
70
|
for (const id in overrides) {
|
|
71
71
|
const override = overrides[id];
|
|
72
72
|
if (!isObject(override)) continue;
|
|
73
|
-
if (builtinRules.some((rule) => rule.id
|
|
73
|
+
if (builtinRules.some((rule) => namesOf(rule).includes(id))) continue;
|
|
74
74
|
rules.push(customRule(id, override));
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
if (words.length > 0) {
|
|
78
|
-
const escaped = words.map((word) =>
|
|
78
|
+
const escaped = words.map((word) => RegExp.escape(word));
|
|
79
79
|
rules.push({
|
|
80
80
|
id: "custom-word",
|
|
81
81
|
message: "Banned word (nollm config)",
|
|
@@ -86,6 +86,18 @@ function resolveRules(overrides = {}, words = []) {
|
|
|
86
86
|
return rules;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The config entry for a built in rule, under its id or one of its old ids.
|
|
91
|
+
*/
|
|
92
|
+
function overrideFor(overrides, rule) {
|
|
93
|
+
const name = namesOf(rule).find((candidate) => candidate in overrides);
|
|
94
|
+
return name === undefined ? undefined : overrides[name];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function namesOf(rule) {
|
|
98
|
+
return [rule.id].concat(rule.aliases ?? []);
|
|
99
|
+
}
|
|
100
|
+
|
|
89
101
|
/**
|
|
90
102
|
* Builds a rule from a config entry.
|
|
91
103
|
* Keys that the entry leaves out come from the built in rule, when there is one.
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
const run = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The checkout hint, for the one audience it helps.
|
|
10
|
+
*
|
|
11
|
+
* GitHub Actions checks out one branch at depth 1, which is what breaks
|
|
12
|
+
* --diff there. Anyone else gets the fetch line above it and no noise.
|
|
13
|
+
*/
|
|
14
|
+
function checkoutHint() {
|
|
15
|
+
if (process.env.GITHUB_ACTIONS !== "true") return [];
|
|
16
|
+
return ["In GitHub Actions, set fetch-depth: 0 on actions/checkout."];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Stands for every line of a file, used for files that are new in full. */
|
|
20
|
+
export const ALL_LINES = true;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The lines a branch adds or changes, per file.
|
|
24
|
+
*
|
|
25
|
+
* Returns a Map from path to a Set of line numbers in the new file, or to
|
|
26
|
+
* ALL_LINES when the whole file is new.
|
|
27
|
+
* Paths are relative to cwd, so they match what collectFiles returns.
|
|
28
|
+
*
|
|
29
|
+
* The comparison starts at the merge base of base and the working tree, so
|
|
30
|
+
* commits that land on base after the branch point stay out of the result.
|
|
31
|
+
* Uncommitted edits and files git does not track yet count as part of the
|
|
32
|
+
* branch, so the same call works before a push.
|
|
33
|
+
*
|
|
34
|
+
* Files with no added or changed lines are left out. So are deleted files.
|
|
35
|
+
*/
|
|
36
|
+
export async function changedLines(base, { cwd = process.cwd(), roots = ["."] } = {}) {
|
|
37
|
+
const changed = new Map();
|
|
38
|
+
for (const top of await repositories(roots, cwd)) {
|
|
39
|
+
for (const [file, lines] of await changedIn(base, top)) changed.set(file, lines);
|
|
40
|
+
}
|
|
41
|
+
return changed;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The repositories the roots live in, one entry each.
|
|
46
|
+
*
|
|
47
|
+
* A root names where to look, so that is where git is asked. Running nollm
|
|
48
|
+
* from one directory against another used to diff the directory it was run
|
|
49
|
+
* from, which is not the one holding the files.
|
|
50
|
+
*/
|
|
51
|
+
async function repositories(roots, cwd) {
|
|
52
|
+
const tops = new Set();
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < roots.length; i++) {
|
|
56
|
+
const absolute = resolve(cwd, roots[i]);
|
|
57
|
+
const dir = (await isDirectory(absolute)) ? absolute : dirname(absolute);
|
|
58
|
+
if (seen.has(dir)) continue;
|
|
59
|
+
seen.add(dir);
|
|
60
|
+
|
|
61
|
+
await checkBase(dir);
|
|
62
|
+
tops.add(await real(await topLevel(dir)));
|
|
63
|
+
}
|
|
64
|
+
return tops;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The lines one repository changed, keyed by absolute path.
|
|
69
|
+
*
|
|
70
|
+
* Paths are absolute because the caller labels files against its own
|
|
71
|
+
* directory, which is not always this repository.
|
|
72
|
+
*/
|
|
73
|
+
async function changedIn(base, top) {
|
|
74
|
+
await checkRef(base, top);
|
|
75
|
+
|
|
76
|
+
const args = [
|
|
77
|
+
"diff",
|
|
78
|
+
"--no-color",
|
|
79
|
+
"--no-ext-diff",
|
|
80
|
+
"--no-renames",
|
|
81
|
+
"--no-prefix",
|
|
82
|
+
"--unified=0",
|
|
83
|
+
"--diff-filter=ACM",
|
|
84
|
+
"--merge-base",
|
|
85
|
+
base,
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
let stdout;
|
|
89
|
+
try {
|
|
90
|
+
({ stdout } = await run("git", args, { cwd: top, maxBuffer: 256 * 1024 * 1024 }));
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const reason = firstLine(error.stderr) ?? error.message;
|
|
93
|
+
if (reason.includes("no merge base")) throw await noMergeBase(base, top);
|
|
94
|
+
throw new Error(`Could not diff against "${base}" in ${top}: ${reason}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const changed = new Map();
|
|
98
|
+
for (const [file, lines] of parse(stdout)) changed.set(resolve(top, file), lines);
|
|
99
|
+
for (const file of await untracked(top)) changed.set(resolve(top, file), ALL_LINES);
|
|
100
|
+
return changed;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function topLevel(dir) {
|
|
104
|
+
return (await tryGit(["rev-parse", "--show-toplevel"], dir))?.trim() ?? dir;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function isDirectory(path) {
|
|
108
|
+
try {
|
|
109
|
+
return (await stat(path)).isDirectory();
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The path with its symlinks followed, so it matches how files are labelled. */
|
|
116
|
+
async function real(path) {
|
|
117
|
+
try {
|
|
118
|
+
return await realpath(path);
|
|
119
|
+
} catch {
|
|
120
|
+
return path;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Says what is wrong with a base ref before git says it less clearly.
|
|
126
|
+
*
|
|
127
|
+
* A checkout that fetched one branch, or fetched to a shallow depth, is the
|
|
128
|
+
* usual reason a ref is missing. CI does both by default, so the ref a pull
|
|
129
|
+
* request is against is often the one that is not there.
|
|
130
|
+
*/
|
|
131
|
+
async function checkBase(dir) {
|
|
132
|
+
if ((await tryGit(["rev-parse", "--is-inside-work-tree"], dir)) === null) {
|
|
133
|
+
throw new Error(`Not a git repository, so --diff has nothing to compare: ${dir}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function checkRef(base, dir) {
|
|
138
|
+
if ((await tryGit(["rev-parse", "--verify", "-q", `${base}^{commit}`], dir)) !== null) return;
|
|
139
|
+
|
|
140
|
+
throw new Error(
|
|
141
|
+
[
|
|
142
|
+
`Could not find "${base}" in ${dir}.`,
|
|
143
|
+
`Fetch it with: git -C ${dir} fetch ${await fetchArgs(base, dir)}`,
|
|
144
|
+
...checkoutHint(),
|
|
145
|
+
].join("\n"),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The error for a ref that exists but shares no history with the branch.
|
|
151
|
+
*/
|
|
152
|
+
async function noMergeBase(base, dir) {
|
|
153
|
+
const shallow = (await tryGit(["rev-parse", "--is-shallow-repository"], dir))?.trim() === "true";
|
|
154
|
+
if (!shallow) {
|
|
155
|
+
return new Error(`"${base}" and the branch in ${dir} share no history, so there is no diff.`);
|
|
156
|
+
}
|
|
157
|
+
return new Error(
|
|
158
|
+
[
|
|
159
|
+
`No merge base with "${base}" in ${dir}. This clone is shallow, so the shared commit is missing.`,
|
|
160
|
+
`Deepen it with: git -C ${dir} fetch --unshallow`,
|
|
161
|
+
...checkoutHint(),
|
|
162
|
+
].join("\n"),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* How to fetch a missing ref. "origin/develop" needs "origin develop".
|
|
168
|
+
*
|
|
169
|
+
* A branch name may hold a slash of its own, so the first part counts as a
|
|
170
|
+
* remote only when the repository lists it as one. A repository with no
|
|
171
|
+
* remotes gives nothing to check against, so the usual reading wins.
|
|
172
|
+
*/
|
|
173
|
+
async function fetchArgs(base, cwd) {
|
|
174
|
+
const cut = base.indexOf("/");
|
|
175
|
+
if (cut <= 0) return `origin ${base}`;
|
|
176
|
+
|
|
177
|
+
const listed = (await tryGit(["remote"], cwd))?.trim();
|
|
178
|
+
const remotes = listed ? listed.split("\n") : [];
|
|
179
|
+
if (remotes.length === 0 || remotes.includes(base.slice(0, cut))) {
|
|
180
|
+
return `${base.slice(0, cut)} ${base.slice(cut + 1)}`;
|
|
181
|
+
}
|
|
182
|
+
return `origin ${base}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Runs git and returns its output, or null when it fails. */
|
|
186
|
+
async function tryGit(args, cwd) {
|
|
187
|
+
try {
|
|
188
|
+
const { stdout } = await run("git", args, { cwd });
|
|
189
|
+
return stdout;
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Files git does not track yet.
|
|
197
|
+
*
|
|
198
|
+
* collectFiles lists these, so a file a branch adds but has not committed
|
|
199
|
+
* still counts as part of the diff.
|
|
200
|
+
*/
|
|
201
|
+
async function untracked(cwd) {
|
|
202
|
+
let stdout;
|
|
203
|
+
try {
|
|
204
|
+
({ stdout } = await run("git", ["ls-files", "-z", "--others", "--exclude-standard"], {
|
|
205
|
+
cwd,
|
|
206
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
207
|
+
}));
|
|
208
|
+
} catch {
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const files = [];
|
|
213
|
+
const parts = stdout.split("\0");
|
|
214
|
+
for (let i = 0; i < parts.length; i++) {
|
|
215
|
+
if (parts[i].length > 0) files.push(parts[i]);
|
|
216
|
+
}
|
|
217
|
+
return files;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Reads a unified diff produced with --unified=0 and --no-prefix.
|
|
222
|
+
*
|
|
223
|
+
* Every file starts with a "diff --git" line, so that line marks where the
|
|
224
|
+
* next "+++" is a header and not a line of added content that starts with "++".
|
|
225
|
+
*/
|
|
226
|
+
function parse(patch) {
|
|
227
|
+
const changed = new Map();
|
|
228
|
+
const lines = patch.split("\n");
|
|
229
|
+
|
|
230
|
+
let file = null;
|
|
231
|
+
let expectHeader = false;
|
|
232
|
+
|
|
233
|
+
for (let i = 0; i < lines.length; i++) {
|
|
234
|
+
const line = lines[i];
|
|
235
|
+
|
|
236
|
+
if (line.startsWith("diff --git ")) {
|
|
237
|
+
file = null;
|
|
238
|
+
expectHeader = true;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (expectHeader && line.startsWith("+++ ")) {
|
|
243
|
+
const path = line.slice(4);
|
|
244
|
+
expectHeader = false;
|
|
245
|
+
if (path === "/dev/null") continue;
|
|
246
|
+
file = path;
|
|
247
|
+
if (!changed.has(file)) changed.set(file, new Set());
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (file === null || !line.startsWith("@@")) continue;
|
|
252
|
+
|
|
253
|
+
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
254
|
+
if (!hunk) continue;
|
|
255
|
+
|
|
256
|
+
const start = Number(hunk[1]);
|
|
257
|
+
const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
|
|
258
|
+
const set = changed.get(file);
|
|
259
|
+
for (let n = 0; n < count; n++) set.add(start + n);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
for (const [path, set] of changed) {
|
|
263
|
+
if (set.size === 0) changed.delete(path);
|
|
264
|
+
}
|
|
265
|
+
return changed;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function firstLine(text) {
|
|
269
|
+
if (!text) return null;
|
|
270
|
+
const trimmed = text.trim();
|
|
271
|
+
return trimmed.length > 0 ? trimmed.split("\n")[0] : null;
|
|
272
|
+
}
|
package/src/files.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { glob, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
5
|
import { promisify } from "node:util";
|
|
5
6
|
import ignore from "ignore";
|
|
6
7
|
|
|
@@ -8,27 +9,58 @@ const run = promisify(execFile);
|
|
|
8
9
|
|
|
9
10
|
const ALWAYS_SKIPPED = new Set([".git", "node_modules"]);
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* glob leaves dotfiles out unless a pattern asks for them, and the tool checks
|
|
14
|
+
* files such as .gitignore and everything under .github.
|
|
15
|
+
*/
|
|
16
|
+
const EVERYTHING = ["**/*", "**/.*", "**/.*/**"];
|
|
17
|
+
|
|
11
18
|
/**
|
|
12
19
|
* Lists the files to check.
|
|
13
20
|
*
|
|
14
|
-
*
|
|
21
|
+
* Roots may be relative or absolute.
|
|
22
|
+
* A file under cwd comes back relative to cwd, with forward slashes.
|
|
23
|
+
* A file outside cwd keeps its absolute path, so a report never has to point
|
|
24
|
+
* at it through a row of "..".
|
|
25
|
+
*
|
|
15
26
|
* Files that git ignores are left out.
|
|
16
27
|
*
|
|
17
28
|
* Inside a git work tree the list comes from git itself.
|
|
18
29
|
* Elsewhere, or with git: false, the tool reads .gitignore files while it walks.
|
|
30
|
+
* git only knows about its own work tree, so roots outside cwd are always
|
|
31
|
+
* walked.
|
|
19
32
|
*
|
|
20
|
-
* The ignore option takes patterns in .gitignore syntax.
|
|
33
|
+
* The ignore option takes patterns in .gitignore syntax. They describe the
|
|
34
|
+
* project, so they apply under cwd and leave paths outside cwd alone.
|
|
35
|
+
*
|
|
36
|
+
* Roots and cwd are compared after their symlinks are followed. On macOS a
|
|
37
|
+
* temp directory is reached through /var and lives in /private/var, and
|
|
38
|
+
* without this a path under cwd would look like a path outside it.
|
|
21
39
|
*/
|
|
22
40
|
export async function collectFiles(
|
|
23
41
|
roots,
|
|
24
42
|
{ cwd = process.cwd(), git = true, ignore: patterns = [] } = {},
|
|
25
43
|
) {
|
|
26
|
-
const
|
|
27
|
-
const
|
|
44
|
+
const base = await real(cwd);
|
|
45
|
+
const inside = [];
|
|
46
|
+
const outside = [];
|
|
47
|
+
for (let i = 0; i < roots.length; i++) {
|
|
48
|
+
const absolute = await real(resolve(cwd, roots[i]));
|
|
49
|
+
if (isInside(base, absolute)) inside.push(absolute);
|
|
50
|
+
else outside.push(absolute);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let list = [];
|
|
54
|
+
if (inside.length > 0) {
|
|
55
|
+
const tracked = git ? await fromGit(inside, cwd) : null;
|
|
56
|
+
list = tracked ?? (await fromWalk(inside, base));
|
|
57
|
+
}
|
|
58
|
+
if (outside.length > 0) list = list.concat(await fromWalk(outside, base));
|
|
59
|
+
|
|
28
60
|
if (patterns.length === 0) return list;
|
|
29
61
|
|
|
30
62
|
const matcher = ignore().add(patterns);
|
|
31
|
-
return list.filter((file) => !matcher.ignores(file));
|
|
63
|
+
return list.filter((file) => isAbsolute(file) || !matcher.ignores(file));
|
|
32
64
|
}
|
|
33
65
|
|
|
34
66
|
async function fromGit(roots, cwd) {
|
|
@@ -58,46 +90,64 @@ async function fromGit(roots, cwd) {
|
|
|
58
90
|
return files;
|
|
59
91
|
}
|
|
60
92
|
|
|
61
|
-
async function fromWalk(roots,
|
|
93
|
+
async function fromWalk(roots, base) {
|
|
62
94
|
const files = [];
|
|
63
95
|
for (let i = 0; i < roots.length; i++) {
|
|
64
|
-
const
|
|
65
|
-
const info = await stat(
|
|
96
|
+
const root = roots[i];
|
|
97
|
+
const info = await stat(root);
|
|
66
98
|
if (info.isFile()) {
|
|
67
|
-
files.push(
|
|
99
|
+
files.push(label(base, root));
|
|
68
100
|
continue;
|
|
69
101
|
}
|
|
70
|
-
|
|
102
|
+
|
|
103
|
+
const entries = glob(EVERYTHING, { cwd: root, withFileTypes: true, exclude: skips(root) });
|
|
104
|
+
for await (const entry of entries) {
|
|
105
|
+
if (entry.isFile()) files.push(label(base, join(entry.parentPath, entry.name)));
|
|
106
|
+
}
|
|
71
107
|
}
|
|
72
108
|
return files;
|
|
73
109
|
}
|
|
74
110
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Tells glob which entries to leave out. Saying yes to a directory prunes it,
|
|
113
|
+
* so an ignored tree is never opened.
|
|
114
|
+
*
|
|
115
|
+
* A .gitignore applies to the directory that holds it and to everything below.
|
|
116
|
+
* So each entry is matched against the chain of files from the root down to
|
|
117
|
+
* its own directory. Chains are built once per directory and kept.
|
|
118
|
+
*
|
|
119
|
+
* glob asks this question synchronously, so the reads are synchronous. It is
|
|
120
|
+
* one small file per directory, which is what the walk read before.
|
|
121
|
+
*/
|
|
122
|
+
function skips(root) {
|
|
123
|
+
const chains = new Map();
|
|
79
124
|
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
125
|
+
const chainFor = (dir) => {
|
|
126
|
+
const known = chains.get(dir);
|
|
127
|
+
if (known) return known;
|
|
128
|
+
|
|
129
|
+
const parent = dir === root || dirname(dir) === dir ? [] : chainFor(dirname(dir));
|
|
130
|
+
const local = readIgnore(dir);
|
|
131
|
+
const chain = local ? parent.concat([local]) : parent;
|
|
132
|
+
chains.set(dir, chain);
|
|
133
|
+
return chain;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return (entry) => {
|
|
137
|
+
if (ALWAYS_SKIPPED.has(entry.name)) return true;
|
|
83
138
|
|
|
84
|
-
const absolute = join(dir, entry.name);
|
|
85
139
|
const isDir = entry.isDirectory();
|
|
86
|
-
if (!isDir && !entry.isFile())
|
|
87
|
-
if (isIgnored(absolute, isDir, active)) continue;
|
|
140
|
+
if (!isDir && !entry.isFile()) return true;
|
|
88
141
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
files.push(toPosix(relative(cwd, absolute)));
|
|
93
|
-
}
|
|
94
|
-
}
|
|
142
|
+
const absolute = join(entry.parentPath, entry.name);
|
|
143
|
+
return isIgnored(absolute, isDir, chainFor(entry.parentPath));
|
|
144
|
+
};
|
|
95
145
|
}
|
|
96
146
|
|
|
97
|
-
|
|
147
|
+
function readIgnore(dir) {
|
|
98
148
|
let content;
|
|
99
149
|
try {
|
|
100
|
-
content =
|
|
150
|
+
content = readFileSync(join(dir, ".gitignore"), "utf8");
|
|
101
151
|
} catch {
|
|
102
152
|
return null;
|
|
103
153
|
}
|
|
@@ -113,6 +163,28 @@ function isIgnored(absolute, isDir, filters) {
|
|
|
113
163
|
return false;
|
|
114
164
|
}
|
|
115
165
|
|
|
166
|
+
/**
|
|
167
|
+
* How a file is named in a report: relative to the base directory when it sits
|
|
168
|
+
* under it, absolute when it does not.
|
|
169
|
+
*/
|
|
170
|
+
function label(base, absolute) {
|
|
171
|
+
return toPosix(isInside(base, absolute) ? relative(base, absolute) : absolute);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isInside(base, absolute) {
|
|
175
|
+
const rel = relative(base, absolute);
|
|
176
|
+
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The path with its symlinks followed, or the path itself if it is missing. */
|
|
180
|
+
async function real(path) {
|
|
181
|
+
try {
|
|
182
|
+
return await realpath(path);
|
|
183
|
+
} catch {
|
|
184
|
+
return path;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
116
188
|
function toPosix(path) {
|
|
117
189
|
return sep === "/" ? path : path.split(sep).join("/");
|
|
118
190
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ export type Scope = "prose" | "comments" | "text" | "everywhere";
|
|
|
2
2
|
|
|
3
3
|
export interface PatternRule {
|
|
4
4
|
id: string;
|
|
5
|
+
/** Old ids of this rule. A config may still refer to the rule by one of them. */
|
|
6
|
+
aliases?: string[];
|
|
5
7
|
message: string;
|
|
6
8
|
pattern: RegExp;
|
|
7
9
|
scope?: Scope;
|
|
@@ -15,6 +17,8 @@ export interface ShapeFinding {
|
|
|
15
17
|
|
|
16
18
|
export interface CheckRule {
|
|
17
19
|
id: string;
|
|
20
|
+
/** Old ids of this rule. A config may still refer to the rule by one of them. */
|
|
21
|
+
aliases?: string[];
|
|
18
22
|
message: string;
|
|
19
23
|
check: (segments: Segment[], scope: Scope) => ShapeFinding[];
|
|
20
24
|
scope?: Scope;
|
|
@@ -82,6 +86,8 @@ export interface LintOptions {
|
|
|
82
86
|
cwd?: string;
|
|
83
87
|
configPath?: string;
|
|
84
88
|
git?: boolean;
|
|
89
|
+
/** A git ref. Only lines added or changed since the merge base are reported. */
|
|
90
|
+
diff?: string;
|
|
85
91
|
jobs?: number;
|
|
86
92
|
onResult?: (result: FileResult) => void;
|
|
87
93
|
}
|
|
@@ -93,6 +99,11 @@ export function check(filePath: string, source: string, rules?: Rule[]): Finding
|
|
|
93
99
|
export function classify(
|
|
94
100
|
filePath: string,
|
|
95
101
|
): { kind: "prose" } | { kind: "code"; language: Language } | null;
|
|
102
|
+
export const ALL_LINES: true;
|
|
103
|
+
export function changedLines(
|
|
104
|
+
base: string,
|
|
105
|
+
options?: { cwd?: string; roots?: string[] },
|
|
106
|
+
): Promise<Map<string, Set<number> | typeof ALL_LINES>>;
|
|
96
107
|
export function collectFiles(
|
|
97
108
|
roots: string[],
|
|
98
109
|
options?: { cwd?: string; git?: boolean; ignore?: string[] },
|
package/src/index.js
CHANGED
package/src/lint.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
1
2
|
import { resolve } from "node:path";
|
|
2
3
|
import { Tinypool } from "tinypool";
|
|
3
4
|
import { findConfig, loadConfig } from "./config.js";
|
|
5
|
+
import { ALL_LINES, changedLines } from "./diff.js";
|
|
4
6
|
import { collectFiles } from "./files.js";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Checks every file under the given roots.
|
|
8
10
|
*
|
|
11
|
+
* With diff set to a git ref, only the files that changed since the merge base
|
|
12
|
+
* are read, and only findings on added or changed lines are reported.
|
|
13
|
+
*
|
|
9
14
|
* onResult runs once per file, as soon as that file is done.
|
|
10
15
|
* The returned promise resolves after the last file.
|
|
11
16
|
*/
|
|
@@ -14,38 +19,70 @@ export async function lint({
|
|
|
14
19
|
cwd = process.cwd(),
|
|
15
20
|
configPath,
|
|
16
21
|
git = true,
|
|
22
|
+
diff,
|
|
17
23
|
jobs,
|
|
18
24
|
onResult = () => {},
|
|
19
25
|
} = {}) {
|
|
20
26
|
const resolvedConfig = configPath ? resolve(cwd, configPath) : await findConfig(cwd);
|
|
21
27
|
const config = await loadConfig(resolvedConfig);
|
|
22
|
-
|
|
28
|
+
let files = await collectFiles(roots, { cwd, git, ignore: config.ignore });
|
|
29
|
+
|
|
30
|
+
// collectFiles labels files against the real cwd, so absolute paths built
|
|
31
|
+
// from those labels line up with the ones the diff reports.
|
|
32
|
+
let changed = null;
|
|
33
|
+
let base = cwd;
|
|
34
|
+
if (diff) {
|
|
35
|
+
changed = await changedLines(diff, { cwd, roots });
|
|
36
|
+
base = await realCwd(cwd);
|
|
37
|
+
files = files.filter((file) => changed.has(resolve(base, file)));
|
|
38
|
+
}
|
|
23
39
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
40
|
+
// The pool is torn down when this function ends, however it ends.
|
|
41
|
+
await using pool = Object.assign(
|
|
42
|
+
new Tinypool({
|
|
43
|
+
filename: new URL("./worker.js", import.meta.url).href,
|
|
44
|
+
workerData: { configPath: resolvedConfig, cwd },
|
|
45
|
+
maxThreads: jobs,
|
|
46
|
+
}),
|
|
47
|
+
{ [Symbol.asyncDispose]: () => pool.destroy() },
|
|
48
|
+
);
|
|
29
49
|
|
|
30
50
|
const summary = { files: files.length, checked: 0, findings: 0, filesWithFindings: 0 };
|
|
31
51
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
await Promise.all(pending);
|
|
46
|
-
} finally {
|
|
47
|
-
await pool.destroy();
|
|
52
|
+
const pending = [];
|
|
53
|
+
for (let i = 0; i < files.length; i++) {
|
|
54
|
+
const done = pool.run(files[i]).then((result) => {
|
|
55
|
+
if (changed) result = onlyChanged(result, changed.get(resolve(base, result.file)));
|
|
56
|
+
if (!result.skipped) summary.checked++;
|
|
57
|
+
if (result.findings.length > 0) {
|
|
58
|
+
summary.findings += result.findings.length;
|
|
59
|
+
summary.filesWithFindings++;
|
|
60
|
+
}
|
|
61
|
+
onResult(result);
|
|
62
|
+
});
|
|
63
|
+
pending.push(done);
|
|
48
64
|
}
|
|
65
|
+
await Promise.all(pending);
|
|
49
66
|
|
|
50
67
|
return summary;
|
|
51
68
|
}
|
|
69
|
+
|
|
70
|
+
async function realCwd(cwd) {
|
|
71
|
+
try {
|
|
72
|
+
return await realpath(cwd);
|
|
73
|
+
} catch {
|
|
74
|
+
return cwd;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Keeps the findings that sit on a line the branch adds or changes.
|
|
80
|
+
*
|
|
81
|
+
* A finding is placed by the line it points at. A rule that reports at the top
|
|
82
|
+
* of a paragraph, such as wall-of-text, is therefore quiet when the branch
|
|
83
|
+
* extends that paragraph further down.
|
|
84
|
+
*/
|
|
85
|
+
function onlyChanged(result, lines) {
|
|
86
|
+
if (lines === ALL_LINES || result.findings.length === 0) return result;
|
|
87
|
+
return { ...result, findings: result.findings.filter((finding) => lines.has(finding.line)) };
|
|
88
|
+
}
|
package/src/rules.js
CHANGED
|
@@ -131,17 +131,55 @@ const AI_DISCLOSURES = [
|
|
|
131
131
|
"my training data",
|
|
132
132
|
];
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
// Talk that only makes sense while the change is under review.
|
|
135
|
+
// Once merged, nobody reading the code knows what "the old way" or "this PR" was.
|
|
136
|
+
const PR_TALK = [
|
|
137
|
+
// The change itself
|
|
135
138
|
"as requested",
|
|
136
139
|
"as discussed",
|
|
137
140
|
"per the discussion",
|
|
138
141
|
"per our discussion",
|
|
139
142
|
"per the review",
|
|
140
143
|
"per review",
|
|
144
|
+
"per feedback",
|
|
145
|
+
"based on feedback",
|
|
146
|
+
"review feedback",
|
|
147
|
+
"review comment",
|
|
148
|
+
"review comments",
|
|
149
|
+
"addressing feedback",
|
|
150
|
+
"addresses feedback",
|
|
151
|
+
"addressed feedback",
|
|
152
|
+
"requested changes",
|
|
153
|
+
"code review",
|
|
154
|
+
"reviewer",
|
|
155
|
+
"reviewers",
|
|
141
156
|
"this pr",
|
|
157
|
+
"this mr",
|
|
158
|
+
"pr description",
|
|
159
|
+
"pull request",
|
|
160
|
+
"merge request",
|
|
142
161
|
"this commit",
|
|
143
162
|
"this diff",
|
|
144
163
|
"this change",
|
|
164
|
+
"this refactor",
|
|
165
|
+
"the refactor",
|
|
166
|
+
"this migration",
|
|
167
|
+
"this upgrade",
|
|
168
|
+
"the upgrade",
|
|
169
|
+
"after upgrading",
|
|
170
|
+
"before upgrading",
|
|
171
|
+
"since upgrading",
|
|
172
|
+
"version bump",
|
|
173
|
+
"this bump",
|
|
174
|
+
"cherry-picked",
|
|
175
|
+
"cherry picked",
|
|
176
|
+
"backported",
|
|
177
|
+
"rebased",
|
|
178
|
+
"merge conflict",
|
|
179
|
+
"merge conflicts",
|
|
180
|
+
"work in progress",
|
|
181
|
+
"wip",
|
|
182
|
+
// How the code used to be
|
|
145
183
|
"no longer",
|
|
146
184
|
"used to be",
|
|
147
185
|
"used to use",
|
|
@@ -164,10 +202,176 @@ const DIFF_TALK = [
|
|
|
164
202
|
"now handles",
|
|
165
203
|
"now lives",
|
|
166
204
|
"moved to",
|
|
205
|
+
"never worked",
|
|
206
|
+
"wasn't working",
|
|
207
|
+
"was not working",
|
|
208
|
+
"weren't working",
|
|
209
|
+
"were not working",
|
|
210
|
+
"didn't work before",
|
|
211
|
+
"did not work before",
|
|
212
|
+
"stopped working",
|
|
213
|
+
"was failing",
|
|
214
|
+
"were failing",
|
|
215
|
+
"started failing",
|
|
216
|
+
"kept failing",
|
|
217
|
+
"on the main branch",
|
|
218
|
+
"on the master branch",
|
|
219
|
+
"compared to main",
|
|
220
|
+
"compared to master",
|
|
221
|
+
// Claims that nothing changed
|
|
222
|
+
"no behavior change",
|
|
223
|
+
"no behaviour change",
|
|
224
|
+
"no functional change",
|
|
225
|
+
"no functional changes",
|
|
226
|
+
"no-op change",
|
|
227
|
+
"behavior is unchanged",
|
|
228
|
+
"behaviour is unchanged",
|
|
229
|
+
"behavior unchanged",
|
|
230
|
+
"behaviour unchanged",
|
|
231
|
+
"no change in behavior",
|
|
232
|
+
"no change in behaviour",
|
|
233
|
+
"same as before",
|
|
234
|
+
"same behavior as before",
|
|
235
|
+
"same behaviour as before",
|
|
236
|
+
"behaves as before",
|
|
237
|
+
"behaves the same",
|
|
238
|
+
"works the same",
|
|
239
|
+
"functionally equivalent",
|
|
240
|
+
"functionally identical",
|
|
241
|
+
"preserves behavior",
|
|
242
|
+
"preserves behaviour",
|
|
243
|
+
"preserve behavior",
|
|
244
|
+
"preserve behaviour",
|
|
245
|
+
"preserves existing behavior",
|
|
246
|
+
"preserves existing behaviour",
|
|
247
|
+
"existing behavior",
|
|
248
|
+
"existing behaviour",
|
|
249
|
+
"unchanged from",
|
|
250
|
+
// Leaving things as they were
|
|
251
|
+
"keeping it off",
|
|
252
|
+
"keeping it on",
|
|
253
|
+
"keeping this off",
|
|
254
|
+
"keeping this on",
|
|
255
|
+
"keeping it disabled",
|
|
256
|
+
"keeping it enabled",
|
|
257
|
+
"keeping this disabled",
|
|
258
|
+
"keeping this enabled",
|
|
259
|
+
"keep it off",
|
|
260
|
+
"keep this off",
|
|
261
|
+
"kept as is",
|
|
262
|
+
"kept as-is",
|
|
263
|
+
"left as is",
|
|
264
|
+
"left as-is",
|
|
265
|
+
"leave as is",
|
|
266
|
+
"leave as-is",
|
|
267
|
+
"leaving as is",
|
|
268
|
+
"leaving as-is",
|
|
269
|
+
"left in place",
|
|
270
|
+
"left untouched",
|
|
271
|
+
"left alone",
|
|
272
|
+
"leaving this alone",
|
|
273
|
+
"not touching",
|
|
274
|
+
"didn't touch",
|
|
275
|
+
"did not touch",
|
|
276
|
+
// Scope and follow-ups
|
|
277
|
+
"for now",
|
|
278
|
+
"out of scope",
|
|
279
|
+
"scope of this",
|
|
280
|
+
"follow-up pr",
|
|
281
|
+
"follow up pr",
|
|
282
|
+
"in a follow-up",
|
|
283
|
+
"in a follow up",
|
|
284
|
+
"as a follow-up",
|
|
285
|
+
"as a follow up",
|
|
286
|
+
"separate pr",
|
|
287
|
+
"later pr",
|
|
288
|
+
"future pr",
|
|
289
|
+
"another pr",
|
|
290
|
+
"next pr",
|
|
291
|
+
"will be removed",
|
|
292
|
+
"can be removed",
|
|
293
|
+
"to be removed",
|
|
294
|
+
"should be removed",
|
|
295
|
+
"safe to remove",
|
|
296
|
+
"safe to delete",
|
|
297
|
+
"can be deleted",
|
|
298
|
+
"will be deleted",
|
|
299
|
+
"remove this once",
|
|
300
|
+
"remove this after",
|
|
301
|
+
"remove this when",
|
|
302
|
+
"delete this once",
|
|
303
|
+
"delete this after",
|
|
304
|
+
"clean up later",
|
|
305
|
+
"cleanup later",
|
|
306
|
+
"clean this up later",
|
|
307
|
+
"will clean up",
|
|
308
|
+
"will be cleaned up",
|
|
309
|
+
"quick fix",
|
|
310
|
+
"quick hack",
|
|
311
|
+
"quick and dirty",
|
|
312
|
+
"band-aid",
|
|
313
|
+
"bandaid",
|
|
314
|
+
"band aid",
|
|
315
|
+
"stopgap",
|
|
316
|
+
"stop-gap",
|
|
317
|
+
"stop gap",
|
|
318
|
+
"hotfix",
|
|
319
|
+
"hot fix",
|
|
320
|
+
// The author talking about their own process
|
|
321
|
+
"tested locally",
|
|
322
|
+
"verified locally",
|
|
323
|
+
"confirmed locally",
|
|
324
|
+
"works locally",
|
|
325
|
+
"tested with",
|
|
326
|
+
"tested on",
|
|
327
|
+
"tested against",
|
|
328
|
+
"verified with",
|
|
329
|
+
"confirmed with",
|
|
330
|
+
"ci passes",
|
|
331
|
+
"passes ci",
|
|
332
|
+
"ci is green",
|
|
333
|
+
"ci was failing",
|
|
334
|
+
"ci fails",
|
|
335
|
+
"ci failed",
|
|
336
|
+
"i tested",
|
|
337
|
+
"i verified",
|
|
338
|
+
"i checked",
|
|
339
|
+
"i confirmed",
|
|
340
|
+
"i tried",
|
|
341
|
+
"i went with",
|
|
342
|
+
"i chose",
|
|
343
|
+
"i opted",
|
|
344
|
+
"i decided",
|
|
345
|
+
"i ended up",
|
|
346
|
+
"i left",
|
|
347
|
+
"i kept",
|
|
348
|
+
"i removed",
|
|
349
|
+
"i replaced",
|
|
350
|
+
"i added",
|
|
351
|
+
"i moved",
|
|
352
|
+
"i renamed",
|
|
353
|
+
"i changed",
|
|
354
|
+
"i updated",
|
|
355
|
+
"i bumped",
|
|
356
|
+
"i couldn't",
|
|
357
|
+
"i could not",
|
|
358
|
+
"i'm not sure",
|
|
359
|
+
"i am not sure",
|
|
360
|
+
"not sure if",
|
|
361
|
+
"not sure why",
|
|
362
|
+
"not sure whether",
|
|
363
|
+
"we tested",
|
|
364
|
+
"we verified",
|
|
365
|
+
"we confirmed",
|
|
366
|
+
"we decided",
|
|
367
|
+
"we went with",
|
|
368
|
+
"we opted",
|
|
369
|
+
"we couldn't",
|
|
370
|
+
"we could not",
|
|
167
371
|
];
|
|
168
372
|
|
|
169
|
-
//
|
|
170
|
-
const
|
|
373
|
+
// PR talk only when it opens a sentence. "used previously" is fine.
|
|
374
|
+
const PR_OPENERS = ["previously", "formerly", "originally"];
|
|
171
375
|
|
|
172
376
|
const DRAMATIC_VERBS = [
|
|
173
377
|
"blow up",
|
|
@@ -323,10 +527,11 @@ export const rules = [
|
|
|
323
527
|
scope: "prose",
|
|
324
528
|
},
|
|
325
529
|
{
|
|
326
|
-
id: "
|
|
327
|
-
|
|
530
|
+
id: "no-short-term-relevance",
|
|
531
|
+
aliases: ["diff-comment"],
|
|
532
|
+
message: "Comment only makes sense while the change is under review. Say it in the PR",
|
|
328
533
|
pattern: new RegExp(
|
|
329
|
-
String.raw`\b(?:${words(
|
|
534
|
+
String.raw`\b(?:${words(PR_TALK)})\b|${SENTENCE_START}(?:${words(PR_OPENERS)})\b`,
|
|
330
535
|
"gmi",
|
|
331
536
|
),
|
|
332
537
|
scope: "comments",
|