nollm 0.0.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/LICENSE +21 -0
- package/README.md +153 -0
- package/bin/nollm.js +4 -0
- package/package.json +58 -0
- package/src/check.js +86 -0
- package/src/cli.js +114 -0
- package/src/comments.js +156 -0
- package/src/config.js +110 -0
- package/src/files.js +118 -0
- package/src/index.d.ts +88 -0
- package/src/index.js +7 -0
- package/src/languages.js +266 -0
- package/src/lint.js +51 -0
- package/src/rules.js +321 -0
- package/src/worker.js +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NullVoxPopuli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# nollm
|
|
2
|
+
|
|
3
|
+
Lint against LLMisms in your codebase.
|
|
4
|
+
|
|
5
|
+
`nollm` reads every file that git tracks or does not ignore.
|
|
6
|
+
It checks prose files line by line, and code files comment by comment.
|
|
7
|
+
Each finding prints as soon as it is found.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npx nollm
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
pnpm add -D nollm
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Add a script to `package.json`:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"scripts": {
|
|
24
|
+
"lint:prose": "nollm"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Requires Node 22.13 or newer.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
nollm [options] [paths...]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
With no paths, `nollm` checks the current directory.
|
|
38
|
+
Paths can be files or directories.
|
|
39
|
+
|
|
40
|
+
| Option | Effect |
|
|
41
|
+
| ----------------- | ------------------------------------------------------------------ |
|
|
42
|
+
| `--jobs <n>` | Number of worker threads. Defaults to the CPU count. |
|
|
43
|
+
| `--config <path>` | Config file to use. |
|
|
44
|
+
| `--no-git` | Do not ask git for the file list. Read `.gitignore` files instead. |
|
|
45
|
+
| `--quiet` | Print only the summary. |
|
|
46
|
+
| `--list-rules` | Print every rule and exit. |
|
|
47
|
+
|
|
48
|
+
The exit code is 1 when there are findings, and 2 on a usage error.
|
|
49
|
+
|
|
50
|
+
Output looks like this:
|
|
51
|
+
|
|
52
|
+
```
|
|
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)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## What gets checked
|
|
59
|
+
|
|
60
|
+
Prose files: markdown, text, reStructuredText, AsciiDoc, and files named `README`, `CHANGELOG`, `LICENSE`, and similar.
|
|
61
|
+
Every line is checked.
|
|
62
|
+
|
|
63
|
+
Code files: JavaScript, TypeScript, Python, Ruby, Rust, Go, shell, YAML, TOML, HTML, Handlebars, `.gjs`, `.gts`, and many more.
|
|
64
|
+
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
|
+
|
|
67
|
+
Files of other types, binary files, lockfiles, minified files, and files over 2 MB are skipped.
|
|
68
|
+
|
|
69
|
+
## Rules
|
|
70
|
+
|
|
71
|
+
| Rule | Catches |
|
|
72
|
+
| --------------------- | ----------------------------------------------------------------------- |
|
|
73
|
+
| `banned-word` | genuinely, load-bearing, crutch, spearheaded, fails loudly, and friends |
|
|
74
|
+
| `em-dash` | The em dash character |
|
|
75
|
+
| `bold-fragment` | `**Bold label:** followed by plain text` in markdown |
|
|
76
|
+
| `filler-word` | simply, robust, leverage, utilize, in order to, keep in mind, and more |
|
|
77
|
+
| `llm-vocabulary` | delve, tapestry, crucial, game-changer, battle-tested, and more |
|
|
78
|
+
| `chat-opener` | Lines that start with "Great question", "Certainly", "Let me", and more |
|
|
79
|
+
| `chat-closer` | "Hope this helps", "Let me know if", "Feel free to", and more |
|
|
80
|
+
| `ai-disclosure` | "As an AI", "my training data", and more |
|
|
81
|
+
| `error-exclamation` | "Oops", "Uh oh", "Something went wrong" |
|
|
82
|
+
| `contrast-cliche` | "not just X, but Y" and "it's not X, it's Y" |
|
|
83
|
+
| `rhetorical-question` | "Why? Because" and "The result?" |
|
|
84
|
+
| `emoji` | Emoji |
|
|
85
|
+
| `diff-comment` | Comments about the change: "no longer", "as discussed", "previously" |
|
|
86
|
+
| `what-comment` | Comments that narrate the code: "This function returns", "Loop over" |
|
|
87
|
+
|
|
88
|
+
Run `nollm --list-rules` for the full list.
|
|
89
|
+
|
|
90
|
+
## Configuration
|
|
91
|
+
|
|
92
|
+
`nollm` finds its config with [lilconfig](https://github.com/antonk52/lilconfig).
|
|
93
|
+
Put it in one of these places:
|
|
94
|
+
|
|
95
|
+
- a `nollm` key in `package.json`
|
|
96
|
+
- `.nollmrc` or `.nollmrc.json`
|
|
97
|
+
- `.nollmrc.js`, `.nollmrc.cjs`, or `.nollmrc.mjs`
|
|
98
|
+
- `nollm.config.js`, `nollm.config.cjs`, or `nollm.config.mjs`
|
|
99
|
+
|
|
100
|
+
Parent directories are searched too.
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
// nollm.config.js
|
|
104
|
+
export default {
|
|
105
|
+
// .gitignore syntax
|
|
106
|
+
ignore: ["CHANGELOG.md", "tests/fixtures/"],
|
|
107
|
+
|
|
108
|
+
// extra banned words
|
|
109
|
+
words: ["synergy", "circle back"],
|
|
110
|
+
|
|
111
|
+
rules: {
|
|
112
|
+
// turn a rule off
|
|
113
|
+
"em-dash": false,
|
|
114
|
+
|
|
115
|
+
// add a rule, or replace a built in one
|
|
116
|
+
"open-todo": {
|
|
117
|
+
pattern: /\bTODO\b/,
|
|
118
|
+
message: "Open TODO",
|
|
119
|
+
scope: "comments",
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
In JSON configs, write the pattern as a string and add flags in a `flags` key.
|
|
126
|
+
|
|
127
|
+
`scope` is one of:
|
|
128
|
+
|
|
129
|
+
- `prose`: prose files only
|
|
130
|
+
- `comments`: comments in code files only
|
|
131
|
+
- `everywhere`: every line of every file
|
|
132
|
+
|
|
133
|
+
A rule with no scope runs in prose and in comments.
|
|
134
|
+
|
|
135
|
+
To silence one line, put `nollm-ignore-next-line` on the line before it.
|
|
136
|
+
To silence a whole file, put `nollm-ignore-file` anywhere in it.
|
|
137
|
+
|
|
138
|
+
## API
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
import { check, lint } from "nollm";
|
|
142
|
+
|
|
143
|
+
const findings = check("README.md", "This is simply the best.");
|
|
144
|
+
// [{ line: 1, column: 9, ruleId: "filler-word", message: "...", text: "simply" }]
|
|
145
|
+
|
|
146
|
+
const summary = await lint({
|
|
147
|
+
roots: ["src", "docs"],
|
|
148
|
+
onResult({ file, findings }) {
|
|
149
|
+
// runs once per file, as soon as it is done
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
// { files: 12, checked: 10, findings: 3, filesWithFindings: 2 }
|
|
153
|
+
```
|
package/bin/nollm.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nollm",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "lint against LLMisms in your codebase",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"comments",
|
|
7
|
+
"lint",
|
|
8
|
+
"llm",
|
|
9
|
+
"markdown",
|
|
10
|
+
"prose"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "NullVoxPopuli",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/NullVoxPopuli/nollm.git"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"nollm": "bin/nollm.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"bin",
|
|
23
|
+
"src"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "src/index.js",
|
|
27
|
+
"types": "src/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./src/index.d.ts",
|
|
31
|
+
"default": "./src/index.js"
|
|
32
|
+
}
|
|
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
|
+
"dependencies": {
|
|
43
|
+
"ignore": "^7.0.9",
|
|
44
|
+
"lilconfig": "^3.1.3",
|
|
45
|
+
"tinypool": "^2.2.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@tsconfig/node-lts": "^24.0.1",
|
|
49
|
+
"oxfmt": "^0.68.0",
|
|
50
|
+
"oxlint": "^1.83.0",
|
|
51
|
+
"publint": "^0.3.24",
|
|
52
|
+
"vitest": "^5.0.1"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">= 22.5"
|
|
56
|
+
},
|
|
57
|
+
"packageManager": "pnpm@11.22.0"
|
|
58
|
+
}
|
package/src/check.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { classify } from "./languages.js";
|
|
2
|
+
import { extractComments, extractLines } from "./comments.js";
|
|
3
|
+
import { rules as builtinRules } from "./rules.js";
|
|
4
|
+
|
|
5
|
+
const IGNORE_FILE = "nollm-ignore-file";
|
|
6
|
+
const IGNORE_NEXT = "nollm-ignore-next-line";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Checks one file's content and returns its findings.
|
|
10
|
+
*
|
|
11
|
+
* A finding is { line, column, ruleId, message, text }.
|
|
12
|
+
* Files this tool does not read return an empty array.
|
|
13
|
+
*
|
|
14
|
+
* A line that contains nollm-ignore-next-line silences the line after it.
|
|
15
|
+
* A file that contains nollm-ignore-file returns no findings.
|
|
16
|
+
*/
|
|
17
|
+
export function check(filePath, source, rules = builtinRules) {
|
|
18
|
+
const kind = classify(filePath);
|
|
19
|
+
if (!kind) return [];
|
|
20
|
+
if (source.includes(IGNORE_FILE)) return [];
|
|
21
|
+
|
|
22
|
+
const findings = collect(kind, source, rules);
|
|
23
|
+
return source.includes(IGNORE_NEXT) ? withoutIgnored(findings, source) : findings;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function collect(kind, source, rules) {
|
|
27
|
+
const findings = [];
|
|
28
|
+
|
|
29
|
+
if (kind.kind === "prose") {
|
|
30
|
+
const lines = extractLines(source);
|
|
31
|
+
run(findings, lines, rules, "prose");
|
|
32
|
+
run(findings, lines, rules, "everywhere");
|
|
33
|
+
findings.sort(byPosition);
|
|
34
|
+
return findings;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
run(findings, extractComments(source, kind.language), rules, "comments");
|
|
38
|
+
run(findings, extractLines(source), rules, "everywhere");
|
|
39
|
+
findings.sort(byPosition);
|
|
40
|
+
return findings;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function run(findings, segments, rules, scope) {
|
|
44
|
+
for (let r = 0; r < rules.length; r++) {
|
|
45
|
+
const rule = rules[r];
|
|
46
|
+
if (!applies(rule, scope)) continue;
|
|
47
|
+
|
|
48
|
+
for (let s = 0; s < segments.length; s++) {
|
|
49
|
+
const segment = segments[s];
|
|
50
|
+
const pattern = rule.pattern;
|
|
51
|
+
pattern.lastIndex = 0;
|
|
52
|
+
|
|
53
|
+
let match;
|
|
54
|
+
while ((match = pattern.exec(segment.text)) !== null) {
|
|
55
|
+
findings.push({
|
|
56
|
+
line: segment.line,
|
|
57
|
+
column: segment.column + match.index,
|
|
58
|
+
ruleId: rule.id,
|
|
59
|
+
message: rule.message,
|
|
60
|
+
text: match[0].trim(),
|
|
61
|
+
});
|
|
62
|
+
if (match[0].length === 0) pattern.lastIndex++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function withoutIgnored(findings, source) {
|
|
69
|
+
const silenced = new Set();
|
|
70
|
+
const lines = extractLines(source);
|
|
71
|
+
for (let i = 0; i < lines.length; i++) {
|
|
72
|
+
if (lines[i].text.includes(IGNORE_NEXT)) silenced.add(lines[i].line + 1);
|
|
73
|
+
}
|
|
74
|
+
return findings.filter((finding) => !silenced.has(finding.line));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function applies(rule, scope) {
|
|
78
|
+
const own = rule.scope ?? "text";
|
|
79
|
+
if (scope === "everywhere") return own === "everywhere";
|
|
80
|
+
if (own === "text") return true;
|
|
81
|
+
return own === scope;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function byPosition(a, b) {
|
|
85
|
+
return a.line - b.line || a.column - b.column;
|
|
86
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { parseArgs, styleText } from "node:util";
|
|
3
|
+
import { lint } from "./lint.js";
|
|
4
|
+
import { rules } from "./rules.js";
|
|
5
|
+
|
|
6
|
+
const HELP = `Usage: nollm [options] [paths...]
|
|
7
|
+
|
|
8
|
+
Checks files for LLMisms and prints each finding as soon as it is found.
|
|
9
|
+
Files that git ignores are skipped.
|
|
10
|
+
|
|
11
|
+
Options:
|
|
12
|
+
--jobs, -j <n> Number of worker threads (default: cpu count)
|
|
13
|
+
--config <path> Config file (default: nollm.config.js in the current directory)
|
|
14
|
+
--no-git Do not ask git for the file list. Read .gitignore files instead
|
|
15
|
+
--quiet, -q Print only the summary
|
|
16
|
+
--list-rules Print every rule and exit
|
|
17
|
+
--version, -v Print the version and exit
|
|
18
|
+
--help, -h Print this help and exit
|
|
19
|
+
|
|
20
|
+
Exit code 1 when there are findings. Exit code 2 on a usage error.
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
export async function main(
|
|
24
|
+
argv,
|
|
25
|
+
{ stdout = process.stdout, stderr = process.stderr, cwd = process.cwd() } = {},
|
|
26
|
+
) {
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = parseArgs({
|
|
30
|
+
args: argv,
|
|
31
|
+
allowPositionals: true,
|
|
32
|
+
allowNegative: true,
|
|
33
|
+
options: {
|
|
34
|
+
jobs: { type: "string", short: "j" },
|
|
35
|
+
config: { type: "string" },
|
|
36
|
+
git: { type: "boolean", default: true },
|
|
37
|
+
quiet: { type: "boolean", short: "q", default: false },
|
|
38
|
+
"list-rules": { type: "boolean", default: false },
|
|
39
|
+
version: { type: "boolean", short: "v", default: false },
|
|
40
|
+
help: { type: "boolean", short: "h", default: false },
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
} catch (error) {
|
|
44
|
+
stderr.write(`${error.message}\n\n${HELP}`);
|
|
45
|
+
return 2;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { values, positionals } = parsed;
|
|
49
|
+
|
|
50
|
+
if (values.help) {
|
|
51
|
+
stdout.write(HELP);
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (values.version) {
|
|
56
|
+
const require = createRequire(import.meta.url);
|
|
57
|
+
stdout.write(`${require("../package.json").version}\n`);
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (values["list-rules"]) {
|
|
62
|
+
for (let i = 0; i < rules.length; i++) {
|
|
63
|
+
stdout.write(`${rules[i].id.padEnd(20)} ${rules[i].message}\n`);
|
|
64
|
+
}
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let jobs;
|
|
69
|
+
if (values.jobs !== undefined) {
|
|
70
|
+
jobs = Number(values.jobs);
|
|
71
|
+
if (!Number.isInteger(jobs) || jobs < 1) {
|
|
72
|
+
stderr.write(`--jobs needs a positive integer, got "${values.jobs}"\n`);
|
|
73
|
+
return 2;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const paint = (style, text) => styleText(style, text, { stream: stdout });
|
|
78
|
+
const started = performance.now();
|
|
79
|
+
|
|
80
|
+
let summary;
|
|
81
|
+
try {
|
|
82
|
+
summary = await lint({
|
|
83
|
+
roots: positionals.length > 0 ? positionals : ["."],
|
|
84
|
+
cwd,
|
|
85
|
+
configPath: values.config,
|
|
86
|
+
git: values.git,
|
|
87
|
+
jobs,
|
|
88
|
+
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
|
+
}
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
stderr.write(`${error.message}\n`);
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const seconds = ((performance.now() - started) / 1000).toFixed(2);
|
|
102
|
+
const problems = summary.findings === 1 ? "1 problem" : `${summary.findings} problems`;
|
|
103
|
+
const files = summary.filesWithFindings === 1 ? "1 file" : `${summary.filesWithFindings} files`;
|
|
104
|
+
const line = `${problems} in ${files} (${summary.checked} files checked, ${seconds}s)\n`;
|
|
105
|
+
|
|
106
|
+
stdout.write(summary.findings > 0 ? paint("red", line) : paint("green", line));
|
|
107
|
+
return summary.findings > 0 ? 1 : 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
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`;
|
|
114
|
+
}
|
package/src/comments.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scans a source file and returns its comments as segments.
|
|
3
|
+
*
|
|
4
|
+
* A segment is one physical line of one comment:
|
|
5
|
+
* { text, line, column }
|
|
6
|
+
*
|
|
7
|
+
* line and column are 1-based.
|
|
8
|
+
* Block comments produce one segment per line.
|
|
9
|
+
* Comment markers stay in the text.
|
|
10
|
+
*/
|
|
11
|
+
export function extractComments(source, language) {
|
|
12
|
+
const segments = [];
|
|
13
|
+
const stack = [{ language, end: null }];
|
|
14
|
+
let index = 0;
|
|
15
|
+
let line = 1;
|
|
16
|
+
let lineStart = 0;
|
|
17
|
+
|
|
18
|
+
while (index < source.length) {
|
|
19
|
+
const frame = stack[stack.length - 1];
|
|
20
|
+
const current = frame.language;
|
|
21
|
+
|
|
22
|
+
if (frame.end && startsWith(source, index, frame.end)) {
|
|
23
|
+
index += frame.end.length;
|
|
24
|
+
stack.pop();
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const region = findRegion(source, index, current.regions);
|
|
29
|
+
if (region) {
|
|
30
|
+
index += region[0].length;
|
|
31
|
+
stack.push({ language: region[2], end: region[1] });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const comment = findMarker(source, index, current.comments);
|
|
36
|
+
if (comment) {
|
|
37
|
+
const [start, end] = comment;
|
|
38
|
+
const closeAt =
|
|
39
|
+
end === "\n" ? source.indexOf("\n", index) : source.indexOf(end, index + start.length);
|
|
40
|
+
const stop = closeAt === -1 ? source.length : end === "\n" ? closeAt : closeAt + end.length;
|
|
41
|
+
|
|
42
|
+
pushLines(segments, source, index, stop, line, lineStart);
|
|
43
|
+
|
|
44
|
+
for (let i = index; i < stop; i++) {
|
|
45
|
+
if (source.charCodeAt(i) === 10) {
|
|
46
|
+
line++;
|
|
47
|
+
lineStart = i + 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
index = stop;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const quote = findString(source, index, current.strings);
|
|
55
|
+
if (quote) {
|
|
56
|
+
index += quote.length;
|
|
57
|
+
while (index < source.length) {
|
|
58
|
+
const code = source.charCodeAt(index);
|
|
59
|
+
if (code === 92) {
|
|
60
|
+
if (source.charCodeAt(index + 1) === 10) {
|
|
61
|
+
line++;
|
|
62
|
+
lineStart = index + 2;
|
|
63
|
+
}
|
|
64
|
+
index += 2;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (code === 10) {
|
|
68
|
+
if (quote !== "`") break;
|
|
69
|
+
line++;
|
|
70
|
+
lineStart = index + 1;
|
|
71
|
+
}
|
|
72
|
+
if (startsWith(source, index, quote)) {
|
|
73
|
+
index += quote.length;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
index++;
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (source.charCodeAt(index) === 10) {
|
|
82
|
+
line++;
|
|
83
|
+
lineStart = index + 1;
|
|
84
|
+
}
|
|
85
|
+
index++;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return segments;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Every line of a prose file is a segment.
|
|
93
|
+
*/
|
|
94
|
+
export function extractLines(source) {
|
|
95
|
+
const segments = [];
|
|
96
|
+
let line = 1;
|
|
97
|
+
let start = 0;
|
|
98
|
+
|
|
99
|
+
while (start <= source.length) {
|
|
100
|
+
let end = source.indexOf("\n", start);
|
|
101
|
+
if (end === -1) end = source.length;
|
|
102
|
+
if (end > start) {
|
|
103
|
+
segments.push({ text: source.slice(start, end), line, column: 1 });
|
|
104
|
+
}
|
|
105
|
+
line++;
|
|
106
|
+
start = end + 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return segments;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function pushLines(segments, source, from, to, line, lineStart) {
|
|
113
|
+
let start = from;
|
|
114
|
+
let currentLine = line;
|
|
115
|
+
let currentLineStart = lineStart;
|
|
116
|
+
|
|
117
|
+
while (start < to) {
|
|
118
|
+
let end = source.indexOf("\n", start);
|
|
119
|
+
if (end === -1 || end > to) end = to;
|
|
120
|
+
if (end > start) {
|
|
121
|
+
segments.push({
|
|
122
|
+
text: source.slice(start, end),
|
|
123
|
+
line: currentLine,
|
|
124
|
+
column: start - currentLineStart + 1,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
currentLine++;
|
|
128
|
+
currentLineStart = end + 1;
|
|
129
|
+
start = end + 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function startsWith(source, index, marker) {
|
|
134
|
+
return source.startsWith(marker, index);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function findMarker(source, index, markers) {
|
|
138
|
+
for (let i = 0; i < markers.length; i++) {
|
|
139
|
+
if (startsWith(source, index, markers[i][0])) return markers[i];
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function findRegion(source, index, regions) {
|
|
145
|
+
for (let i = 0; i < regions.length; i++) {
|
|
146
|
+
if (startsWith(source, index, regions[i][0])) return regions[i];
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function findString(source, index, quotes) {
|
|
152
|
+
for (let i = 0; i < quotes.length; i++) {
|
|
153
|
+
if (startsWith(source, index, quotes[i])) return quotes[i];
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { lilconfig } from "lilconfig";
|
|
2
|
+
import { rules as builtinRules } from "./rules.js";
|
|
3
|
+
|
|
4
|
+
const SKIPPED = [
|
|
5
|
+
"pnpm-lock.yaml",
|
|
6
|
+
"package-lock.json",
|
|
7
|
+
"yarn.lock",
|
|
8
|
+
"*.min.js",
|
|
9
|
+
"*.min.css",
|
|
10
|
+
"*.map",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export const SEARCH_PLACES = [
|
|
14
|
+
"package.json",
|
|
15
|
+
".nollmrc",
|
|
16
|
+
".nollmrc.json",
|
|
17
|
+
".nollmrc.js",
|
|
18
|
+
".nollmrc.cjs",
|
|
19
|
+
".nollmrc.mjs",
|
|
20
|
+
"nollm.config.js",
|
|
21
|
+
"nollm.config.cjs",
|
|
22
|
+
"nollm.config.mjs",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const explorer = lilconfig("nollm", { searchPlaces: SEARCH_PLACES });
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Finds the config for a directory with lilconfig.
|
|
29
|
+
* Parent directories are searched too.
|
|
30
|
+
*
|
|
31
|
+
* Returns the path of the config file, or null.
|
|
32
|
+
*/
|
|
33
|
+
export async function findConfig(cwd) {
|
|
34
|
+
const result = await explorer.search(cwd);
|
|
35
|
+
return result?.filepath ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Loads a config file and merges it with the defaults.
|
|
40
|
+
*
|
|
41
|
+
* The result has:
|
|
42
|
+
* rules → the rules to run
|
|
43
|
+
* ignore → patterns of files to skip, in .gitignore syntax
|
|
44
|
+
*/
|
|
45
|
+
export async function loadConfig(configPath) {
|
|
46
|
+
let user = {};
|
|
47
|
+
if (configPath) {
|
|
48
|
+
const result = await explorer.load(configPath);
|
|
49
|
+
user = result?.config ?? {};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
path: configPath,
|
|
54
|
+
rules: resolveRules(user.rules, user.words),
|
|
55
|
+
ignore: SKIPPED.concat(user.ignore ?? []),
|
|
56
|
+
maxBytes: user.maxBytes ?? 2 * 1024 * 1024,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveRules(overrides = {}, words = []) {
|
|
61
|
+
const rules = [];
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < builtinRules.length; i++) {
|
|
64
|
+
const rule = builtinRules[i];
|
|
65
|
+
const override = overrides[rule.id];
|
|
66
|
+
if (override === false) continue;
|
|
67
|
+
rules.push(isObject(override) ? customRule(rule.id, override) : rule);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const id in overrides) {
|
|
71
|
+
const override = overrides[id];
|
|
72
|
+
if (!isObject(override)) continue;
|
|
73
|
+
if (builtinRules.some((rule) => rule.id === id)) continue;
|
|
74
|
+
rules.push(customRule(id, override));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (words.length > 0) {
|
|
78
|
+
const escaped = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
79
|
+
rules.push({
|
|
80
|
+
id: "custom-word",
|
|
81
|
+
message: "Banned word (nollm config)",
|
|
82
|
+
pattern: new RegExp(String.raw`\b(?:${escaped.join("|")})\b`, "gi"),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return rules;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function customRule(id, override) {
|
|
90
|
+
return {
|
|
91
|
+
id,
|
|
92
|
+
message: override.message ?? id,
|
|
93
|
+
pattern: toGlobal(id, override.pattern, override.flags),
|
|
94
|
+
scope: override.scope,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function toGlobal(id, pattern, flags = "") {
|
|
99
|
+
if (typeof pattern === "string") {
|
|
100
|
+
return new RegExp(pattern, flags.includes("g") ? flags : flags + "g");
|
|
101
|
+
}
|
|
102
|
+
if (pattern instanceof RegExp) {
|
|
103
|
+
return pattern.global ? pattern : new RegExp(pattern.source, pattern.flags + "g");
|
|
104
|
+
}
|
|
105
|
+
throw new Error(`Rule "${id}" needs a pattern: a RegExp, or a string with optional flags`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isObject(value) {
|
|
109
|
+
return typeof value === "object" && value !== null;
|
|
110
|
+
}
|