nollm 0.3.0 → 0.5.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
@@ -52,14 +52,16 @@ through a row of `..`.
52
52
  Ignore patterns from the config describe the project, so they apply under the
53
53
  current directory and leave paths outside it alone.
54
54
 
55
- | Option | Effect |
56
- | ----------------- | ------------------------------------------------------------------ |
57
- | `--jobs <n>` | Number of worker threads. Defaults to the CPU count. |
58
- | `--config <path>` | Config file to use. |
59
- | `--diff <ref>` | Check only the lines this branch adds or changes since `<ref>`. |
60
- | `--no-git` | Do not ask git for the file list. Read `.gitignore` files instead. |
61
- | `--quiet` | Print only the summary. |
62
- | `--list-rules` | Print every rule and exit. |
55
+ | Option | Effect |
56
+ | ------------------------- | ------------------------------------------------------------------ |
57
+ | `--jobs <n>` | Number of worker threads. Defaults to the CPU count. |
58
+ | `--config <path>` | Config file to use. |
59
+ | `--diff <ref>` | Check only the lines this branch adds or changes since `<ref>`. |
60
+ | `--no-git` | Do not ask git for the file list. Read `.gitignore` files instead. |
61
+ | `--stdin` | Read the text from stdin. The path `-` does the same. |
62
+ | `--stdin-filename <name>` | Check stdin as if it were this file. Defaults to `stdin.md`. |
63
+ | `--quiet` | Print only the summary. |
64
+ | `--list-rules` | Print every rule and exit. |
63
65
 
64
66
  The exit code is 1 when there are findings, and 2 on a usage error.
65
67
 
@@ -80,6 +82,24 @@ src/index.js
80
82
  4 problems in 2 files (5 files checked, 0.07s)
81
83
  ```
82
84
 
85
+ ## Checking text from stdin
86
+
87
+ Pass `-` to check text that is not in a file, such as a draft or a commit message:
88
+
89
+ ```
90
+ pbpaste | nollm -
91
+ git log -1 --format=%B | nollm -
92
+ ```
93
+
94
+ Stdin is checked as markdown. To check it as another type, name a file.
95
+ The name picks the language and labels the report. No file is read:
96
+
97
+ ```
98
+ git show main:src/index.js | nollm --stdin-filename src/index.js
99
+ ```
100
+
101
+ The config applies as usual. Stdin cannot be combined with paths or `--diff`.
102
+
83
103
  ## Checking only a pull request
84
104
 
85
105
  A large codebase written before you added `nollm` has findings everywhere.
@@ -136,7 +156,7 @@ Files of other types, binary files, lockfiles, minified files, and files over 2
136
156
  | `chat-opener` | Lines that start with "Great question", "Certainly", "Let me", and more |
137
157
  | `chat-closer` | "Hope this helps", "Let me know if", "Feel free to", and more |
138
158
  | `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" |
159
+ | `contrast-cliche` | "not just X, but Y", "it's not X, it's Y", and "X, not Y: the rest" |
140
160
  | `rhetorical-question` | "Why? Because" and "The result?" |
141
161
  | `emoji-list` | List items that start with an emoji |
142
162
  | `no-short-term-relevance` | Comments that stop making sense once the change lands: "no longer", "no behavior change", "for now", "Previously," |
@@ -144,6 +164,7 @@ Files of other types, binary files, lockfiles, minified files, and files over 2
144
164
  | `quoted-error` | Comments that quote an error message: `"Cannot read properties of..."` |
145
165
  | `dramatic-verb` | blows up, dies with, falls over, chokes on, and friends |
146
166
  | `parenthetical-aside` | Asides like `(and their compiled handles)` |
167
+ | `mid-phrase-break` | A line that stops mid phrase, on a word such as the, of, or that |
147
168
  | `long-sentence` | A sentence over 30 words |
148
169
  | `wall-of-text` | A paragraph over 120 words or 7 sentences |
149
170
  | `uniform-paragraphs` | Three or more paragraphs in a row of about the same length |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nollm",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "lint against LLMisms in your codebase",
