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 +55 -25
- 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 +33 -8
- package/src/index.d.ts +32 -3
- package/src/index.js +1 -0
- package/src/rules.js +321 -56
- 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,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
|
|
72
|
-
|
|
|
73
|
-
| `banned-word`
|
|
74
|
-
| `em-dash`
|
|
75
|
-
| `bold-
|
|
76
|
-
| `filler-word`
|
|
77
|
-
| `llm-vocabulary`
|
|
78
|
-
| `chat-opener`
|
|
79
|
-
| `chat-closer`
|
|
80
|
-
| `ai-disclosure`
|
|
81
|
-
| `
|
|
82
|
-
| `
|
|
83
|
-
| `
|
|
84
|
-
| `
|
|
85
|
-
| `
|
|
86
|
-
| `
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
"
|
|
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.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
|
-
"
|
|
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(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
|
-
|
|
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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
93
|
-
pattern: toGlobal(id, override.pattern, override.flags),
|
|
94
|
-
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(
|
|
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
|
|
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
|
|
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
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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-
|
|
253
|
-
message: "
|
|
254
|
-
pattern: /^\s*(?:[-*+]|\d+[.)])
|
|
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: "
|
|
307
|
-
pattern:
|
|
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: "
|
|
311
|
-
|
|
312
|
-
|
|
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
|
+
}
|