@reticlehq/eslint-plugin 2.13.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,17 @@ export declare const DEFAULT_MUTATORS: readonly string[];
15
15
  /** Plugin meta name used in the flat-config export + RuleCreator url namespace. */
16
16
  export declare const PLUGIN_NAME = "reticle";
17
17
  /** Docs URL builder root for ESLintUtils.RuleCreator. */
18
- export declare const DOCS_URL_ROOT = "https://github.com/reticlehq/reticle/blob/main/packages/eslint-plugin/README.md";
18
+ export declare const DOCS_URL_ROOT = "https://github.com/reticlehq/reticle/blob/main/eslint-plugin/README.md";
19
19
  /** Rule that keeps design-doc codes and internal version strings out of source comments. */
20
20
  export declare const INTERNAL_TAGS_RULE_NAME = "no-internal-tags";
21
+ /** Rule that caps how long one block comment may be. */
22
+ export declare const RUNAWAY_COMMENT_RULE_NAME = "no-runaway-comment";
23
+ /**
24
+ * Longest permitted block comment, in lines.
25
+ *
26
+ * Set above the longest genuine comment in the repository (45) and far below the 423-line accident
27
+ * this rule exists to catch, so it reports a paste and never a paragraph.
28
+ */
29
+ export declare const MAX_COMMENT_LINES = 60;
30
+ /** The human-readable report text (the assertion in tests matches this verbatim). */
31
+ export declare const RUNAWAY_COMMENT_MESSAGE = "block comment is {{lines}} lines (max {{max}}) \u2014 a comment this long is usually pasted output, not prose";
package/dist/constants.js CHANGED
@@ -14,6 +14,17 @@ export const DEFAULT_MUTATORS = [];
14
14
  /** Plugin meta name used in the flat-config export + RuleCreator url namespace. */
15
15
  export const PLUGIN_NAME = 'reticle';
16
16
  /** Docs URL builder root for ESLintUtils.RuleCreator. */
17
- export const DOCS_URL_ROOT = 'https://github.com/reticlehq/reticle/blob/main/packages/eslint-plugin/README.md';
17
+ export const DOCS_URL_ROOT = 'https://github.com/reticlehq/reticle/blob/main/eslint-plugin/README.md';
18
18
  /** Rule that keeps design-doc codes and internal version strings out of source comments. */
19
19
  export const INTERNAL_TAGS_RULE_NAME = 'no-internal-tags';
20
+ /** Rule that caps how long one block comment may be. */
21
+ export const RUNAWAY_COMMENT_RULE_NAME = 'no-runaway-comment';
22
+ /**
23
+ * Longest permitted block comment, in lines.
24
+ *
25
+ * Set above the longest genuine comment in the repository (45) and far below the 423-line accident
26
+ * this rule exists to catch, so it reports a paste and never a paragraph.
27
+ */
28
+ export const MAX_COMMENT_LINES = 60;
29
+ /** The human-readable report text (the assertion in tests matches this verbatim). */
30
+ export const RUNAWAY_COMMENT_MESSAGE = 'block comment is {{lines}} lines (max {{max}}) — a comment this long is usually pasted output, not prose';
package/dist/index.d.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  /** @reticlehq/eslint-plugin — flat-config plugin export. */
2
2
  import { requireSignalOnMutation } from './require-signal-on-mutation.js';
3
3
  import { noInternalTags } from './no-internal-tags.js';
4
+ import { noRunawayComment } from './no-runaway-comment.js';
4
5
  export declare const rules: {
5
6
  readonly "require-signal-on-mutation": import("@typescript-eslint/utils/ts-eslint").RuleModule<"mutationWithoutSignal", [import("./options.js").RawRuleOptions], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
6
7
  name: string;
7
8
  };
8
- readonly "no-internal-tags": import("@typescript-eslint/utils/ts-eslint").RuleModule<"internalTag", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
9
+ readonly "no-internal-tags": import("@typescript-eslint/utils/ts-eslint").RuleModule<"internalTag" | "pathTag", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
10
+ name: string;
11
+ };
12
+ readonly "no-runaway-comment": import("@typescript-eslint/utils/ts-eslint").RuleModule<"runawayComment", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
9
13
  name: string;
