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 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
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../src/cli.js";
3
+
4
+ process.exitCode = await main(process.argv.slice(2));
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
+ }
@@ -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
+ }