nollm 0.3.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
@@ -136,7 +136,7 @@ Files of other types, binary files, lockfiles, minified files, and files over 2
136
136
  | `chat-opener` | Lines that start with "Great question", "Certainly", "Let me", and more |
137
137
  | `chat-closer` | "Hope this helps", "Let me know if", "Feel free to", and more |
138
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" |
139
+ | `contrast-cliche` | "not just X, but Y", "it's not X, it's Y", and "X, not Y: the rest" |
140
140
  | `rhetorical-question` | "Why? Because" and "The result?" |
141
141
  | `emoji-list` | List items that start with an emoji |
142
142
  | `no-short-term-relevance` | Comments that stop making sense once the change lands: "no longer", "no behavior change", "for now", "Previously," |
@@ -144,6 +144,7 @@ Files of other types, binary files, lockfiles, minified files, and files over 2
144
144
  | `quoted-error` | Comments that quote an error message: `"Cannot read properties of..."` |
145
145
  | `dramatic-verb` | blows up, dies with, falls over, chokes on, and friends |
146
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 |
147
148
  | `long-sentence` | A sentence over 30 words |
148
149
  | `wall-of-text` | A paragraph over 120 words or 7 sentences |
149
150
  | `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.4.0",
4
4
  "description": "lint against LLMisms in your codebase",
5
5
  "keywords": [
6
6
  "comments",
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
+ }