nollm 0.2.0 → 0.4.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 CHANGED
@@ -30,7 +30,7 @@ Add a script to `package.json`:
30
30
  }
31
31
  ```
32
32
 
33
- Requires Node 22.13 or newer.
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.
@@ -90,7 +136,7 @@ Files of other types, binary files, lockfiles, minified files, and files over 2
90
136
  | `chat-opener` | Lines that start with "Great question", "Certainly", "Let me", and more |
91
137
  | `chat-closer` | "Hope this helps", "Let me know if", "Feel free to", and more |
92
138
  | `ai-disclosure` | "As an AI", "my training data", and more |
93
- | `contrast-cliche` | "not just X, but Y" and "it's not X, it's Y" |
139
+ | `contrast-cliche` | "not just X, but Y", "it's not X, it's Y", and "X, not Y: the rest" |
94
140
  | `rhetorical-question` | "Why? Because" and "The result?" |
95
141
  | `emoji-list` | List items that start with an emoji |
96
142
  | `no-short-term-relevance` | Comments that stop making sense once the change lands: "no longer", "no behavior change", "for now", "Previously," |
@@ -98,6 +144,7 @@ Files of other types, binary files, lockfiles, minified files, and files over 2
98
144
  | `quoted-error` | Comments that quote an error message: `"Cannot read properties of..."` |
99
145
  | `dramatic-verb` | blows up, dies with, falls over, chokes on, and friends |
100
146
  | `parenthetical-aside` | Asides like `(and their compiled handles)` |
147
+ | `mid-phrase-break` | A line that stops mid phrase, on a word such as the, of, or that |
101
148
  | `long-sentence` | A sentence over 30 words |
102
149
  | `wall-of-text` | A paragraph over 120 words or 7 sentences |
103
150
  | `uniform-paragraphs` | Three or more paragraphs in a row of about the same length |
@@ -175,6 +222,7 @@ const findings = check("README.md", "This is simply the best.");
175
222
 
176
223
  const summary = await lint({
177
224
  roots: ["src", "docs"],
225
+ diff: "origin/main",
178
226
  onResult({ file, findings }) {
179
227
  // runs once per file, as soon as it is done
180
228
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nollm",
3
- "version": "0.2.0",
3
+ "version": "0.4.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/node-lts": "^24.0.1",
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": ">= 22.5"
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
- let width = 0;
135
- for (let i = 0; i < group.items.length; i++) {
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
- for (let i = 0; i < group.items.length; i++) {
141
- const item = group.items[i];
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) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
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,275 @@
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,
26
+ * or to 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 branch,
32
+ * 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,
128
+ * is the usual reason a ref is missing.
129
+ * CI does both by default,
130
+ * so the ref a branch is compared against is often the one that is missing.
131
+ */
132
+ async function checkBase(dir) {
133
+ if ((await tryGit(["rev-parse", "--is-inside-work-tree"], dir)) === null) {
134
+ throw new Error(`Not a git repository, so --diff has nothing to compare: ${dir}`);
135
+ }
136
+ }
137
+
138
+ async function checkRef(base, dir) {
139
+ if ((await tryGit(["rev-parse", "--verify", "-q", `${base}^{commit}`], dir)) !== null) return;
140
+
141
+ throw new Error(
142
+ [
143
+ `Could not find "${base}" in ${dir}.`,
144
+ `Fetch it with: git -C ${dir} fetch ${await fetchArgs(base, dir)}`,
145
+ ...checkoutHint(),
146
+ ].join("\n"),
147
+ );
148
+ }
149
+
150
+ /**
151
+ * The error for a ref that exists but shares no history with the branch.
152
+ */
153
+ async function noMergeBase(base, dir) {
154
+ const shallow = (await tryGit(["rev-parse", "--is-shallow-repository"], dir))?.trim() === "true";
155
+ if (!shallow) {
156
+ return new Error(`"${base}" and the branch in ${dir} share no history, so there is no diff.`);
157
+ }
158
+ return new Error(
159
+ [
160
+ `No merge base with "${base}" in ${dir}. This clone is shallow, so the shared commit is missing.`,
161
+ `Deepen it with: git -C ${dir} fetch --unshallow`,
162
+ ...checkoutHint(),
163
+ ].join("\n"),
164
+ );
165
+ }
166
+
167
+ /**
168
+ * How to fetch a missing ref. "origin/develop" needs "origin develop".
169
+ *
170
+ * A branch name may hold a slash of its own,
171
+ * so the first part counts as a remote only when the repository lists it as one.
172
+ * A repository with no remotes gives nothing to check against,
173
+ * so the usual reading wins.
174
+ */
175
+ async function fetchArgs(base, cwd) {
176
+ const cut = base.indexOf("/");
177
+ if (cut <= 0) return `origin ${base}`;
178
+
179
+ const listed = (await tryGit(["remote"], cwd))?.trim();
180
+ const remotes = listed ? listed.split("\n") : [];
181
+ if (remotes.length === 0 || remotes.includes(base.slice(0, cut))) {
182
+ return `${base.slice(0, cut)} ${base.slice(cut + 1)}`;
183
+ }
184
+ return `origin ${base}`;
185
+ }
186
+
187
+ /** Runs git and returns its output, or null when it fails. */
188
+ async function tryGit(args, cwd) {
189
+ try {
190
+ const { stdout } = await run("git", args, { cwd });
191
+ return stdout;
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Files git does not track yet.
199
+ *
200
+ * collectFiles lists these, so a file a branch adds but has not committed
201
+ * still counts as part of the diff.
202
+ */
203
+ async function untracked(cwd) {
204
+ let stdout;
205
+ try {
206
+ ({ stdout } = await run("git", ["ls-files", "-z", "--others", "--exclude-standard"], {
207
+ cwd,
208
+ maxBuffer: 256 * 1024 * 1024,
209
+ }));
210
+ } catch {
211
+ return [];
212
+ }
213
+
214
+ const files = [];
215
+ const parts = stdout.split("\0");
216
+ for (let i = 0; i < parts.length; i++) {
217
+ if (parts[i].length > 0) files.push(parts[i]);
218
+ }
219
+ return files;
220
+ }
221
+
222
+ /**
223
+ * Reads a unified diff produced with --unified=0 and --no-prefix.
224
+ *
225
+ * Every file starts with a "diff --git" line,
226
+ * so that line marks where the next "+++" is a header,
227
+ * and not a line of added content that starts with "++".
228
+ */
229
+ function parse(patch) {
230
+ const changed = new Map();
231
+ const lines = patch.split("\n");
232
+
233
+ let file = null;
234
+ let expectHeader = false;
235
+
236
+ for (let i = 0; i < lines.length; i++) {
237
+ const line = lines[i];
238
+
239
+ if (line.startsWith("diff --git ")) {
240
+ file = null;
241
+ expectHeader = true;
242
+ continue;
243
+ }
244
+
245
+ if (expectHeader && line.startsWith("+++ ")) {
246
+ const path = line.slice(4);
247
+ expectHeader = false;
248
+ if (path === "/dev/null") continue;
249
+ file = path;
250
+ if (!changed.has(file)) changed.set(file, new Set());
251
+ continue;
252
+ }
253
+
254
+ if (file === null || !line.startsWith("@@")) continue;
255
+
256
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
257
+ if (!hunk) continue;
258
+
259
+ const start = Number(hunk[1]);
260
+ const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
261
+ const set = changed.get(file);
262
+ for (let n = 0; n < count; n++) set.add(start + n);
263
+ }
264
+
265
+ for (const [path, set] of changed) {
266
+ if (set.size === 0) changed.delete(path);
267
+ }
268
+ return changed;
269
+ }
270
+
271
+ function firstLine(text) {
272
+ if (!text) return null;
273
+ const trimmed = text.trim();
274
+ return trimmed.length > 0 ? trimmed.split("\n")[0] : null;
275
+ }
package/src/files.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
- import { readdir, readFile, stat } from "node:fs/promises";
3
- import { join, relative, resolve, sep } from "node:path";
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,59 @@ 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
- * Paths come back relative to cwd, with forward slashes.
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
33
  * The ignore option takes patterns in .gitignore syntax.
34
+ * They describe the project, so they apply under cwd
35
+ * and leave paths outside cwd alone.
36
+ *
37
+ * Roots and cwd are compared after their symlinks are followed.
38
+ * On macOS a temp directory is reached through /var and lives in /private/var,
39
+ * so without this a path under cwd would look like a path outside it.
21
40
  */
22
41
  export async function collectFiles(
23
42
  roots,
24
43
  { cwd = process.cwd(), git = true, ignore: patterns = [] } = {},
25
44
  ) {
26
- const files = git ? await fromGit(roots, cwd) : null;
27
- const list = files ?? (await fromWalk(roots, cwd));
45
+ const base = await real(cwd);
46
+ const inside = [];
47
+ const outside = [];
48
+ for (let i = 0; i < roots.length; i++) {
49
+ const absolute = await real(resolve(cwd, roots[i]));
50
+ if (isInside(base, absolute)) inside.push(absolute);
51
+ else outside.push(absolute);
52
+ }
53
+
54
+ let list = [];
55
+ if (inside.length > 0) {
56
+ const tracked = git ? await fromGit(inside, cwd) : null;
57
+ list = tracked ?? (await fromWalk(inside, base));
58
+ }
59
+ if (outside.length > 0) list = list.concat(await fromWalk(outside, base));
60
+
28
61
  if (patterns.length === 0) return list;
29
62
 
30
63
  const matcher = ignore().add(patterns);
31
- return list.filter((file) => !matcher.ignores(file));
64
+ return list.filter((file) => isAbsolute(file) || !matcher.ignores(file));
32
65
  }
33
66
 
34
67
  async function fromGit(roots, cwd) {
@@ -58,46 +91,65 @@ async function fromGit(roots, cwd) {
58
91
  return files;
59
92
  }
60
93
 
61
- async function fromWalk(roots, cwd) {
94
+ async function fromWalk(roots, base) {
62
95
  const files = [];
63
96
  for (let i = 0; i < roots.length; i++) {
64
- const absolute = resolve(cwd, roots[i]);
65
- const info = await stat(absolute);
97
+ const root = roots[i];
98
+ const info = await stat(root);
66
99
  if (info.isFile()) {
67
- files.push(toPosix(relative(cwd, absolute)));
100
+ files.push(label(base, root));
68
101
  continue;
69
102
  }
70
- await walk(absolute, cwd, [], files);
103
+
104
+ const entries = glob(EVERYTHING, { cwd: root, withFileTypes: true, exclude: skips(root) });
105
+ for await (const entry of entries) {
106
+ if (entry.isFile()) files.push(label(base, join(entry.parentPath, entry.name)));
107
+ }
71
108
  }
72
109
  return files;
73
110
  }
74
111
 
75
- async function walk(dir, cwd, filters, files) {
76
- const local = await readIgnore(dir);
77
- const active = local ? filters.concat([local]) : filters;
78
- const entries = await readdir(dir, { withFileTypes: true });
112
+ /**
113
+ * Tells glob which entries to leave out. Saying yes to a directory prunes it,
114
+ * so an ignored tree is never opened.
115
+ *
116
+ * A .gitignore applies to the directory that holds it and to everything below.
117
+ * So each entry is matched against the chain of files
118
+ * from the root down to its own directory.
119
+ * Chains are built once per directory and kept.
120
+ *
121
+ * glob asks this question synchronously, so the reads are synchronous.
122
+ * It is one small file per directory, which is what the walk read before.
123
+ */
124
+ function skips(root) {
125
+ const chains = new Map();
79
126
 
80
- for (let i = 0; i < entries.length; i++) {
81
- const entry = entries[i];
82
- if (ALWAYS_SKIPPED.has(entry.name)) continue;
127
+ const chainFor = (dir) => {
128
+ const known = chains.get(dir);
129
+ if (known) return known;
130
+
131
+ const parent = dir === root || dirname(dir) === dir ? [] : chainFor(dirname(dir));
132
+ const local = readIgnore(dir);
133
+ const chain = local ? parent.concat([local]) : parent;
134
+ chains.set(dir, chain);
135
+ return chain;
136
+ };
137
+
138
+ return (entry) => {
139
+ if (ALWAYS_SKIPPED.has(entry.name)) return true;
83
140
 
84
- const absolute = join(dir, entry.name);
85
141
  const isDir = entry.isDirectory();
86
- if (!isDir && !entry.isFile()) continue;
87
- if (isIgnored(absolute, isDir, active)) continue;
142
+ if (!isDir && !entry.isFile()) return true;
88
143
 
89
- if (isDir) {
90
- await walk(absolute, cwd, active, files);
91
- } else {
92
- files.push(toPosix(relative(cwd, absolute)));
93
- }
94
- }
144
+ const absolute = join(entry.parentPath, entry.name);
145
+ return isIgnored(absolute, isDir, chainFor(entry.parentPath));
146
+ };
95
147
  }
96
148
 
97
- async function readIgnore(dir) {
149
+ function readIgnore(dir) {
98
150
  let content;
99
151
  try {
100
- content = await readFile(join(dir, ".gitignore"), "utf8");
152
+ content = readFileSync(join(dir, ".gitignore"), "utf8");
101
153
  } catch {
102
154
  return null;
103
155
  }
@@ -113,6 +165,28 @@ function isIgnored(absolute, isDir, filters) {
113
165
  return false;
114
166
  }
115
167
 
168
+ /**
169
+ * How a file is named in a report: relative to the base directory when it sits
170
+ * under it, absolute when it does not.
171
+ */
172
+ function label(base, absolute) {
173
+ return toPosix(isInside(base, absolute) ? relative(base, absolute) : absolute);
174
+ }
175
+
176
+ function isInside(base, absolute) {
177
+ const rel = relative(base, absolute);
178
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
179
+ }
180
+
181
+ /** The path with its symlinks followed, or the path itself if it is missing. */
182
+ async function real(path) {
183
+ try {
184
+ return await realpath(path);
185
+ } catch {
186
+ return path;
187
+ }
188
+ }
189
+
116
190
  function toPosix(path) {
117
191
  return sep === "/" ? path : path.split(sep).join("/");
118
192
  }
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
@@ -1,4 +1,5 @@
1
1
  export { check } from "./check.js";
2
+ export { ALL_LINES, changedLines } from "./diff.js";
2
3
  export { classify } from "./languages.js";
3
4
  export { collectFiles } from "./files.js";
4
5
  export { extractComments, extractLines } from "./comments.js";
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
- const files = await collectFiles(roots, { cwd, git, ignore: config.ignore });
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
- const pool = new Tinypool({
25
- filename: new URL("./worker.js", import.meta.url).href,
26
- workerData: { configPath: resolvedConfig, cwd },
27
- maxThreads: jobs,
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
- try {
33
- const pending = [];
34
- for (let i = 0; i < files.length; i++) {
35
- const done = pool.run(files[i]).then((result) => {
36
- if (!result.skipped) summary.checked++;
37
- if (result.findings.length > 0) {
38
- summary.findings += result.findings.length;
39
- summary.filesWithFindings++;
40
- }
41
- onResult(result);
42
- });
43
- pending.push(done);
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
@@ -1,4 +1,10 @@
1
- import { longSentences, uniformParagraphs, uniformSentences, wallOfText } from "./shape.js";
1
+ import {
2
+ longSentences,
3
+ midPhraseBreaks,
4
+ uniformParagraphs,
5
+ uniformSentences,
6
+ wallOfText,
7
+ } from "./shape.js";
2
8
 
3
9
  /**
4
10
  * A rule is a regular expression plus a message,
@@ -28,6 +34,23 @@ function escape(text) {
28
34
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29
35
  }
30
36
 
37
+ /**
38
+ * Three shapes of one move: naming the thing it is not.
39
+ *
40
+ * not just X, but Y
41
+ * it is not X, it is Y
42
+ * X, not Y: the rest
43
+ *
44
+ * The third wants a label colon, so "a, not https://b" stays out. It also
45
+ * stays on one line, so a wrapped block comment cannot join two sentences
46
+ * into a match.
47
+ */
48
+ const CONTRAST_CLICHES = [
49
+ String.raw`\bnot (?:just|only|merely|simply) [^.\n]{1,60}?,? but(?: also)?\b`,
50
+ String.raw`\bit'?s not (?:just |about )?[^.\n]{1,40}?[,;] it'?s\b`,
51
+ String.raw`[^\s,:][^,:\n]{0,29},[ \t]+not[ \t]+[^,:\n]{1,30}:(?=\s|$)`,
52
+ ];
53
+
31
54
  const BANNED_WORDS = [
32
55
  "genuinely",
33
56
  "fails loudly",
@@ -508,9 +531,8 @@ export const rules = [
508
531
  },
509
532
  {
510
533
  id: "contrast-cliche",
511
- message: "Contrast cliche (not just X, but Y)",
512
- pattern:
513
- /\b(?:not (?:just|only|merely|simply) [^.\n]{1,60}?,? but(?: also)?\b|it'?s not (?:just |about )?[^.\n]{1,40}?[,;] it'?s\b)/gi,
534
+ message: "Contrast cliche. State the thing directly",
535
+ pattern: new RegExp(CONTRAST_CLICHES.join("|"), "gi"),
514
536
  },
515
537
  {
516
538
  id: "rhetorical-question",
@@ -562,6 +584,11 @@ export const rules = [
562
584
  pattern: new RegExp(String.raw`${COMMENT_START}(?:${words(WHAT_COMMENT_STARTS)})\b`, "gmi"),
563
585
  scope: "comments",
564
586
  },
587
+ {
588
+ id: "mid-phrase-break",
589
+ message: "Line break in the middle of a phrase. Break where there is a pause",
590
+ check: midPhraseBreaks,
591
+ },
565
592
  {
566
593
  id: "long-sentence",
567
594
  message: "Long sentence. One idea per sentence",
package/src/shape.js CHANGED
@@ -11,6 +11,86 @@ const TRAILER = /\s*(?:\*\/|-->|"""|''')\s*$/;
11
11
  const LIST_ITEM = /^(?:[-*+]|\d+[.)])\s+|^@\w+/;
12
12
  const SENTENCE_END = /[.!?]+(?:["')\]]+)?(?:\s+|$)/;
13
13
 
14
+ /**
15
+ * Words that cannot end a thought.
16
+ * They point at whatever comes next,
17
+ * so a line that stops on one stopped in the middle of a phrase.
18
+ *
19
+ * Words that can stand at the end of a clause stay out, however often they
20
+ * also appear mid phrase. "Yes it can" and "give it to her" are ordinary,
21
+ * so can and her are not here.
22
+ */
23
+ export const DANGLING_WORDS = [
24
+ "a",
25
+ "an",
26
+ "the",
27
+ "of",
28
+ "to",
29
+ "in",
30
+ "on",
31
+ "at",
32
+ "by",
33
+ "for",
34
+ "with",
35
+ "from",
36
+ "into",
37
+ "onto",
38
+ "upon",
39
+ "over",
40
+ "under",
41
+ "about",
42
+ "across",
43
+ "after",
44
+ "before",
45
+ "between",
46
+ "during",
47
+ "through",
48
+ "toward",
49
+ "towards",
50
+ "within",
51
+ "without",
52
+ "against",
53
+ "among",
54
+ "around",
55
+ "beyond",
56
+ "per",
57
+ "via",
58
+ "and",
59
+ "or",
60
+ "but",
61
+ "nor",
62
+ "that",
63
+ "which",
64
+ "who",
65
+ "whom",
66
+ "whose",
67
+ "if",
68
+ "when",
69
+ "while",
70
+ "because",
71
+ "although",
72
+ "though",
73
+ "unless",
74
+ "until",
75
+ "since",
76
+ "whether",
77
+ "is",
78
+ "are",
79
+ "was",
80
+ "were",
81
+ "has",
82
+ "have",
83
+ "had",
84
+ "its",
85
+ "their",
86
+ "your",
87
+ "our",
88
+ "my",
89
+ "every",
90
+ ];
91
+
92
+ const DANGLING_END = new RegExp(String.raw`\b(${DANGLING_WORDS.join("|")})\s*$`, "i");
93
+
14
94
  export const WALL_WORDS = 120;
15
95
  export const WALL_SENTENCES = 7;
16
96
  export const LONG_SENTENCE_WORDS = 30;
@@ -23,8 +103,8 @@ export const UNIFORM_SENTENCE_VARIATION = 0.2;
23
103
  /**
24
104
  * Groups segments into paragraphs.
25
105
  *
26
- * Pass inComments: true for comment segments, so that
27
- * comment markers are removed before counting.
106
+ * Pass inComments: true for comment segments,
107
+ * so comment markers are removed before counting.
28
108
  *
29
109
  * Each paragraph has:
30
110
  * line, column → where it starts
@@ -260,3 +340,56 @@ function preview(text) {
260
340
  const words = text.split(/\s+/, 6);
261
341
  return words.join(" ") + (words.length === 6 ? "..." : "");
262
342
  }
343
+
344
+ /**
345
+ * Line breaks that land in the middle of a phrase.
346
+ *
347
+ * A break reads as a pause, so the line before it should be able to stop.
348
+ * A line ending on a word that points at the next one cannot.
349
+ *
350
+ * Only a break the next line carries on counts.
351
+ * A blank line, a heading, a table, a fence, or a new list item
352
+ * each end the thought by themselves, so the line before one is skipped.
353
+ */
354
+ export function midPhraseBreaks(segments, scope) {
355
+ const inComments = scope === "comments";
356
+ const found = [];
357
+ let fence = false;
358
+ let previous = null;
359
+
360
+ for (let i = 0; i < segments.length; i++) {
361
+ const segment = segments[i];
362
+ const text = inComments ? strip(segment.text) : segment.text.trim();
363
+
364
+ if (text.startsWith("```") || text.startsWith("~~~")) {
365
+ fence = !fence;
366
+ previous = null;
367
+ continue;
368
+ }
369
+
370
+ if (fence || text.length === 0 || text.startsWith("#") || text.startsWith("|")) {
371
+ previous = null;
372
+ continue;
373
+ }
374
+
375
+ const carriesOn = previous !== null && segment.line === previous.line + 1;
376
+ if (carriesOn && !LIST_ITEM.test(text)) {
377
+ const match = DANGLING_END.exec(previous.text);
378
+ if (match) {
379
+ found.push({
380
+ line: previous.line,
381
+ column: previous.column + match.index,
382
+ text: match[1],
383
+ });
384
+ }
385
+ }
386
+
387
+ previous = {
388
+ text,
389
+ line: segment.line,
390
+ column: segment.column + segment.text.indexOf(text),
391
+ };
392
+ }
393
+
394
+ return found;
395
+ }