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 +43 -13
- package/bin/nollm.js +6 -0
- package/package.json +11 -11
- package/src/check.js +14 -0
- package/src/cli.js +43 -10
- package/src/config.js +19 -6
- package/src/index.d.ts +28 -3
- package/src/index.js +1 -0
- package/src/rules.js +113 -53
- package/src/shape.js +262 -0
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
|
+

|
|
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
|
|
54
|
+
Output is one block per file, grouped by rule:
|
|
51
55
|
|
|
52
56
|
```
|
|
53
|
-
README.md
|
|
54
|
-
|
|
55
|
-
|
|
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-
|
|
76
|
-
| `filler-word` | simply,
|
|
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`
|
|
85
|
-
| `diff-comment` | Comments about the change: "no longer", "as discussed", "
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
93
|
-
pattern: toGlobal(id, override.pattern, override.flags),
|
|
94
|
-
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(
|
|
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
|
|
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
|
|
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
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-
|
|
253
|
-
message: "
|
|
254
|
-
pattern: /^\s*(?:[-*+]|\d+[.)])
|
|
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: "
|
|
307
|
-
pattern:
|
|
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:
|
|
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
|
+
}
|