nollm 0.2.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 +49 -2
- package/package.json +3 -3
- package/src/cli.js +13 -21
- package/src/config.js +1 -1
- package/src/diff.js +272 -0
- package/src/files.js +102 -30
- package/src/index.d.ts +7 -0
- package/src/index.js +1 -0
- package/src/lint.js +59 -22
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.
|
|
@@ -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 },
|
|
@@ -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
|
@@ -75,7 +75,7 @@ function resolveRules(overrides = {}, words = []) {
|
|
|
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)",
|
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
|
@@ -86,6 +86,8 @@ export interface LintOptions {
|
|
|
86
86
|
cwd?: string;
|
|
87
87
|
configPath?: string;
|
|
88
88
|
git?: boolean;
|
|
89
|
+
/** A git ref. Only lines added or changed since the merge base are reported. */
|
|
90
|
+
diff?: string;
|
|
89
91
|
jobs?: number;
|
|
90
92
|
onResult?: (result: FileResult) => void;
|
|
91
93
|
}
|
|
@@ -97,6 +99,11 @@ export function check(filePath: string, source: string, rules?: Rule[]): Finding
|
|
|
97
99
|
export function classify(
|
|
98
100
|
filePath: string,
|
|
99
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>>;
|
|
100
107
|
export function collectFiles(
|
|
101
108
|
roots: string[],
|
|
102
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
|
+
}
|