10
14
  };
11
15
  };
@@ -17,11 +21,14 @@ declare const plugin: {
17
21
  readonly "require-signal-on-mutation": import("@typescript-eslint/utils/ts-eslint").RuleModule<"mutationWithoutSignal", [import("./options.js").RawRuleOptions], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
18
22
  name: string;
19
23
  };
20
- readonly "no-internal-tags": import("@typescript-eslint/utils/ts-eslint").RuleModule<"internalTag", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
24
+ readonly "no-internal-tags": import("@typescript-eslint/utils/ts-eslint").RuleModule<"internalTag" | "pathTag", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
25
+ name: string;
26
+ };
27
+ readonly "no-runaway-comment": import("@typescript-eslint/utils/ts-eslint").RuleModule<"runawayComment", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
21
28
  name: string;
22
29
  };
23
30
  };
24
31
  configs: Record<string, unknown>;
25
32
  };
26
33
  export default plugin;
27
- export { requireSignalOnMutation, noInternalTags };
34
+ export { requireSignalOnMutation, noInternalTags, noRunawayComment };
package/dist/index.js CHANGED
@@ -1,10 +1,12 @@
1
1
  /** @reticlehq/eslint-plugin — flat-config plugin export. */
2
2
  import { requireSignalOnMutation } from './require-signal-on-mutation.js';
3
3
  import { noInternalTags } from './no-internal-tags.js';
4
- import { PLUGIN_NAME, RULE_NAME, INTERNAL_TAGS_RULE_NAME } from './constants.js';
4
+ import { noRunawayComment } from './no-runaway-comment.js';
5
+ import { PLUGIN_NAME, RULE_NAME, INTERNAL_TAGS_RULE_NAME, RUNAWAY_COMMENT_RULE_NAME, } from './constants.js';
5
6
  export const rules = {
6
7
  [RULE_NAME]: requireSignalOnMutation,
7
8
  [INTERNAL_TAGS_RULE_NAME]: noInternalTags,
9
+ [RUNAWAY_COMMENT_RULE_NAME]: noRunawayComment,
8
10
  };
