nollm 0.6.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nollm",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "lint against LLMisms in your codebase",
5
5
  "keywords": [
6
6
  "comments",
package/src/check.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { classify } from "./languages.js";
2
2
  import { extractComments, extractLines } from "./comments.js";
3
3
  import { rules as builtinRules } from "./rules.js";
4
+ import { withoutCode, withoutFences } from "./shape.js";
4
5
 
5
6
  const IGNORE_FILE = "nollm-ignore-file";
6
7
  const IGNORE_NEXT = "nollm-ignore-next-line";
@@ -28,13 +29,18 @@ function collect(kind, source, rules) {
28
29
 
29
30
  if (kind.kind === "prose") {
30
31
  const lines = extractLines(source);
31
- run(findings, lines, rules, "prose");
32
+ run(findings, withoutFences(lines), rules, "prose");
32
33
  run(findings, lines, rules, "everywhere");
33
34
  findings.sort(byPosition);
34
35
  return findings;
35
36
  }
36
37
 
37
- run(findings, extractComments(source, kind.language), rules, "comments");
38
+ run(
39
+ findings,
40
+ withoutCode(withoutFences(extractComments(source, kind.language), { inComments: true })),
41
+ rules,
42
+ "comments",
43
+ );
38
44
  run(findings, extractLines(source), rules, "everywhere");
39
45
  findings.sort(byPosition);
40
46
  return findings;
package/src/shape.js CHANGED
@@ -6,8 +6,8 @@
6
6
  * A list item or a JSDoc tag starts a new paragraph.
7
7
  */
8
8
 
9
- const MARKER = /^\s*(?:\/\/+|#+|\*+|\/\*+|<!--|--|;+|%+|"""|''')?\s*/;
10
- const TRAILER = /\s*(?:\*\/|-->|"""|''')\s*$/;
9
+ const MARKER = /^\s*(?:\/\/+|#+|\*+|\/\*+|<!--|\{\{!--|\{\{!|--|;+|%+|"""|''')?\s*/;
10
+ const TRAILER = /\s*(?:\*\/|-->|--\}\}|\}\}|"""|''')\s*$/;
11
11
  const LIST_ITEM = /^(?:[-*+]|\d+[.)])\s+|^@\w+/;
12
12
  const SENTENCE_END = /[.!?]+(?:["')\]]+)?(?:\s+|$)/;
13
13
 
@@ -306,6 +306,108 @@ function strip(text) {
306
306
  return text.replace(TRAILER, "").replace(MARKER, "").trim();
307
307
  }
308
308
 
309
+ /**
310
+ * Drops the comment lines that are code: commented-out statements, markup
311
+ * and diagrams.
312
+ *
313
+ * Each token votes. Syntax votes for code: brackets, `=`, box drawing.
314
+ * A word votes for prose. An identifier or a number does not vote,
315
+ * since prose names code as often as code does.
316
+ * A quoted string votes once, as code.
317
+ *
318
+ * A line is code when code outvotes prose, or when it has no words.
319
+ * A tie leans code. When more than two thirds of a comment's lines lean code,
320
+ * every line in it is code, so a string inside commented-out code is skipped too.
321
+ * A tie in a prose comment stays prose.
322
+ *
323
+ * The gap a dropped line leaves ends the paragraph, as a blank line does.
324
+ */
325
+ export function withoutCode(segments) {
326
+ const code = new Set();
327
+ for (const comment of comments(segments)) {
328
+ const votes = comment.map((segment) => vote(strip(segment.text)));
329
+ const spoken = votes.filter((v) => v.tokens > 0);
330
+ const mostlyCode = spoken.length > 1 && spoken.filter(leansCode).length * 3 > spoken.length * 2;
331
+ for (let i = 0; i < comment.length; i++) {
332
+ if (isCode(votes[i]) || (mostlyCode && votes[i].tokens > 0)) code.add(comment[i]);
333
+ }
334
+ }
335
+ return segments.filter((segment) => !code.has(segment));
336
+ }
337
+
338
+ const LINE_COMMENT = /^\s*(?:\/\/|#|--|;|%)/;
339
+ const CLOSER = /(?:\*\/|-->|\}\}|"""|''')\s*$/;
340
+
341
+ /**
342
+ * Groups segments by comment. A run of line comments is one comment, and so
343
+ * are the lines of one block comment. Two block comments stay apart.
344
+ */
345
+ function comments(segments) {
346
+ const result = [];
347
+ let previous = null;
348
+ for (const segment of segments) {
349
+ const continues =
350
+ previous !== null &&
351
+ segment.line === previous.line + 1 &&
352
+ (LINE_COMMENT.test(previous.text)
353
+ ? LINE_COMMENT.test(segment.text)
354
+ : !CLOSER.test(previous.text));
355
+ if (continues) result[result.length - 1].push(segment);
356
+ else result.push([segment]);
357
+ previous = segment;
358
+ }
359
+ return result;
360
+ }
361
+
362
+ const WRAP_START = /^[("'“‘«*[]+/u;
363
+ const WRAP_END = /[.,:;!?)"'”’»*\]]+$/u;
364
+ const SYNTAX = /[{}()[\];=<>─-╿]/u;
365
+ const PLAIN_WORD = /^\p{L}+(?:['’-]\p{L}+)*$/u;
366
+ const IDENTIFIER = /\p{Ll}\p{Lu}|[._$]/u;
367
+
368
+ /** A quoted span is one string, as a span of inline code is one word. */
369
+ const STRING = /(?<![\p{L}\p{N}])(["'])[^"'\n]*\1(?![\p{L}\p{N}])/gu;
370
+
371
+ function vote(text) {
372
+ const tokens =
373
+ text
374
+ .replace(/`[^`]*`/g, "code")
375
+ .replace(STRING, "=")
376
+ .match(/\S+/g) ?? [];
377
+ let code = 0;
378
+ let words = 0;
379
+ for (const token of tokens) {
380
+ const core = token.replace(WRAP_START, "").replace(WRAP_END, "");
381
+ if (SYNTAX.test(core)) code++;
382
+ else if (PLAIN_WORD.test(core) && !IDENTIFIER.test(core)) words++;
383
+ }
384
+ return { tokens: tokens.length, code, words };
385
+ }
386
+
387
+ function isCode({ tokens, code, words }) {
388
+ return tokens > 0 && (words === 0 || code > words);
389
+ }
390
+
391
+ function leansCode({ tokens, code, words }) {
392
+ return tokens > 0 && code >= words;
393
+ }
394
+
395
+ /**
396
+ * Drops fenced code: the fences and every line between them. No rule reads
397
+ * code, so this runs before all of them.
398
+ */
399
+ export function withoutFences(segments, { inComments = false } = {}) {
400
+ let inFence = false;
401
+ return segments.filter((segment) => {
402
+ const text = inComments ? strip(segment.text) : segment.text.trim();
403
+ if (text.startsWith("```") || text.startsWith("~~~")) {
404
+ inFence = !inFence;
405
+ return false;
406
+ }
407
+ return !inFence;
408
+ });
409
+ }
410
+
309
411
  function sentenceSpans(text) {
310
412
  const spans = [];
311
413
  const ends = new RegExp(SENTENCE_END.source, "g");