nollm 0.0.0 → 0.1.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,7 +75,6 @@ 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
 
@@ -72,20 +84,30 @@ Files of other types, binary files, lockfiles, minified files, and files over 2
72
84
  | --------------------- | ----------------------------------------------------------------------- |
73
85
  | `banned-word` | genuinely, load-bearing, crutch, spearheaded, fails loudly, and friends |
74
86
  | `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 |
87
+ | `bold-list-item` | List items like `- **Label:** plain text` |
88
+ | `filler-word` | simply, seamlessly, seamless, robust |
77
89
  | `llm-vocabulary` | delve, tapestry, crucial, game-changer, battle-tested, and more |
78
90
  | `chat-opener` | Lines that start with "Great question", "Certainly", "Let me", and more |
79
91
  | `chat-closer` | "Hope this helps", "Let me know if", "Feel free to", and more |
80
92
  | `ai-disclosure` | "As an AI", "my training data", and more |
81
- | `error-exclamation` | "Oops", "Uh oh", "Something went wrong" |
82
93
  | `contrast-cliche` | "not just X, but Y" and "it's not X, it's Y" |
83
94
  | `rhetorical-question` | "Why? Because" and "The result?" |
84
- | `emoji` | Emoji |
85
- | `diff-comment` | Comments about the change: "no longer", "as discussed", "previously" |
95
+ | `emoji-list` | List items that start with an emoji |
96
+ | `diff-comment` | Comments about the change: "no longer", "as discussed", "Previously," |
86
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.
87
107
 
88
- Run `nollm --list-rules` for the full list.
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.1.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(20)} ${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
@@ -64,7 +64,7 @@ function resolveRules(overrides = {}, words = []) {
64
64
  const rule = builtinRules[i];
65
65
  const override = overrides[rule.id];
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) {
@@ -86,12 +86,23 @@ function resolveRules(overrides = {}, words = []) {
86
86
  return rules;
87
87
  }
88
88
 
89
- function customRule(id, override) {
89
+ /**
90
+ * Builds a rule from a config entry.
91
+ * Keys that the entry leaves out come from the built in rule, when there is one.
92
+ */
93
+ function customRule(id, override, base = {}) {
94
+ const message = override.message ?? base.message ?? id;
95
+ const scope = override.scope ?? base.scope;
96
+ const check = override.check ?? (override.pattern === undefined ? base.check : undefined);
97
+
98
+ if (typeof check === "function") {
99
+ return { id, message, check, scope };
100
+ }
90
101
  return {
91
102
  id,
92
- message: override.message ?? id,
93
- pattern: toGlobal(id, override.pattern, override.flags),
94
- scope: override.scope,
103
+ message,
104
+ pattern: toGlobal(id, override.pattern ?? base.pattern, override.flags),
105
+ scope,
95
106
  };
96
107
  }
97
108
 
@@ -102,7 +113,9 @@ function toGlobal(id, pattern, flags = "") {
102
113
  if (pattern instanceof RegExp) {
103
114
  return pattern.global ? pattern : new RegExp(pattern.source, pattern.flags + "g");
104
115
  }
105
- throw new Error(`Rule "${id}" needs a pattern: a RegExp, or a string with optional flags`);
116
+ throw new Error(
117
+ `Rule "${id}" needs a pattern (a RegExp, or a string with optional flags) or a check function`,
118
+ );
106
119
  }
107
120
 
108
121
  function isObject(value) {
package/src/index.d.ts CHANGED
@@ -1,12 +1,27 @@
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
5
  message: string;
6
6
  pattern: RegExp;
7
7
  scope?: Scope;
8
8
  }
9
9
 
10
+ export interface ShapeFinding {
11
+ line: number;
12
+ column: number;
13
+ text: string;
14
+ }
15
+
16
+ export interface CheckRule {
17
+ id: string;
18
+ message: string;
19
+ check: (segments: Segment[], scope: Scope) => ShapeFinding[];
20
+ scope?: Scope;
21
+ }
22
+
23
+ export type Rule = PatternRule | CheckRule;
24
+
10
25
  export interface Finding {
11
26
  line: number;
12
27
  column: number;
@@ -48,8 +63,9 @@ export interface Config {
48
63
  }
49
64
 
50
65
  export interface RuleConfig {
51
- pattern: RegExp | string;
66
+ pattern?: RegExp | string;
52
67
  flags?: string;
68
+ check?: (segments: Segment[], scope: Scope) => ShapeFinding[];
53
69
  message?: string;
54
70
  scope?: Scope;
55
71
  }
@@ -83,6 +99,15 @@ export function collectFiles(
83
99
  ): Promise<string[]>;
84
100
  export function extractComments(source: string, language: Language): Segment[];
85
101
  export function extractLines(source: string): Segment[];
102
+ export function paragraphs(segments: Segment[], options?: { inComments?: boolean }): Paragraph[];
103
+
104
+ export interface Paragraph {
105
+ line: number;
106
+ column: number;
107
+ words: number;
108
+ sentences: number[];
109
+ preview: string;
110
+ }
86
111
  export function findConfig(cwd: string): Promise<string | null>;
87
112
  export function loadConfig(configPath: string | null): Promise<Config>;
88
113
  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,16 +131,6 @@ 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
134
  const DIFF_TALK = [
164
135
  "as requested",
165
136
  "as discussed",
@@ -175,8 +146,6 @@ const DIFF_TALK = [
175
146
  "used to be",
176
147
  "used to use",
177
148
  "used to return",
178
- "previously",
179
- "formerly",
180
149
  "the old implementation",
181
150
  "the old version",
182
151
  "the old code",
@@ -197,6 +166,53 @@ const DIFF_TALK = [
197
166
  "moved to",
198
167
  ];
199
168
 
169
+ // Diff talk only when it opens a sentence. "used previously" is fine.
170
+ const DIFF_OPENERS = ["previously", "formerly"];
171
+
172
+ const DRAMATIC_VERBS = [
173
+ "blow up",
174
+ "blows up",
175
+ "blowing up",
176
+ "blew up",
177
+ "die with",
178
+ "dies with",
179
+ "died with",
180
+ "fall over",
181
+ "falls over",
182
+ "fell over",
183
+ "choke on",
184
+ "chokes on",
185
+ "choked on",
186
+ "trip over",
187
+ "trips over",
188
+ "tripped over",
189
+ "bites us",
190
+ "bites you",
191
+ "bit us",
192
+ "explode",
193
+ "explodes",
194
+ "exploded",
195
+ "barf",
196
+ "barfs",
197
+ ];
198
+
199
+ const ERROR_OPENERS = [
200
+ "Cannot",
201
+ "Could not",
202
+ "Couldn't",
203
+ "Unable to",
204
+ "Failed to",
205
+ "Unexpected",
206
+ "Invalid",
207
+ "Expected",
208
+ "Missing",
209
+ "Uncaught",
210
+ "Assertion Failed",
211
+ "Maximum call stack",
212
+ ];
213
+
214
+ const ASIDE_OPENERS = ["and", "but", "though", "although", "plus", "as well as", "not to mention"];
215
+
200
216
  const WHAT_COMMENT_STARTS = [
201
217
  "this function",
202
218
  "this method",
@@ -237,6 +253,9 @@ const WHAT_COMMENT_STARTS = [
237
253
  // A comment starts after its marker, so the rule skips the marker first.
238
254
  const COMMENT_START = String.raw`^\s*(?:\/\/+|#+|\*+|\/\*+|<!--|--|;+|%+|"""|''')?\s*`;
239
255
 
256
+ // The start of a comment or of a sentence, as a lookbehind.
257
+ const SENTENCE_START = String.raw`(?<=${COMMENT_START}|[.!?:;]\s+)`;
258
+
240
259
  export const rules = [
241
260
  {
242
261
  id: "banned-word",
@@ -247,11 +266,12 @@ export const rules = [
247
266
  id: "em-dash",
248
267
  message: "Em dash. Use a comma, a colon, or a new sentence",
249
268
  pattern: /\u2014/g,
269
+ scope: "comments",
250
270
  },
251
271
  {
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,
272
+ id: "bold-list-item",
273
+ message: "List item with a bold label followed by plain text",
274
+ pattern: /^\s*(?:[-*+]|\d+[.)])\s+(?:\*\*[^*\n]{1,80}\*\*|__[^_\n]{1,80}__):?(?=\s+[^\s*_])/gm,
255
275
  scope: "prose",
256
276
  },
257
277
  {
@@ -282,12 +302,6 @@ export const rules = [
282
302
  message: "Text written from the point of view of a chat assistant",
283
303
  pattern: anyOf(AI_DISCLOSURES),
284
304
  },
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
305
  {
292
306
  id: "contrast-cliche",
293
307
  message: "Contrast cliche (not just X, but Y)",
@@ -302,20 +316,66 @@ export const rules = [
302
316
  scope: "prose",
303
317
  },
304
318
  {
305
- id: "emoji",
306
- message: "Emoji",
307
- pattern: /(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F)/gu,
319
+ id: "emoji-list",
320
+ message: "List item that starts with an emoji",
321
+ pattern:
322
+ /^\s*(?:[-*+]|\d+[.)])\s+(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F)/gmu,
323
+ scope: "prose",
308
324
  },
309
325
  {
310
326
  id: "diff-comment",
311
327
  message: "Comment describes the change, not the code. Put it in the commit message",
312
- pattern: anyOf(DIFF_TALK),
328
+ pattern: new RegExp(
329
+ String.raw`\b(?:${words(DIFF_TALK)})\b|${SENTENCE_START}(?:${words(DIFF_OPENERS)})\b`,
330
+ "gmi",
331
+ ),
332
+ scope: "comments",
333
+ },
334
+ {
335
+ id: "quoted-error",
336
+ message:
337
+ "Comment quotes an error message. Say what breaks and why, not what the terminal printed",
338
+ pattern: new RegExp(
339
+ String.raw`["\u201C](?:(?:${words(ERROR_OPENERS)})\b|\w+Error:|\w+ is not (?:a function|defined|iterable)\b)[^"\u201D\n]*["\u201D]?`,
340
+ "g",
341
+ ),
313
342
  scope: "comments",
314
343
  },
344
+ {
345
+ id: "dramatic-verb",
346
+ message: "Dramatic failure verb. Say what happens: throws, hangs, returns null",
347
+ pattern: anyOf(DRAMATIC_VERBS),
348
+ },
349
+ {
350
+ id: "parenthetical-aside",
351
+ message: "Parenthetical aside. Make it a sentence, or delete it",
352
+ pattern: new RegExp(String.raw`\((?:${words(ASIDE_OPENERS)})\b[^()\n]*\)?`, "gi"),
353
+ },
315
354
  {
316
355
  id: "what-comment",
317
356
  message: "Comment narrates what the code does. Say why, or delete it",
318
357
  pattern: new RegExp(String.raw`${COMMENT_START}(?:${words(WHAT_COMMENT_STARTS)})\b`, "gmi"),
319
358
  scope: "comments",
320
359
  },
360
+ {
361
+ id: "long-sentence",
362
+ message: "Long sentence. One idea per sentence",
363
+ check: longSentences,
364
+ },
365
+ {
366
+ id: "wall-of-text",
367
+ message: "Wall of text. Split the paragraph",
368
+ check: wallOfText,
369
+ },
370
+ {
371
+ id: "uniform-paragraphs",
372
+ message: "Consecutive paragraphs of the same length",
373
+ check: uniformParagraphs,
374
+ scope: "prose",
375
+ },
376
+ {
377
+ id: "uniform-sentences",
378
+ message: "Sentences of the same length. Vary the rhythm",
379
+ check: uniformSentences,
380
+ },
321
381
  ];
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
+ }