9
11
  const plugin = {
10
12
  meta: { name: PLUGIN_NAME },
@@ -20,7 +22,8 @@ plugin.configs['recommended'] = {
20
22
  rules: {
21
23
  [`${PLUGIN_NAME}/${RULE_NAME}`]: 'warn',
22
24
  [`${PLUGIN_NAME}/${INTERNAL_TAGS_RULE_NAME}`]: 'error',
25
+ [`${PLUGIN_NAME}/${RUNAWAY_COMMENT_RULE_NAME}`]: 'error',
23
26
  },
24
27
  };
25
28
  export default plugin;
26
- export { requireSignalOnMutation, noInternalTags };
29
+ export { requireSignalOnMutation, noInternalTags, noRunawayComment };
@@ -1,14 +1,12 @@
1
1
  /**
2
2
  * Rule: no-internal-tags.
3
3
  *
4
- * Bans design-doc reference codes and internal version strings from comments, so a reader of the source
5
- * never meets a token that only means something in a document they cannot see.
4
+ * Bans design-doc reference codes and internal version strings from comments, TEST DESCRIPTIONS and
5
+ * the FILE AND DIRECTORY NAMES of the module itself, so a reader of the source never meets a token
6
+ * that only means something in a document they cannot see.
6
7
  *
7
- * This rule exists because the prose version of it did not hold. An audit of the codebase found the
8
- * machine-checked rules at ~100% compliance and every prose-only rule violated — 59 tracking codes
9
- * across the library packages alone, including in the contract package and in test descriptions, plus
10
- * codes that leaked into user-facing docs. The gap was enforcement, not intent, so the rule is now a
11
- * lint error rather than a paragraph.
8
+ * A path is the first thing a reader meets, before any comment, so `n5-ring-buffer.ts` is the shape
9
+ * this bans in the loudest available place.
12
10
  *
13
11
  * Deliberately narrow, to avoid punishing legitimate prose:
14
12
  * - a bare `W11`, `B37`, `N5`, `M8` style token (one or two capitals + digits) standing alone;
@@ -18,6 +16,6 @@
18
16
  * `H2`, `Welford M2` when part of a sentence) is matched only when it stands alone as a tag.
19
17
  */
20
18
  import { ESLintUtils } from '@typescript-eslint/utils';
21
- export declare const noInternalTags: ESLintUtils.RuleModule<"internalTag", [], unknown, ESLintUtils.RuleListener> & {
19
+ export declare const noInternalTags: ESLintUtils.RuleModule<"internalTag" | "pathTag", [], unknown, ESLintUtils.RuleListener> & {
22
20
  name: string;
23
21
  };
@@ -1,14 +1,12 @@
1
1
  /**
2
2
  * Rule: no-internal-tags.
3
3
  *
4
- * Bans design-doc reference codes and internal version strings from comments, so a reader of the source
5
- * never meets a token that only means something in a document they cannot see.
4
+ * Bans design-doc reference codes and internal version strings from comments, TEST DESCRIPTIONS and
5
+ * the FILE AND DIRECTORY NAMES of the module itself, so a reader of the source never meets a token
6
+ * that only means something in a document they cannot see.
6
7
  *
7
- * This rule exists because the prose version of it did not hold. An audit of the codebase found the
8
- * machine-checked rules at ~100% compliance and every prose-only rule violated — 59 tracking codes
9
- * across the library packages alone, including in the contract package and in test descriptions, plus
10
- * codes that leaked into user-facing docs. The gap was enforcement, not intent, so the rule is now a
11
- * lint error rather than a paragraph.
8
+ * A path is the first thing a reader meets, before any comment, so `n5-ring-buffer.ts` is the shape
9
+ * this bans in the loudest available place.
12
10
  *
13
11
  * Deliberately narrow, to avoid punishing legitimate prose:
14
12
  * - a bare `W11`, `B37`, `N5`, `M8` style token (one or two capitals + digits) standing alone;
@@ -17,33 +15,43 @@
17
15
  * Ordinary technical prose that happens to contain a letter and digits inside a larger word (`UTF-8`,
18
16
  * `H2`, `Welford M2` when part of a sentence) is matched only when it stands alone as a tag.
19
17
  */
18
+ import { relative, sep } from 'node:path';
20
19
  import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils';
21
20
  import { DOCS_URL_ROOT } from './constants.js';
22
21
  const createRule = ESLintUtils.RuleCreator((name) => `${DOCS_URL_ROOT}#${name}`);
23
22
  const INTERNAL_TAG_MESSAGE = "Internal reference '{{tag}}' does not belong in source. It means nothing to a reader without the design doc — describe the behaviour instead.";
24
- /** `(W11)`, `B37`, `W10.3`, `N5:` — a short capital-prefixed code used as a label. */
25
- const TRACKING_CODE = /(?<![A-Za-z0-9])[A-Z]{1,2}\d{1,2}(?:\.\d{1,2})?(?![A-Za-z0-9])/g;
23
+ const PATH_TAG_MESSAGE = "Internal reference '{{tag}}' does not belong in a file or directory name. It means nothing to a reader without the design doc — name the module after what it does.";
26
24
  /**
27
- * `§4.3` — a design-doc section reference.
28
- *
29
- * Defaults to FLAGGING and exempts only a citation of a NAMED EXTERNAL spec. The first version did the
30
- * reverse — it exempted any § preceded by a word — which silently permitted `see PLAN §4.3`, the very
31
- * shape being banned. For a ban rule the default must be "flag", or the exemption swallows the rule.
25
+ * `(W11)`, `B37`, `W10.3`, `N5:`, `P5a` — a short capital-prefixed code used as a label. The optional
26
+ * trailing letter is how a design document tells sibling parts apart. Exactly ONE, and lower case:
27
+ * two would start matching ordinary identifiers, and an upper-case one would catch names like `UTF8B`.
28
+ * Exempted by `ALLOWED` below.
29
+ */
30
+ const TRACKING_CODE = /(?<![A-Za-z0-9])[A-Z]{1,2}\d{1,2}(?:\.\d{1,2})?[a-z]?(?![A-Za-z0-9])/g;
31
+ /**
32
+ * `§4.3` — a design-doc section reference. Flags by default and exempts only a citation of a NAMED
33
+ * EXTERNAL spec (`EXTERNAL_SPECS`); exempting any § preceded by a word would permit `see PLAN §4.3`,
34
+ * the very shape being banned.
32
35
  */
33
36
  const SECTION_REF = /§\s?\d+(?:\.\d+)*/g;
34
37
  /** Specs whose section numbers are real references a reader can follow. */
35
38
  const EXTERNAL_SPECS = /\b(RFC|WCAG|ECMA|ISO|IEEE|W3C|WHATWG|HTML|CSS|ARIA|HTTP|OAuth|JSON-LD)\b[^§]{0,20}$/i;
36
39
  /**
37
- * `v2.2.0` — an INTERNAL version string.
38
- *
39
- * Same inversion: flag by default, exempt only a version attributed to a NAMED third party. Exempting
40
- * "any capitalised preceding word" permitted `new in Reticle v2.2.0` — our own product name made the
41
- * banned string legal.
40
+ * `v2.2.0` — an INTERNAL version string. Flags by default and exempts only a version attributed to a
41
+ * NAMED third party (`THIRD_PARTY`); exempting any capitalised preceding word would make
42
+ * `new in Reticle v2.2.0` legal.
42
43
  */
43
44
  const VERSION_STRING = /(?<![A-Za-z0-9])v\d+\.\d+\.\d+(?![A-Za-z0-9])/g;
45
+ /**
46
+ * The same two shapes over a PATH, case-insensitively: file names here are kebab-case, so `N5` in a
47
+ * sentence reads `n5` in a path and the upper-case patterns above cannot see it. The boundaries do the
48
+ * work that case was doing — `e2e`, `utf8`, `http2` and `base64` all need a non-alphanumeric on both
49
+ * sides of a letters-then-digits token, and so fail them.
50
+ */
51
+ const PATH_TRACKING_CODE = /(?<![A-Za-z0-9])[A-Za-z]{1,2}\d{1,2}(?:\.\d{1,2})?(?![A-Za-z0-9])/g;
52
+ const PATH_VERSION_STRING = /(?<![A-Za-z0-9])v\d+\.\d+\.\d+(?![A-Za-z0-9])/gi;
44
53
  /** Third-party names whose versions are legitimate prose. Reticle is deliberately NOT here. */
45
54
  const THIRD_PARTY = /\b(React|Vite|Node|TypeScript|Playwright|Chrome|Chromium|Firefox|Safari|Zod|Vitest|ESLint|pnpm|MCP)\b[^v]{0,12}$/i;
46
- /** Tokens that look like codes but are established technical terms. */
47
55
  /** Test-block callees whose first argument is a human-readable description. */
48
56
  const TEST_BLOCKS = new Set(['describe', 'it', 'test', 'suite', 'bench']);
49
57
  /**
@@ -69,13 +77,11 @@ const ALLOWED = new Set([
69
77
  'Q4', // quarters
70
78
  'L1',
71
79
  'L2', // cache levels
72
- // The JavaScript engine. Omitting it made the rule fire on ordinary writing about heap growth and
73
- // GC in a JavaScript codebase — the most likely place this rule runs, and the fastest way to get it
74
- // switched off. Found by running the rule over directories the linter had never reached.
80
+ // The JavaScript engine — without it the rule fires on ordinary writing about heap growth and GC.
75
81
  'V8',
76
82
  'H4',
77
83
  'H5',
78
- 'H6', // heading levels — H1-H3 were already here; stopping at 3 was an oversight, not a rule
84
+ 'H6', // heading levels
79
85
  ]);
80
86
  export const noInternalTags = createRule({
81
87
  name: 'no-internal-tags',
@@ -83,7 +89,7 @@ export const noInternalTags = createRule({
83
89
  type: 'problem',
84
90
  docs: { description: 'Ban design-doc codes and internal version strings from comments.' },
85
91
  schema: [],
86
- messages: { internalTag: INTERNAL_TAG_MESSAGE },
92
+ messages: { internalTag: INTERNAL_TAG_MESSAGE, pathTag: PATH_TAG_MESSAGE },
87
93
  },
88
94
  defaultOptions: [],
89
95
  create(context) {
@@ -109,18 +115,62 @@ export const noInternalTags = createRule({
109
115
  }
110
116
  return found;
111
117
  };
118
+ /**
119
+ * Banned tokens in this module's own path, relative to the project root.
120
+ *
121
+ * Relative, never absolute: an absolute path runs through directories nobody chose as part of
122
+ * this codebase (a CI runner's workspace, a developer's home) and flagging those would report a
123
+ * violation the author cannot fix.
124
+ */
125
+ const pathTags = () => {
126
+ const found = new Set();
127
+ const filename = context.filename;
128
+ // `<input>` and `<text>` are what a linter reports for source with no file, e.g. a RuleTester
129
+ // case or a piped snippet. There is no path to judge.
130
+ if (filename.startsWith('<'))
131
+ return found;
132
+ const rel = relative(context.cwd, filename);
133
+ // Outside the project root entirely: `relative` climbs out with `..`, and the segments above
134
+ // the root are not this codebase's to answer for.
135
+ if (rel.startsWith('..'))
136
+ return found;
137
+ const path = rel.split(sep).join('/');
138
+ // Version FIRST, and its spans claimed, because the tracking-code shape is a prefix of it:
139
+ // `replay-v2.2.0.ts` matches `v2.2.0` as a version and `v2.2` as a code, and reporting both
140
+ // would name the same offence twice under two different explanations.
141
+ const claimed = [];
142
+ for (const pattern of [PATH_VERSION_STRING, PATH_TRACKING_CODE]) {
143
+ pattern.lastIndex = 0;
144
+ for (const match of path.matchAll(pattern)) {
145
+ const start = match.index ?? 0;
146
+ const end = start + match[0].length;
147
+ if (claimed.some((c) => start < c.end && end > c.start))
148
+ continue;
149
+ const tag = match[0].trim();
150
+ claimed.push({ start, end });
151
+ if (ALLOWED.has(tag.replace(/[^A-Za-z0-9]/g, '').toUpperCase()))
152
+ continue;
153
+ found.add(tag);
154
+ }
155
+ }
156
+ return found;
157
+ };
112
158
  return {
113
- Program() {
159
+ Program(node) {
114
160
  for (const comment of context.sourceCode.getAllComments()) {
115
161
  for (const tag of tagsIn(comment.value)) {
116
162
  context.report({ node: comment, messageId: 'internalTag', data: { tag } });
117
163
  }
118
164
  }
165
+ // Reported on the Program node: the offence is the module's name, which has no node of its
166
+ // own, and the first line is where a reader will look for it.
167
+ for (const tag of pathTags()) {
168
+ context.report({ node, messageId: 'pathTag', data: { tag } });
169
+ }
119
170
  },
120
171
  /**
121
- * TEST DESCRIPTIONS are string literals, not comments, so a comment-only rule could not see them
122
- * — and the project rule names them explicitly. Seven violations survived a repo-wide cleanup that
123
- * claimed to have removed them, precisely because nothing mechanical was looking here.
172
+ * TEST DESCRIPTIONS are string literals, not comments, so a comment-only rule cannot see them —
173
+ * and the project rule names them explicitly.
124
174
  */
125
175
  CallExpression(node) {
126
176
  const callee = node.callee;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Rule: no-runaway-comment.
3
+ *
4
+ * Caps a single block comment's length.
5
+ *
6
+ * Incident: commit `ab26e749` authored a doc comment through an unquoted heredoc, so the shell
7
+ * evaluated the backticked code spans inside it. `git ls-files` expanded into the file, pasting 407
8
+ * lines of a private repository's layout — a prod CA cert path, a licence-keypair script, a deploy
9
+ * secrets template — into `server/src/language/flows/flows.ts`, and deleting every other code span
10
+ * the comment named. It compiled, it typechecked, it passed every gate, and it reached the published
11
+ * tarball because `dist` retains comments.
12
+ *
13
+ * The cap is the cheapest check that catches it: the accident was 423 lines and the longest genuine
14
+ * comment in the repository is 45, so nothing legitimate is near the limit.
15
+ */
16
+ import { ESLintUtils } from '@typescript-eslint/utils';
17
+ export declare const noRunawayComment: ESLintUtils.RuleModule<"runawayComment", [], unknown, ESLintUtils.RuleListener> & {
18
+ name: string;
19
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Rule: no-runaway-comment.
3
+ *
4
+ * Caps a single block comment's length.
5
+ *
6
+ * Incident: commit `ab26e749` authored a doc comment through an unquoted heredoc, so the shell
7
+ * evaluated the backticked code spans inside it. `git ls-files` expanded into the file, pasting 407
8
+ * lines of a private repository's layout — a prod CA cert path, a licence-keypair script, a deploy
9
+ * secrets template — into `server/src/language/flows/flows.ts`, and deleting every other code span
10
+ * the comment named. It compiled, it typechecked, it passed every gate, and it reached the published
11
+ * tarball because `dist` retains comments.
12
+ *
13
+ * The cap is the cheapest check that catches it: the accident was 423 lines and the longest genuine
14
+ * comment in the repository is 45, so nothing legitimate is near the limit.
15
+ */
16
+ import { AST_TOKEN_TYPES, ESLintUtils } from '@typescript-eslint/utils';
17
+ import { DOCS_URL_ROOT, MAX_COMMENT_LINES, RUNAWAY_COMMENT_MESSAGE } from './constants.js';
18
+ const createRule = ESLintUtils.RuleCreator((name) => `${DOCS_URL_ROOT}#${name}`);
19
+ export const noRunawayComment = createRule({
20
+ name: 'no-runaway-comment',
21
+ meta: {
22
+ type: 'problem',
23
+ docs: { description: 'Cap the length of a single block comment.' },
24
+ schema: [],
25
+ messages: { runawayComment: RUNAWAY_COMMENT_MESSAGE },
26
+ },
27
+ defaultOptions: [],
28
+ create(context) {
29
+ return {
30
+ Program() {
31
+ for (const comment of context.sourceCode.getAllComments()) {
32
+ if (AST_TOKEN_TYPES.Block !== comment.type)
33
+ continue;
34
+ const lines = comment.loc.end.line - comment.loc.start.line + 1;
35
+ if (lines <= MAX_COMMENT_LINES)
36
+ continue;
37
+ context.report({
38
+ loc: comment.loc,
39
+ messageId: 'runawayComment',
40
+ data: { lines: String(lines), max: String(MAX_COMMENT_LINES) },
41
+ });
42
+ }
43
+ },
44
+ };
45
+ },
46
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/eslint-plugin",
3
- "version": "2.13.1",
3
+ "version": "3.1.0",
4
4
  "description": "Reticle ESLint plugin — keeps the signal layer self-enforcing (mutation-without-signal).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -25,7 +25,7 @@
25
25
  "repository": {
26
26
  "type": "git",
27
27
  "url": "git+https://github.com/reticlehq/reticle.git",
28
- "directory": "packages/eslint-plugin"
28
+ "directory": "adapters/lint/eslint"
29
29
  },
30
30
  "homepage": "https://github.com/reticlehq/reticle#readme",
31
31
  "bugs": "https://github.com/reticlehq/reticle/issues",
@@ -49,7 +49,7 @@
49
49
  "node": ">=20.0.0"
50
50
  },
51
51
  "scripts": {
52
- "build": "tsc -b",
52
+ "build": "tsc -b && tsc-alias",
53
53
  "typecheck": "tsc -b",
54
54
  "lint": "eslint src",
55
55
  "test:unit": "vitest run src --passWithNoTests"