5
5
  "keywords": [
6
6
  "comments",
package/src/cli.js CHANGED
@@ -1,18 +1,29 @@
1
1
  import { createRequire } from "node:module";
2
+ import { resolve } from "node:path";
3
+ import { text as readAll } from "node:stream/consumers";
2
4
  import { parseArgs, styleText } from "node:util";
5
+ import { check } from "./check.js";
6
+ import { findConfig, loadConfig } from "./config.js";
7
+ import { classify } from "./languages.js";
3
8
  import { lint } from "./lint.js";
4
9
  import { rules } from "./rules.js";
5
10
 
11
+ const STDIN_FILENAME = "stdin.md";
12
+
6
13
  const HELP = `Usage: nollm [options] [paths...]
7
14
 
8
15
  Checks files for LLMisms and prints each finding as soon as it is found.
9
16
  Paths may be relative or absolute. Files that git ignores are skipped.
17
+ The path - reads the text from stdin instead.
10
18
 
11
19
  Options:
12
20
  --jobs, -j <n> Number of worker threads (default: cpu count)
13
21
  --config <path> Config file (default: nollm.config.js in the current directory)
14
22
  --diff <ref> Check only the lines this branch adds or changes since <ref>
15
23
  --no-git Do not ask git for the file list. Read .gitignore files instead
24
+ --stdin Read the text from stdin. Same as the path -
25
+ --stdin-filename <name>
26
+ Check stdin as if it were this file (default: ${STDIN_FILENAME})
16
27
  --quiet, -q Print only the summary
17
28
  --list-rules Print every rule and exit
18
29
  --version, -v Print the version and exit
@@ -21,13 +32,20 @@ Options:
21
32
  Examples:
22
33
  nollm docs/guide.md a path relative to the current directory
23
34
  nollm /srv/site/docs/guide.md an absolute path
35
+ pbpaste | nollm - text from the clipboard, as markdown
36
+ git show HEAD:a.py | nollm --stdin-filename a.py
24
37
 
25
38
  Exit code 1 when there are findings. Exit code 2 on a usage error.
26
39
  `;
27
40
 
28
41
  export async function main(
29
42
  argv,
30
- { stdout = process.stdout, stderr = process.stderr, cwd = process.cwd() } = {},
43
+ {
44
+ stdin = process.stdin,
45
+ stdout = process.stdout,
46
+ stderr = process.stderr,
47
+ cwd = process.cwd(),
48
+ } = {},
31
49
  ) {
32
50
  let parsed;
33
51
  try {
@@ -40,6 +58,8 @@ export async function main(
40
58
  config: { type: "string" },
41
59
  diff: { type: "string" },
42
60
  git: { type: "boolean", default: true },
61
+ stdin: { type: "boolean", default: false },
62
+ "stdin-filename": { type: "string" },
43
63
  quiet: { type: "boolean", short: "q", default: false },
44
64
  "list-rules": { type: "boolean", default: false },
45
65
  version: { type: "boolean", short: "v", default: false },
@@ -83,21 +103,44 @@ export async function main(
83
103
 
84
104
  const paint = (style, text) => styleText(style, text, { stream: stdout });
85
105
  const started = performance.now();
106
+ const fromStdin =
107
+ values.stdin || values["stdin-filename"] !== undefined || positionals.includes("-");
108
+
109
+ if (fromStdin) {
110
+ if (positionals.some((path) => path !== "-")) {
111
+ stderr.write("Paths cannot be combined with stdin. Check one or the other\n");
112
+ return 2;
113
+ }
114
+ if (values.diff !== undefined) {
115
+ stderr.write("--diff cannot be combined with stdin, since stdin has no git history\n");
116
+ return 2;
117
+ }
118
+ }
119
+
120
+ const onResult = (result) => {
121
+ if (values.quiet || result.findings.length === 0) return;
122
+ stdout.write(formatFile(result.file, result.findings, paint));
123
+ };
86
124
 
87
125
  let summary;
88
126
  try {
89
- summary = await lint({
90
- roots: positionals.length > 0 ? positionals : ["."],
91
- cwd,
92
- configPath: values.config,
93
- git: values.git,
94
- diff: values.diff,
95
- jobs,
96
- onResult(result) {
97
- if (values.quiet || result.findings.length === 0) return;
98
- stdout.write(formatFile(result.file, result.findings, paint));
99
- },
100
- });
127
+ summary = fromStdin
128
+ ? await lintStdin({
129
+ stdin,
130
+ file: values["stdin-filename"] ?? STDIN_FILENAME,
131
+ cwd,
132
+ configPath: values.config,
133
+ onResult,
134
+ })
135
+ : await lint({
136
+ roots: positionals.length > 0 ? positionals : ["."],
137
+ cwd,
138
+ configPath: values.config,
139
+ git: values.git,
140
+ diff: values.diff,
141
+ jobs,
142
+ onResult,
143
+ });
101
144
  } catch (error) {
102
145
  stderr.write(`${error.message}\n`);
103
146
  return 2;
@@ -112,6 +155,30 @@ export async function main(
112
155
  return summary.findings > 0 ? 1 : 0;
113
156
  }
114
157
 
158
+ /**
159
+ * Checks the text on stdin as if it were the named file.
160
+ *
161
+ * The name picks the language and labels the report. No file is read.
162
+ * Returns the same summary as lint, for one file.
163
+ */
164
+ async function lintStdin({ stdin, file, cwd, configPath, onResult }) {
165
+ if (!classify(file)) {
166
+ throw new Error(`nollm does not know how to check "${file}". Pass --stdin-filename a.md`);
167
+ }
168
+
169
+ const resolvedConfig = configPath ? resolve(cwd, configPath) : await findConfig(cwd);
170
+ const config = await loadConfig(resolvedConfig);
171
+ const findings = check(file, await readAll(stdin), config.rules);
172
+
173
+ onResult({ file, findings, skipped: null });
174
+ return {
175
+ files: 1,
176
+ checked: 1,
177
+ findings: findings.length,
178
+ filesWithFindings: findings.length > 0 ? 1 : 0,
179
+ };
180
+ }
181
+
115
182
  /**
116
183
  * One block per file:
117
184
  *
package/src/diff.js CHANGED
@@ -22,14 +22,14 @@ export const ALL_LINES = true;
22
22
  /**
23
23
  * The lines a branch adds or changes, per file.
24
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.
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
27
  * Paths are relative to cwd, so they match what collectFiles returns.
28
28
  *
29
29
  * The comparison starts at the merge base of base and the working tree, so
30
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.
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
33
  *
34
34
  * Files with no added or changed lines are left out. So are deleted files.
35
35
  */
@@ -124,9 +124,10 @@ async function real(path) {
124
124
  /**
125
125
  * Says what is wrong with a base ref before git says it less clearly.
126
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.
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.
130
131
  */
131
132
  async function checkBase(dir) {
132
133
  if ((await tryGit(["rev-parse", "--is-inside-work-tree"], dir)) === null) {
@@ -166,9 +167,10 @@ async function noMergeBase(base, dir) {
166
167
  /**
167
168
  * How to fetch a missing ref. "origin/develop" needs "origin develop".
168
169
  *
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.
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.
172
174
  */
173
175
  async function fetchArgs(base, cwd) {
174
176
  const cut = base.indexOf("/");
@@ -220,8 +222,9 @@ async function untracked(cwd) {
220
222
  /**
221
223
  * Reads a unified diff produced with --unified=0 and --no-prefix.
222
224
  *
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
+ * 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 "++".
225
228
  */
226
229
  function parse(patch) {
227
230
  const changed = new Map();
package/src/files.js CHANGED
@@ -30,12 +30,13 @@ const EVERYTHING = ["**/*", "**/.*", "**/.*/**"];
30
30
  * git only knows about its own work tree, so roots outside cwd are always
31
31
  * walked.
32
32
  *
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.
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.
35
36
  *
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.
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.
39
40
  */
40
41
  export async function collectFiles(
41
42
  roots,
@@ -113,11 +114,12 @@ async function fromWalk(roots, base) {
113
114
  * so an ignored tree is never opened.
114
115
  *
115
116
  * 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.
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.
118
120
  *
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
+ * 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.
121
123
  */
122
124
  function skips(root) {
123
125
  const chains = new Map();
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
+ }