nollm 0.0.0 → 0.2.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
@@ -2,10 +2,14 @@
2
2
 
3
3
  Lint against LLMisms in your codebase.
4
4
 
5
+ Pronounced "nollum": like gollum, with an n.
6
+
5
7
  `nollm` reads every file that git tracks or does not ignore.
6
8
  It checks prose files line by line, and code files comment by comment.
7
9
  Each finding prints as soon as it is found.
8
10
 
11
+ ![!this project was created with claude code](https://i.imgflip.com/4h68b2.jpg)
12
+
9
13
  ```
10
14
  npx nollm
11
15
  ```
@@ -47,12 +51,21 @@ Paths can be files or directories.
47
51
 
48
52
  The exit code is 1 when there are findings, and 2 on a usage error.
49
53
 
50
- Output looks like this:
54
+ Output is one block per file, grouped by rule:
51
55
 
52
56
  ```
53
- README.md:3:14 filler-word Filler. Delete it or replace it: "simply"
54
- src/index.js:1:1 what-comment Comment narrates what the code does. Say why, or delete it: "// This function"
55
- 2 problems in 2 files (5 files checked, 0.07s)
57
+ README.md
58
+ filler-word Filler. Delete it or replace it
59
+ 3:14 "simply"
60
+ 3:33 "robust"
61
+ chat-opener Chat opener. Start with the answer
62
+ 7:1 "Great question"
63
+
64
+ src/index.js
65
+ what-comment Comment narrates what the code does. Say why, or delete it
66
+ 1:1 "// This function"
67
+
68
+ 4 problems in 2 files (5 files checked, 0.07s)
56
69
  ```
57
70
 
58
71
  ## What gets checked
@@ -62,30 +75,39 @@ Every line is checked.
62
75
 
63
76
  Code files: JavaScript, TypeScript, Python, Ruby, Rust, Go, shell, YAML, TOML, HTML, Handlebars, `.gjs`, `.gts`, and many more.
64
77
  Only comments are checked, so identifiers and string contents do not trigger rules.
65
- The `error-exclamation` rule is the exception. It checks every line, because it targets error strings.
66
78
 
67
79
  Files of other types, binary files, lockfiles, minified files, and files over 2 MB are skipped.
68
80
 
69
81
  ## Rules
70
82
 
71
- | Rule | Catches |
72
- | --------------------- | ----------------------------------------------------------------------- |
73
- | `banned-word` | genuinely, load-bearing, crutch, spearheaded, fails loudly, and friends |
74
- | `em-dash` | The em dash character |
75
- | `bold-fragment` | `**Bold label:** followed by plain text` in markdown |
76
- | `filler-word` | simply, robust, leverage, utilize, in order to, keep in mind, and more |
77
- | `llm-vocabulary` | delve, tapestry, crucial, game-changer, battle-tested, and more |
78
- | `chat-opener` | Lines that start with "Great question", "Certainly", "Let me", and more |
79
- | `chat-closer` | "Hope this helps", "Let me know if", "Feel free to", and more |
80
- | `ai-disclosure` | "As an AI", "my training data", and more |
81
- | `error-exclamation` | "Oops", "Uh oh", "Something went wrong" |
82
- | `contrast-cliche` | "not just X, but Y" and "it's not X, it's Y" |
83
- | `rhetorical-question` | "Why? Because" and "The result?" |
84
- | `emoji` | Emoji |
85
- | `diff-comment` | Comments about the change: "no longer", "as discussed", "previously" |
86
- | `what-comment` | Comments that narrate the code: "This function returns", "Loop over" |
87
-
88
- Run `nollm --list-rules` for the full list.
83
+ | Rule | Catches |
84
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------------ |
85
+ | `banned-word` | genuinely, load-bearing, crutch, spearheaded, fails loudly, and friends |
86
+ | `em-dash` | The em dash character |
87
+ | `bold-list-item` | List items like `- **Label:** plain text` |
88
+ | `filler-word` | simply, seamlessly, seamless, robust |
89
+ | `llm-vocabulary` | delve, tapestry, crucial, game-changer, battle-tested, and more |
90
+ | `chat-opener` | Lines that start with "Great question", "Certainly", "Let me", and more |
91
+ | `chat-closer` | "Hope this helps", "Let me know if", "Feel free to", and more |
92
+ | `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" |
94
+ | `rhetorical-question` | "Why? Because" and "The result?" |
95
+ | `emoji-list` | List items that start with an emoji |
96
+ | `no-short-term-relevance` | Comments that stop making sense once the change lands: "no longer", "no behavior change", "for now", "Previously," |
97
+ | `what-comment` | Comments that narrate the code: "This function returns", "Loop over" |
98
+ | `quoted-error` | Comments that quote an error message: `"Cannot read properties of..."` |
99
+ | `dramatic-verb` | blows up, dies with, falls over, chokes on, and friends |
100
+ | `parenthetical-aside` | Asides like `(and their compiled handles)` |
101
+ | `long-sentence` | A sentence over 30 words |
102
+ | `wall-of-text` | A paragraph over 120 words or 7 sentences |
103
+ | `uniform-paragraphs` | Three or more paragraphs in a row of about the same length |
104
+ | `uniform-sentences` | Four or more sentences of about the same length |
105
+
106
+ Run `nollm --list-rules` for the full list with the scope of each rule.
107
+
108
+ Prose and comments get different rules.
109
+ For example, `em-dash` runs in code comments and not in markdown.
110
+ To change where a rule runs, set its `scope` in the config.
89
111
 
90
112
  ## Configuration
91
113
 
@@ -110,7 +132,10 @@ export default {
110
132
 
111
133
  rules: {
112
134
  // turn a rule off
113
- "em-dash": false,
135
+ "chat-opener": false,
136
+
137
+ // run a built in rule somewhere else
138
+ "em-dash": { scope: "text" },
114
139
 
115
140
  // add a rule, or replace a built in one
116
141
  "open-todo": {
@@ -124,13 +149,18 @@ export default {
124
149
 
125
150
  In JSON configs, write the pattern as a string and add flags in a `flags` key.
126
151
 
152
+ A rule can also have a `check` function instead of a pattern.
153
+ It gets every line of the file as `{ text, line, column }`, plus the scope, and returns findings of the same shape, plus `text`.
154
+ The `paragraphs` export groups those lines into paragraphs with word and sentence counts.
155
+
127
156
  `scope` is one of:
128
157
 
129
158
  - `prose`: prose files only
130
159
  - `comments`: comments in code files only
160
+ - `text`: prose and comments. This is the default
131
161
  - `everywhere`: every line of every file
132
162
 
133
- A rule with no scope runs in prose and in comments.
163
+ A config entry for a built in rule can change only `scope` or `message`. The pattern stays.
134
164
 
135
165
  To silence one line, put `nollm-ignore-next-line` on the line before it.
136
166
  To silence a whole file, put `nollm-ignore-file` anywhere in it.
package/bin/nollm.js CHANGED
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { main } from "../src/cli.js";
3
3
 
4
+ // A closed pipe, for example `nollm | head`, is not an error.
5
+ process.stdout.on("error", (error) => {
6
+ if (error.code === "EPIPE") process.exit(0);
7
+ throw error;
8
+ });
9
+
4
10
  process.exitCode = await main(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nollm",
3
- "version": "0.0.0",
3
+ "version": "0.2.0",
4
4
  "description": "lint against LLMisms in your codebase",
5
5
  "keywords": [
6
6
  "comments",
@@ -31,14 +31,6 @@
31
31
  "default": "./src/index.js"
32
32
  }
33
33
  },
34
- "scripts": {
35
- "format": "oxfmt",
36
- "format:check": "oxfmt --check",
37
- "lint": "oxlint && pnpm format:check && publint && pnpm lint:prose",
38
- "lint:fix": "oxlint --fix && oxfmt",
39
- "lint:prose": "node bin/nollm.js",
40
- "test": "vitest run"
41
- },
42
34
  "dependencies": {
43
35
  "ignore": "^7.0.9",
44
36
  "lilconfig": "^3.1.3",
@@ -49,10 +41,18 @@
49
41
  "oxfmt": "^0.68.0",
50
42
  "oxlint": "^1.83.0",
51
43
  "publint": "^0.3.24",
44
+ "release-plan": "^0.18.0",
52
45
  "vitest": "^5.0.1"
53
46
  },
54
47
  "engines": {
55
48
  "node": ">= 22.5"
56
49
  },
57
- "packageManager": "pnpm@11.22.0"
58
- }
50
+ "scripts": {
51
+ "format": "oxfmt",
52
+ "format:check": "oxfmt --check",
53
+ "lint": "oxlint && pnpm format:check && publint && pnpm lint:prose",
54
+ "lint:fix": "oxlint --fix && oxfmt",
55
+ "lint:prose": "node bin/nollm.js",
56
+ "test": "vitest run"
57
+ }
58
+ }
package/src/check.js CHANGED
@@ -45,6 +45,20 @@ function run(findings, segments, rules, scope) {
45
45
  const rule = rules[r];
46
46
  if (!applies(rule, scope)) continue;
47
47
 
48
+ if (typeof rule.check === "function") {
49
+ const found = rule.check(segments, scope);
50
+ for (let f = 0; f < found.length; f++) {
51
+ findings.push({
52
+ line: found[f].line,
53
+ column: found[f].column,
54
+ ruleId: rule.id,
55
+ message: rule.message,
56
+ text: found[f].text,
57
+ });
58
+ }
59
+ continue;
60
+ }
61
+
48
62
  for (let s = 0; s < segments.length; s++) {
49
63
  const segment = segments[s];
50
64
  const pattern = rule.pattern;
package/src/cli.js CHANGED
@@ -60,7 +60,8 @@ export async function main(
60
60
 
61
61
  if (values["list-rules"]) {
62
62
  for (let i = 0; i < rules.length; i++) {
63
- stdout.write(`${rules[i].id.padEnd(20)} ${rules[i].message}\n`);
63
+ const scope = (rules[i].scope ?? "text").padEnd(10);
64
+ stdout.write(`${rules[i].id.padEnd(24)} ${scope} ${rules[i].message}\n`);
64
65
  }
65
66
  return 0;
66
67
  }
@@ -86,11 +87,8 @@ export async function main(
86
87
  git: values.git,
87
88
  jobs,
88
89
  onResult(result) {
89
- if (values.quiet) return;
90
- const findings = result.findings;
91
- for (let i = 0; i < findings.length; i++) {
92
- stdout.write(formatFinding(result.file, findings[i], paint));
93
- }
90
+ if (values.quiet || result.findings.length === 0) return;
91
+ stdout.write(formatFile(result.file, result.findings, paint));
94
92
  },
95
93
  });
96
94
  } catch (error) {
@@ -107,8 +105,43 @@ export async function main(
107
105
  return summary.findings > 0 ? 1 : 0;
108
106
  }
109
107
 
110
- function formatFinding(file, finding, paint) {
111
- const where = paint("dim", `${file}:${finding.line}:${finding.column}`);
112
- const rule = paint("yellow", finding.ruleId);
113
- return `${where} ${rule} ${finding.message}: ${JSON.stringify(finding.text)}\n`;
108
+ /**
109
+ * One block per file:
110
+ *
111
+ * README.md
112
+ * wall-of-text Wall of text. Split the paragraph
113
+ * 3:1 "131 words, 9 sentences: The linter reads every..."
114
+ * 40:1 "202 words, 12 sentences: Each worker loads the..."
115
+ *
116
+ * Findings are grouped by rule, in order of first appearance.
117
+ */
118
+ 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
+ let out = `${paint("underline", file)}\n`;
131
+ for (const [ruleId, group] of groups) {
132
+ out += ` ${paint("yellow", ruleId)} ${group.message}\n`;
133
+
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
+ }
139
+
140
+ for (let i = 0; i < group.items.length; i++) {
141
+ const item = group.items[i];
142
+ const where = `${item.line}:${item.column}`.padEnd(width);
143
+ out += ` ${paint("dim", where)} ${JSON.stringify(item.text)}\n`;
144
+ }
145
+ }
146
+ return out + "\n";
114
147
  }
package/src/config.js CHANGED
@@ -62,15 +62,15 @@ function resolveRules(overrides = {}, words = []) {
62
62
 
63
63
  for (let i = 0; i < builtinRules.length; i++) {
64
64
  const rule = builtinRules[i];
65
- const override = overrides[rule.id];
65
+ const override = overrideFor(overrides, rule);
66
66
  if (override === false) continue;
67
- rules.push(isObject(override) ? customRule(rule.id, override) : rule);
67
+ rules.push(isObject(override) ? customRule(rule.id, override, rule) : rule);
68
68
  }
69
69
 
70
70
  for (const id in overrides) {
71
71
  const override = overrides[id];
72
72
  if (!isObject(override)) continue;
73
- if (builtinRules.some((rule) => rule.id === id)) continue;
73
+ if (builtinRules.some((rule) => namesOf(rule).includes(id))) continue;
74
74
  rules.push(customRule(id, override));
75
75
  }
76
76
 
@@ -86,12 +86,35 @@ function resolveRules(overrides = {}, words = []) {
86
86
  return rules;
87
87
  }
88
88
 
89
- function customRule(id, override) {
89
+ /**
90
+ * The config entry for a built in rule, under its id or one of its old ids.
91
+ */
92
+ function overrideFor(overrides, rule) {
93
+ const name = namesOf(rule).find((candidate) => candidate in overrides);
94
+ return name === undefined ? undefined : overrides[name];
95
+ }
96
+
97
+ function namesOf(rule) {
98
+ return [rule.id].concat(rule.aliases ?? []);
99
+ }
100
+
101
+ /**
102
+ * Builds a rule from a config entry.
103
+ * Keys that the entry leaves out come from the built in rule, when there is one.
104
+ */
105
+ function customRule(id, override, base = {}) {
106
+ const message = override.message ?? base.message ?? id;
107
+ const scope = override.scope ?? base.scope;
108
+ const check = override.check ?? (override.pattern === undefined ? base.check : undefined);
109
+
110
+ if (typeof check === "function") {
111
+ return { id, message, check, scope };
112
+ }
90
113
  return {
91
114
  id,
92
- message: override.message ?? id,
93
- pattern: toGlobal(id, override.pattern, override.flags),
94
- scope: override.scope,
115
+ message,
116
+ pattern: toGlobal(id, override.pattern ?? base.pattern, override.flags),
117
+ scope,
95
118
  };
96
119
  }
97
120
 
@@ -102,7 +125,9 @@ function toGlobal(id, pattern, flags = "") {
102
125
  if (pattern instanceof RegExp) {
103
126
  return pattern.global ? pattern : new RegExp(pattern.source, pattern.flags + "g");
104
127
  }
105
- throw new Error(`Rule "${id}" needs a pattern: a RegExp, or a string with optional flags`);
128
+ throw new Error(
129
+ `Rule "${id}" needs a pattern (a RegExp, or a string with optional flags) or a check function`,
130
+ );
106
131
  }
107
132
 
108
133
  function isObject(value) {
package/src/index.d.ts CHANGED
@@ -1,12 +1,31 @@
1
- export type Scope = "prose" | "comments" | "everywhere";
1
+ export type Scope = "prose" | "comments" | "text" | "everywhere";
2
2
 
3
- export interface Rule {
3
+ export interface PatternRule {
4
4
  id: string;
5
+ /** Old ids of this rule. A config may still refer to the rule by one of them. */
6
+ aliases?: string[];
5
7
  message: string;
6
8
  pattern: RegExp;
7
9
  scope?: Scope;
8
10
  }
9
11
 
12
+ export interface ShapeFinding {
13
+ line: number;
14
+ column: number;
15
+ text: string;
16
+ }
17
+
18
+ export interface CheckRule {
19
+ id: string;
20
+ /** Old ids of this rule. A config may still refer to the rule by one of them. */
21
+ aliases?: string[];
22
+ message: string;
23
+ check: (segments: Segment[], scope: Scope) => ShapeFinding[];
24
+ scope?: Scope;
25
+ }
26
+
27
+ export type Rule = PatternRule | CheckRule;
28
+
10
29
  export interface Finding {
11
30
  line: number;
12
31
  column: number;
@@ -48,8 +67,9 @@ export interface Config {
48
67
  }
49
68
 
50
69
  export interface RuleConfig {
51
- pattern: RegExp | string;
70
+ pattern?: RegExp | string;
52
71
  flags?: string;
72
+ check?: (segments: Segment[], scope: Scope) => ShapeFinding[];
53
73
  message?: string;
54
74
  scope?: Scope;
55
75
  }
@@ -83,6 +103,15 @@ export function collectFiles(
83
103
  ): Promise<string[]>;
84
104
  export function extractComments(source: string, language: Language): Segment[];
85
105
  export function extractLines(source: string): Segment[];
106
+ export function paragraphs(segments: Segment[], options?: { inComments?: boolean }): Paragraph[];
107
+
108
+ export interface Paragraph {
109
+ line: number;
110
+ column: number;
111
+ words: number;
112
+ sentences: number[];
113
+ preview: string;
114
+ }
86
115
  export function findConfig(cwd: string): Promise<string | null>;
87
116
  export function loadConfig(configPath: string | null): Promise<Config>;
88
117
  export function lint(options?: LintOptions): Promise<Summary>;
package/src/index.js CHANGED
@@ -5,3 +5,4 @@ export { extractComments, extractLines } from "./comments.js";
5
5
  export { findConfig, loadConfig, SEARCH_PLACES } from "./config.js";
6
6
  export { lint } from "./lint.js";
7
7
  export { rules } from "./rules.js";
8
+ export { paragraphs } from "./shape.js";
package/src/rules.js CHANGED
@@ -1,14 +1,19 @@
1
+ import { longSentences, uniformParagraphs, uniformSentences, wallOfText } from "./shape.js";
2
+
1
3
  /**
2
- * A rule is a regular expression plus a message.
4
+ * A rule is a regular expression plus a message,
5
+ * or a check function that reads all lines at once.
3
6
  *
4
7
  * scope controls where the rule runs:
5
8
  * prose → markdown and text files
6
9
  * comments → comments inside code files
10
+ * text → both of the above (the default)
7
11
  * everywhere → every line of every file
8
12
  *
9
- * A rule with no scope runs in prose and in comments.
10
- *
11
13
  * Patterns must have the g flag.
14
+ *
15
+ * A check function gets the segments of one file and the scope
16
+ * they came from. It returns { line, column, text } per finding.
12
17
  */
13
18
 
14
19
  function words(list) {
@@ -35,31 +40,7 @@ const BANNED_WORDS = [
35
40
  "carrying",
36
41
  ];
37
42
 
38
- const FILLER_WORDS = [
39
- "simply",
40
- "seamlessly",
41
- "seamless",
42
- "robust",
43
- "powerful",
44
- "comprehensive",
45
- "leverage",
46
- "leverages",
47
- "leveraged",
48
- "leveraging",
49
- "utilize",
50
- "utilizes",
51
- "utilized",
52
- "utilizing",
53
- "in order to",
54
- "prior to",
55
- "in the event that",
56
- "it is worth noting",
57
- "it's worth noting",
58
- "it is important to note",
59
- "it's important to note",
60
- "it should be noted",
61
- "keep in mind",
62
- ];
43
+ const FILLER_WORDS = ["simply", "seamlessly", "seamless", "robust"];
63
44
 
64
45
  const LLM_VOCABULARY = [
65
46
  "delve",
@@ -150,33 +131,59 @@ const AI_DISCLOSURES = [
150
131
  "my training data",
151
132
  ];
152
133
 
153
- const ERROR_EXCLAMATIONS = [
154
- "uh oh",
155
- "uh-oh",
156
- "oh no",
157
- "oops",
158
- "whoops",
159
- "there seems to be a problem",
160
- "something went wrong",
161
- ];
162
-
163
- const DIFF_TALK = [
134
+ // Talk that only makes sense while the change is under review.
135
+ // Once merged, nobody reading the code knows what "the old way" or "this PR" was.
136
+ const PR_TALK = [
137
+ // The change itself
164
138
  "as requested",
165
139
  "as discussed",
166
140
  "per the discussion",
167
141
  "per our discussion",
168
142
  "per the review",
169
143
  "per review",
144
+ "per feedback",
145
+ "based on feedback",
146
+ "review feedback",
147
+ "review comment",
148
+ "review comments",
149
+ "addressing feedback",
150
+ "addresses feedback",
151
+ "addressed feedback",
152
+ "requested changes",
153
+ "code review",
154
+ "reviewer",
155
+ "reviewers",
170
156
  "this pr",
157
+ "this mr",
158
+ "pr description",
159
+ "pull request",
160
+ "merge request",
171
161
  "this commit",
172
162
  "this diff",
173
163
  "this change",
164
+ "this refactor",
165
+ "the refactor",
166
+ "this migration",
167
+ "this upgrade",
168
+ "the upgrade",
169
+ "after upgrading",
170
+ "before upgrading",
171
+ "since upgrading",
172
+ "version bump",
173
+ "this bump",
174
+ "cherry-picked",
175
+ "cherry picked",
176
+ "backported",
177
+ "rebased",
178
+ "merge conflict",
179
+ "merge conflicts",
180
+ "work in progress",
181
+ "wip",
182
+ // How the code used to be
174
183
  "no longer",
175
184
  "used to be",
176
185
  "used to use",
177
186
  "used to return",
178
- "previously",
179
- "formerly",
180
187
  "the old implementation",
181
188
  "the old version",
182
189
  "the old code",
@@ -195,8 +202,221 @@ const DIFF_TALK = [
195
202
  "now handles",
196
203
  "now lives",
197
204
  "moved to",
205
+ "never worked",
206
+ "wasn't working",
207
+ "was not working",
208
+ "weren't working",
209
+ "were not working",
210
+ "didn't work before",
211
+ "did not work before",
212
+ "stopped working",
213
+ "was failing",
214
+ "were failing",
215
+ "started failing",
216
+ "kept failing",
217
+ "on the main branch",
218
+ "on the master branch",
219
+ "compared to main",
220
+ "compared to master",
221
+ // Claims that nothing changed
222
+ "no behavior change",
223
+ "no behaviour change",
224
+ "no functional change",
225
+ "no functional changes",
226
+ "no-op change",
227
+ "behavior is unchanged",
228
+ "behaviour is unchanged",
229
+ "behavior unchanged",
230
+ "behaviour unchanged",
231
+ "no change in behavior",
232
+ "no change in behaviour",
233
+ "same as before",
234
+ "same behavior as before",
235
+ "same behaviour as before",
236
+ "behaves as before",
237
+ "behaves the same",
238
+ "works the same",
239
+ "functionally equivalent",
240
+ "functionally identical",
241
+ "preserves behavior",
242
+ "preserves behaviour",
243
+ "preserve behavior",
244
+ "preserve behaviour",
245
+ "preserves existing behavior",
246
+ "preserves existing behaviour",
247
+ "existing behavior",
248
+ "existing behaviour",
249
+ "unchanged from",
250
+ // Leaving things as they were
251
+ "keeping it off",
252
+ "keeping it on",
253
+ "keeping this off",
254
+ "keeping this on",
255
+ "keeping it disabled",
256
+ "keeping it enabled",
257
+ "keeping this disabled",
258
+ "keeping this enabled",
259
+ "keep it off",
260
+ "keep this off",
261
+ "kept as is",
262
+ "kept as-is",
263
+ "left as is",
264
+ "left as-is",
265
+ "leave as is",
266
+ "leave as-is",
267
+ "leaving as is",
268
+ "leaving as-is",
269
+ "left in place",
270
+ "left untouched",
271
+ "left alone",
272
+ "leaving this alone",
273
+ "not touching",
274
+ "didn't touch",
275
+ "did not touch",
276
+ // Scope and follow-ups
277
+ "for now",
278
+ "out of scope",
279
+ "scope of this",
280
+ "follow-up pr",
281
+ "follow up pr",
282
+ "in a follow-up",
283
+ "in a follow up",
284
+ "as a follow-up",
285
+ "as a follow up",
286
+ "separate pr",
287
+ "later pr",
288
+ "future pr",
289
+ "another pr",
290
+ "next pr",
291
+ "will be removed",
292
+ "can be removed",
293
+ "to be removed",
294
+ "should be removed",
295
+ "safe to remove",
296
+ "safe to delete",
297
+ "can be deleted",
298
+ "will be deleted",
299
+ "remove this once",
300
+ "remove this after",
301
+ "remove this when",
302
+ "delete this once",
303
+ "delete this after",
304
+ "clean up later",
305
+ "cleanup later",
306
+ "clean this up later",
307
+ "will clean up",
308
+ "will be cleaned up",
309
+ "quick fix",
310
+ "quick hack",
311
+ "quick and dirty",
312
+ "band-aid",
313
+ "bandaid",
314
+ "band aid",
315
+ "stopgap",
316
+ "stop-gap",
317
+ "stop gap",
318
+ "hotfix",
319
+ "hot fix",
320
+ // The author talking about their own process
321
+ "tested locally",
322
+ "verified locally",
323
+ "confirmed locally",
324
+ "works locally",
325
+ "tested with",
326
+ "tested on",
327
+ "tested against",
328
+ "verified with",
329
+ "confirmed with",
330
+ "ci passes",
331
+ "passes ci",
332
+ "ci is green",
333
+ "ci was failing",
334
+ "ci fails",
335
+ "ci failed",
336
+ "i tested",
337
+ "i verified",
338
+ "i checked",
339
+ "i confirmed",
340
+ "i tried",
341
+ "i went with",
342
+ "i chose",
343
+ "i opted",
344
+ "i decided",
345
+ "i ended up",
346
+ "i left",
347
+ "i kept",
348
+ "i removed",
349
+ "i replaced",
350
+ "i added",
351
+ "i moved",
352
+ "i renamed",
353
+ "i changed",
354
+ "i updated",
355
+ "i bumped",
356
+ "i couldn't",
357
+ "i could not",
358
+ "i'm not sure",
359
+ "i am not sure",
360
+ "not sure if",
361
+ "not sure why",
362
+ "not sure whether",
363
+ "we tested",
364
+ "we verified",
365
+ "we confirmed",
366
+ "we decided",
367
+ "we went with",
368
+ "we opted",
369
+ "we couldn't",
370
+ "we could not",
371
+ ];
372
+
373
+ // PR talk only when it opens a sentence. "used previously" is fine.
374
+ const PR_OPENERS = ["previously", "formerly", "originally"];
375
+
376
+ const DRAMATIC_VERBS = [
377
+ "blow up",
378
+ "blows up",
379
+ "blowing up",
380
+ "blew up",
381
+ "die with",
382
+ "dies with",
383
+ "died with",
384
+ "fall over",
385
+ "falls over",
386
+ "fell over",
387
+ "choke on",
388
+ "chokes on",
389
+ "choked on",
390
+ "trip over",
391
+ "trips over",
392
+ "tripped over",
393
+ "bites us",
394
+ "bites you",
395
+ "bit us",
396
+ "explode",
397
+ "explodes",
398
+ "exploded",
399
+ "barf",
400
+ "barfs",
401
+ ];
402
+
403
+ const ERROR_OPENERS = [
404
+ "Cannot",
405
+ "Could not",
406
+ "Couldn't",
407
+ "Unable to",
408
+ "Failed to",
409
+ "Unexpected",
410
+ "Invalid",
411
+ "Expected",
412
+ "Missing",
413
+ "Uncaught",
414
+ "Assertion Failed",
415
+ "Maximum call stack",
198
416
  ];
199
417
 
418
+ const ASIDE_OPENERS = ["and", "but", "though", "although", "plus", "as well as", "not to mention"];
419
+
200
420
  const WHAT_COMMENT_STARTS = [
201
421
  "this function",
202
422
  "this method",
@@ -237,6 +457,9 @@ const WHAT_COMMENT_STARTS = [
237
457
  // A comment starts after its marker, so the rule skips the marker first.
238
458
  const COMMENT_START = String.raw`^\s*(?:\/\/+|#+|\*+|\/\*+|<!--|--|;+|%+|"""|''')?\s*`;
239
459
 
460
+ // The start of a comment or of a sentence, as a lookbehind.
461
+ const SENTENCE_START = String.raw`(?<=${COMMENT_START}|[.!?:;]\s+)`;
462
+
240
463
  export const rules = [
241
464
  {
242
465
  id: "banned-word",
@@ -247,11 +470,12 @@ export const rules = [
247
470
  id: "em-dash",
248
471
  message: "Em dash. Use a comma, a colon, or a new sentence",
249
472
  pattern: /\u2014/g,
473
+ scope: "comments",
250
474
  },
251
475
  {
252
- id: "bold-fragment",
253
- message: "Bold fragment followed by plain text",
254
- pattern: /^\s*(?:[-*+]|\d+[.)])?\s*(?:\*\*[^*\n]{1,80}\*\*|__[^_\n]{1,80}__):?\s+\S/gm,
476
+ id: "bold-list-item",
477
+ message: "List item with a bold label followed by plain text",
478
+ pattern: /^\s*(?:[-*+]|\d+[.)])\s+(?:\*\*[^*\n]{1,80}\*\*|__[^_\n]{1,80}__):?(?=\s+[^\s*_])/gm,
255
479
  scope: "prose",
256
480
  },
257
481
  {
@@ -282,12 +506,6 @@ export const rules = [
282
506
  message: "Text written from the point of view of a chat assistant",
283
507
  pattern: anyOf(AI_DISCLOSURES),
284
508
  },
285
- {
286
- id: "error-exclamation",
287
- message: "Error message with an exclamation instead of a cause",
288
- pattern: anyOf(ERROR_EXCLAMATIONS),
289
- scope: "everywhere",
290
- },
291
509
  {
292
510
  id: "contrast-cliche",
293
511
  message: "Contrast cliche (not just X, but Y)",
@@ -302,20 +520,67 @@ export const rules = [
302
520
  scope: "prose",
303
521
  },
304
522
  {
305
- id: "emoji",
306
- message: "Emoji",
307
- pattern: /(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F)/gu,
523
+ id: "emoji-list",
524
+ message: "List item that starts with an emoji",
525
+ pattern:
526
+ /^\s*(?:[-*+]|\d+[.)])\s+(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F)/gmu,
527
+ scope: "prose",
308
528
  },
309
529
  {
310
- id: "diff-comment",
311
- message: "Comment describes the change, not the code. Put it in the commit message",
312
- pattern: anyOf(DIFF_TALK),
530
+ id: "no-short-term-relevance",
531
+ aliases: ["diff-comment"],
532
+ message: "Comment only makes sense while the change is under review. Say it in the PR",
533
+ pattern: new RegExp(
534
+ String.raw`\b(?:${words(PR_TALK)})\b|${SENTENCE_START}(?:${words(PR_OPENERS)})\b`,
535
+ "gmi",
536
+ ),
537
+ scope: "comments",
538
+ },
539
+ {
540
+ id: "quoted-error",
541
+ message:
542
+ "Comment quotes an error message. Say what breaks and why, not what the terminal printed",
543
+ pattern: new RegExp(
544
+ String.raw`["\u201C](?:(?:${words(ERROR_OPENERS)})\b|\w+Error:|\w+ is not (?:a function|defined|iterable)\b)[^"\u201D\n]*["\u201D]?`,
545
+ "g",
546
+ ),
313
547
  scope: "comments",
314
548
  },
549
+ {
550
+ id: "dramatic-verb",
551
+ message: "Dramatic failure verb. Say what happens: throws, hangs, returns null",
552
+ pattern: anyOf(DRAMATIC_VERBS),
553
+ },
554
+ {
555
+ id: "parenthetical-aside",
556
+ message: "Parenthetical aside. Make it a sentence, or delete it",
557
+ pattern: new RegExp(String.raw`\((?:${words(ASIDE_OPENERS)})\b[^()\n]*\)?`, "gi"),
558
+ },
315
559
  {
316
560
  id: "what-comment",
317
561
  message: "Comment narrates what the code does. Say why, or delete it",
318
562
  pattern: new RegExp(String.raw`${COMMENT_START}(?:${words(WHAT_COMMENT_STARTS)})\b`, "gmi"),
319
563
  scope: "comments",
320
564
  },
565
+ {
566
+ id: "long-sentence",
567
+ message: "Long sentence. One idea per sentence",
568
+ check: longSentences,
569
+ },
570
+ {
571
+ id: "wall-of-text",
572
+ message: "Wall of text. Split the paragraph",
573
+ check: wallOfText,
574
+ },
575
+ {
576
+ id: "uniform-paragraphs",
577
+ message: "Consecutive paragraphs of the same length",
578
+ check: uniformParagraphs,
579
+ scope: "prose",
580
+ },
581
+ {
582
+ id: "uniform-sentences",
583
+ message: "Sentences of the same length. Vary the rhythm",
584
+ check: uniformSentences,
585
+ },
321
586
  ];
package/src/shape.js ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Shape rules look at paragraphs instead of single lines.
3
+ *
4
+ * A paragraph is a run of lines with no blank line between them.
5
+ * Comment markers, headings, tables, and fenced code do not count.
6
+ * A list item or a JSDoc tag starts a new paragraph.
7
+ */
8
+
9
+ const MARKER = /^\s*(?:\/\/+|#+|\*+|\/\*+|<!--|--|;+|%+|"""|''')?\s*/;
10
+ const TRAILER = /\s*(?:\*\/|-->|"""|''')\s*$/;
11
+ const LIST_ITEM = /^(?:[-*+]|\d+[.)])\s+|^@\w+/;
12
+ const SENTENCE_END = /[.!?]+(?:["')\]]+)?(?:\s+|$)/;
13
+
14
+ export const WALL_WORDS = 120;
15
+ export const WALL_SENTENCES = 7;
16
+ export const LONG_SENTENCE_WORDS = 30;
17
+ export const UNIFORM_PARAGRAPH_MIN_WORDS = 25;
18
+ export const UNIFORM_PARAGRAPH_SPREAD = 1.25;
19
+ export const UNIFORM_SENTENCE_MIN_COUNT = 4;
20
+ export const UNIFORM_SENTENCE_MIN_WORDS = 8;
21
+ export const UNIFORM_SENTENCE_VARIATION = 0.2;
22
+
23
+ /**
24
+ * Groups segments into paragraphs.
25
+ *
26
+ * Pass inComments: true for comment segments, so that
27
+ * comment markers are removed before counting.
28
+ *
29
+ * Each paragraph has:
30
+ * line, column → where it starts
31
+ * words → word count
32
+ * sentences → word count per sentence
33
+ * preview → its first few words
34
+ */
35
+ export function paragraphs(segments, options) {
36
+ const result = group(segments, options);
37
+
38
+ for (let i = 0; i < result.length; i++) {
39
+ const paragraph = result[i];
40
+ const spans = paragraph.spans;
41
+ const sentences = [];
42
+ for (let s = 0; s < spans.length; s++) sentences.push(spans[s].words);
43
+ paragraph.sentences = sentences;
44
+ delete paragraph.spans;
45
+ delete paragraph.pieces;
46
+ }
47
+
48
+ return result;
49
+ }
50
+
51
+ /**
52
+ * Groups segments into paragraphs and keeps where each sentence starts.
53
+ *
54
+ * Each paragraph has the public fields, plus two internal ones:
55
+ * spans → { offset, words, preview } per sentence
56
+ * pieces → { offset, line, column } per source line
57
+ *
58
+ * locate() turns an offset into the paragraph's text back into a position.
59
+ */
60
+ function group(segments, { inComments = false } = {}) {
61
+ const result = [];
62
+ let current = null;
63
+ let inFence = false;
64
+ let previousLine = 0;
65
+
66
+ for (let i = 0; i < segments.length; i++) {
67
+ const segment = segments[i];
68
+ const text = inComments ? strip(segment.text) : segment.text.trim();
69
+
70
+ if (text.startsWith("```") || text.startsWith("~~~")) {
71
+ inFence = !inFence;
72
+ current = null;
73
+ continue;
74
+ }
75
+
76
+ const gap = segment.line !== previousLine + 1;
77
+ previousLine = segment.line;
78
+
79
+ if (inFence || text.length === 0 || text.startsWith("#") || text.startsWith("|")) {
80
+ current = null;
81
+ continue;
82
+ }
83
+
84
+ if (gap || current === null || LIST_ITEM.test(text)) {
85
+ current = { line: segment.line, column: segment.column, text: "", pieces: [] };
86
+ result.push(current);
87
+ }
88
+
89
+ if (current.text.length > 0) current.text += " ";
90
+ current.pieces.push({
91
+ offset: current.text.length,
92
+ line: segment.line,
93
+ column: segment.column + segment.text.indexOf(text),
94
+ });
95
+ current.text += text;
96
+ }
97
+
98
+ for (let i = 0; i < result.length; i++) {
99
+ const paragraph = result[i];
100
+ const spans = sentenceSpans(paragraph.text);
101
+ let words = 0;
102
+ for (let s = 0; s < spans.length; s++) words += spans[s].words;
103
+ paragraph.spans = spans;
104
+ paragraph.words = words;
105
+ paragraph.preview = preview(paragraph.text);
106
+ delete paragraph.text;
107
+ }
108
+
109
+ return result;
110
+ }
111
+
112
+ /**
113
+ * The line and column of an offset into a paragraph's text.
114
+ */
115
+ function locate(paragraph, offset) {
116
+ const pieces = paragraph.pieces;
117
+ let piece = pieces[0];
118
+ for (let i = 1; i < pieces.length; i++) {
119
+ if (pieces[i].offset > offset) break;
120
+ piece = pieces[i];
121
+ }
122
+ return { line: piece.line, column: piece.column + (offset - piece.offset) };
123
+ }
124
+
125
+ export function wallOfText(segments, scope) {
126
+ const found = [];
127
+ const all = paragraphs(segments, { inComments: scope === "comments" });
128
+
129
+ for (let i = 0; i < all.length; i++) {
130
+ const paragraph = all[i];
131
+ if (paragraph.words <= WALL_WORDS && paragraph.sentences.length <= WALL_SENTENCES) continue;
132
+ found.push({
133
+ line: paragraph.line,
134
+ column: paragraph.column,
135
+ text: `${paragraph.words} words, ${paragraph.sentences.length} sentences: ${paragraph.preview}`,
136
+ });
137
+ }
138
+
139
+ return found;
140
+ }
141
+
142
+ export function longSentences(segments, scope) {
143
+ const found = [];
144
+ const all = group(segments, { inComments: scope === "comments" });
145
+
146
+ for (let i = 0; i < all.length; i++) {
147
+ const spans = all[i].spans;
148
+ for (let s = 0; s < spans.length; s++) {
149
+ if (spans[s].words <= LONG_SENTENCE_WORDS) continue;
150
+ const at = locate(all[i], spans[s].offset);
151
+ found.push({
152
+ line: at.line,
153
+ column: at.column,
154
+ text: `${spans[s].words} words: ${spans[s].preview}`,
155
+ });
156
+ }
157
+ }
158
+
159
+ return found;
160
+ }
161
+
162
+ export function uniformParagraphs(segments, scope) {
163
+ const found = [];
164
+ const all = paragraphs(segments, { inComments: scope === "comments" });
165
+ let start = 0;
166
+
167
+ while (start < all.length) {
168
+ let end = start;
169
+ let min = all[start].words;
170
+ let max = min;
171
+
172
+ while (end + 1 < all.length) {
173
+ const next = all[end + 1].words;
174
+ const lo = Math.min(min, next);
175
+ const hi = Math.max(max, next);
176
+ if (lo < UNIFORM_PARAGRAPH_MIN_WORDS || hi > lo * UNIFORM_PARAGRAPH_SPREAD) break;
177
+ min = lo;
178
+ max = hi;
179
+ end++;
180
+ }
181
+
182
+ if (end - start >= 2 && min >= UNIFORM_PARAGRAPH_MIN_WORDS) {
183
+ const counts = [];
184
+ for (let i = start; i <= end; i++) counts.push(all[i].words);
185
+ found.push({
186
+ line: all[start].line,
187
+ column: all[start].column,
188
+ text: `${counts.length} paragraphs of ${counts.join(", ")} words`,
189
+ });
190
+ start = end + 1;
191
+ } else {
192
+ start++;
193
+ }
194
+ }
195
+
196
+ return found;
197
+ }
198
+
199
+ export function uniformSentences(segments, scope) {
200
+ const found = [];
201
+ const all = paragraphs(segments, { inComments: scope === "comments" });
202
+
203
+ for (let i = 0; i < all.length; i++) {
204
+ const lengths = all[i].sentences;
205
+ if (lengths.length < UNIFORM_SENTENCE_MIN_COUNT) continue;
206
+
207
+ const mean = all[i].words / lengths.length;
208
+ if (mean < UNIFORM_SENTENCE_MIN_WORDS) continue;
209
+
210
+ let squares = 0;
211
+ for (let s = 0; s < lengths.length; s++) squares += (lengths[s] - mean) ** 2;
212
+ const variation = Math.sqrt(squares / lengths.length) / mean;
213
+ if (variation >= UNIFORM_SENTENCE_VARIATION) continue;
214
+
215
+ found.push({
216
+ line: all[i].line,
217
+ column: all[i].column,
218
+ text: `${lengths.length} sentences of ${lengths.join(", ")} words`,
219
+ });
220
+ }
221
+
222
+ return found;
223
+ }
224
+
225
+ function strip(text) {
226
+ return text.replace(TRAILER, "").replace(MARKER, "").trim();
227
+ }
228
+
229
+ function sentenceSpans(text) {
230
+ const spans = [];
231
+ const ends = new RegExp(SENTENCE_END.source, "g");
232
+ let start = 0;
233
+ let match;
234
+
235
+ while ((match = ends.exec(text)) !== null) {
236
+ push(start, text.slice(start, match.index));
237
+ start = match.index + match[0].length;
238
+ }
239
+ push(start, text.slice(start));
240
+ return spans;
241
+
242
+ function push(offset, part) {
243
+ const count = wordCount(part);
244
+ if (count > 0) spans.push({ offset, words: count, preview: preview(part) });
245
+ }
246
+ }
247
+
248
+ function wordCount(text) {
249
+ let count = 0;
250
+ let inWord = false;
251
+ for (let i = 0; i < text.length; i++) {
252
+ const space = text.charCodeAt(i) <= 32;
253
+ if (!space && !inWord) count++;
254
+ inWord = !space;
255
+ }
256
+ return count;
257
+ }
258
+
259
+ function preview(text) {
260
+ const words = text.split(/\s+/, 6);
261
+ return words.join(" ") + (words.length === 6 ? "..." : "");
262
+ }