@starci/eslint-canon-be 1.2.0 → 1.2.2

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/comments.mjs CHANGED
@@ -1,389 +1,388 @@
1
- /**
2
- * The rules that hold `comments.md`.
3
- *
4
- * Five rules, and each is careful about a different false positive:
5
- *
6
- * - `require-export-jsdoc` skips plain data constants. `export const MAX_ATTEMPTS = 3` is already
7
- * fully described by its own name, and demanding a sentence there produces sentences that
8
- * restate the name - which COMMENT-3 forbids. Only declarations with a surface get the check.
9
- * - `require-enum-member-jsdoc` can check that a doc EXISTS and never that it states a
10
- * consequence. That half is read by a person, and the rule says so rather than pretending.
11
- * - `no-non-ascii-source` exempts a marked line, because text a program matches on or emits is
12
- * data wearing prose, and translating it breaks the program.
13
- * - `require-vn-ok-reason` (law 6) holds the half `no-non-ascii-source` cannot: that marker is a
14
- * bare escape hatch unless it says why the line stays, so a bare `vn-ok` is treated the same as
15
- * no marker at all.
16
- * - `no-restated-name-jsdoc` (law 7) holds the decidable slice of law 3 - a doc block whose only
17
- * content is the declared name re-spelled in words teaches nothing beyond the import line, so it
18
- * is COMMENT-3's violation wearing COMMENT-1's shape. It fires only on an exact match between the
19
- * doc's content words and the name's own words, so a doc that adds any real information is left
20
- * alone - the rest of law 3 (whether a sentence explains something outside the line) is not
21
- * decidable and stays a human read.
22
- */
23
-
24
- /** Forward-slash form of a filename, so Windows paths compare like every other path. */
25
- const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
26
-
27
- /** The marker that keeps a non-ASCII literal the program depends on. */
28
- const KEEP_MARKER = /\bvn-ok\b/
29
-
30
- /**
31
- * The character classes this refuses, and why it is NOT simply "ASCII only".
32
- *
33
- * A first cut of this rule banned every non-ASCII codepoint. Measured against the reference
34
- * repository it reported 857 offenders -- and every one was an em dash, a box-drawing run in a
35
- * comment banner, or a middle dot. The code has used those freely and deliberately for its whole
36
- * life, so "ASCII only" was not the law being recorded; it was a stricter law being invented, which
37
- * is the one thing canon must not do.
38
- *
39
- * What the law actually refuses is three things, for three different reasons:
40
- *
41
- * - VIETNAMESE letters, because a reader who does not share the author's first language loses
42
- * exactly the half of the reasoning that explains the surprising parts. Matched precisely, so a
43
- * European loanword (`naive`, `facade`, `Muller`) is never a false positive.
44
- * - EMOJI, because they carry tone rather than information, and tone reads differently to
45
- * everybody.
46
- * - DECORATIVE symbols -- check marks, crosses, arrows used as ornament -- for the same reason.
47
- *
48
- * Typographic punctuation is none of those and stays.
49
- */
50
- const VIETNAMESE_LETTER = /[À-ÃÈ-ÊÌÍÒ-ÕÙÚÝà-ãè-êìíò-õùúýĂăĐđĨĩŨũƠơƯưẠ-ỿ]/
51
-
52
- /** Emoji and pictographs. */
53
- const EMOJI = /[\u{1F300}-\u{1FAFF}\u{1F000}-\u{1F0FF}\u{2600}-\u{27BF}\u{FE0F}\u{1F1E6}-\u{1F1FF}]/u
54
-
55
- /** Ornamental marks that stand in for a word. */
56
- const ORNAMENT = /[✅❌✔✖✗✘⭐⬆⬇➡⬅]/
57
-
58
- /** The Vietnamese language's own name, which is a label rather than prose. */
59
- const ENDONYM = /Tiếng Việt/
60
-
61
- /** Declarations whose surface other files depend on. */
62
- const DOCUMENTED_KINDS = new Set([
63
- "TSInterfaceDeclaration",
64
- "TSTypeAliasDeclaration",
65
- "TSEnumDeclaration",
66
- "ClassDeclaration",
67
- "FunctionDeclaration",
68
- ])
69
-
70
- /** What a line offends with, or null. */
71
- const offenceIn = (line) => {
72
- const withoutEndonym = line.replace(ENDONYM, "")
73
- if (VIETNAMESE_LETTER.test(withoutEndonym)) return "a Vietnamese letter"
74
- if (EMOJI.test(withoutEndonym)) return "an emoji"
75
- if (ORNAMENT.test(withoutEndonym)) return "an ornamental symbol"
76
- return null
77
- }
78
-
79
- /** Whether a JSDoc block sits immediately before a node. */
80
- const hasJsdocBefore = (sourceCode, node) =>
81
- sourceCode
82
- .getCommentsBefore(node)
83
- .some((comment) => comment.type === "Block" && comment.value.startsWith("*"))
84
-
85
- /** The declared name, for the message. */
86
- const nameOf = (declaration) => {
87
- if (declaration.id) return declaration.id.name
88
- const first = declaration.declarations && declaration.declarations[0]
89
- return (first && first.id && first.id.name) || "this export"
90
- }
91
-
92
- // -- COMMENT-1 -------------------------------------------------------------------------------------
93
-
94
- /** Every export with a surface opens with a doc block. */
95
- export const requireExportJsdoc = {
96
- meta: {
97
- type: "suggestion",
98
- docs: { description: "An exported class/interface/type/enum/function opens with JSDoc." },
99
- schema: [],
100
- messages: {
101
- jsdoc:
102
- "`{{name}}` is exported with no doc block. This is surface other files depend on, and a name plus a signature says what it TAKES - never what it is for, or when to reach for it rather than the thing beside it.",
103
- },
104
- },
105
- create(context) {
106
- const sourceCode = context.sourceCode || context.getSourceCode()
107
- const check = (node) => {
108
- const declaration = node.declaration
109
- // a re-export has nothing here to attach a doc to
110
- if (!declaration) return
111
- if (declaration.type === "VariableDeclaration") {
112
- // only a const bound to a function has a surface; a data constant is already described
113
- const first = declaration.declarations[0]
114
- const init = first && first.init
115
- const isFunction = init
116
- && (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression")
117
- if (!isFunction) return
118
- } else if (!DOCUMENTED_KINDS.has(declaration.type)) {
119
- return
120
- }
121
- if (hasJsdocBefore(sourceCode, node)) return
122
- context.report({
123
- node: declaration.id || declaration,
124
- messageId: "jsdoc",
125
- data: { name: nameOf(declaration) },
126
- })
127
- }
128
- return {
129
- ExportNamedDeclaration: check,
130
- ExportDefaultDeclaration: check,
131
- }
132
- },
133
- }
134
-
135
- // -- COMMENT-2 -------------------------------------------------------------------------------------
136
-
137
- /** Every member of an exported enum carries its own doc. */
138
- export const requireEnumMemberJsdoc = {
139
- meta: {
140
- type: "suggestion",
141
- docs: { description: "Every member of an exported enum carries its own JSDoc." },
142
- schema: [],
143
- messages: {
144
- jsdoc:
145
- "Enum member `{{name}}` has no doc. State what CHOOSING it causes, not what it is called - a member is picked at a call site far from the switch that gives it meaning. (A rule can only see that a doc exists; whether it states a consequence is read by a person.)",
146
- },
147
- },
148
- create(context) {
149
- const sourceCode = context.sourceCode || context.getSourceCode()
150
- return {
151
- TSEnumDeclaration(node) {
152
- if (!node.parent || node.parent.type !== "ExportNamedDeclaration") return
153
- for (const member of node.members || []) {
154
- if (hasJsdocBefore(sourceCode, member)) continue
155
- const name = member.id && (member.id.name || member.id.value)
156
- context.report({ node: member, messageId: "jsdoc", data: { name: name || "?" } })
157
- }
158
- },
159
- }
160
- },
161
- }
162
-
163
- // -- COMMENT-4 -------------------------------------------------------------------------------------
164
-
165
- /** Source is English ASCII, except where a literal is data the program depends on. */
166
- export const noNonAsciiSource = {
167
- meta: {
168
- type: "problem",
169
- docs: { description: "Source stays English ASCII; a depended-upon literal is marked instead." },
170
- schema: [],
171
- messages: {
172
- nonAscii:
173
- "This line carries {{offence}}. The bar is a reader who does not share the author's first language: a codebase with two languages in it has somebody for whom half the reasoning is unavailable, and it is the half explaining the surprising parts - and an emoji or an ornament carries tone rather than information, which reads differently to everybody. If this is text the program MATCHES on or EMITS, it is data rather than prose: keep it and mark the line `vn-ok: <reason>` so the next sweep does not turn it into a bug.",
174
- },
175
- },
176
- create(context) {
177
- const sourceCode = context.sourceCode || context.getSourceCode()
178
- const file = normalizePath(context.filename || context.getFilename())
179
- // locale files are wholly product copy; policing them would be policing the product
180
- if (/\/(?:messages|locales|i18n)\//.test(file)) return {}
181
-
182
- /*
183
- * IN A TEST LANE, A STRING IS A FIXTURE AND A COMMENT IS STILL PROSE.
184
- *
185
- * The rule's own reason draws this line already: prose in a second language leaves half the
186
- * reasoning unavailable to a reader, while "text the program MATCHES on or EMITS is data rather
187
- * than prose". A spec that feeds an agent the sentence a real customer would type - "khach vua
188
- * chuyen khoan" - is feeding it data, and translating it would test a system nobody uses.
189
- *
190
- * Measured before it was written: of 92 findings in one back end, 89 were fixture strings and 3
191
- * were comments. Demanding `vn-ok` on all 92 would have put a marker on every line of every
192
- * conversation fixture, which is noise that teaches a reader to stop seeing the marker.
193
- *
194
- * The exemption is for STRINGS ONLY. A Vietnamese comment in a spec is the same problem it is
195
- * anywhere else - the next reader still cannot follow the reasoning - so it is still refused.
196
- */
197
- const fixtureLane = /\.spec\.ts$|-spec\.ts$|\/src\/tests\//.test(file)
198
- const commentLines = fixtureLane
199
- ? new Set(sourceCode.getAllComments().flatMap((comment) => {
200
- const span = []
201
- for (let line = comment.loc.start.line; line <= comment.loc.end.line; line += 1) span.push(line)
202
- return span
203
- }))
204
- : null
205
-
206
- return {
207
- "Program:exit"(node) {
208
- const lines = sourceCode.getLines()
209
- for (let index = 0; index < lines.length; index += 1) {
210
- const line = lines[index]
211
- if (KEEP_MARKER.test(line)) continue
212
- // in a fixture lane only prose is policed; the data the fixture feeds is the point of it
213
- if (commentLines && !commentLines.has(index + 1)) continue
214
- const offence = offenceIn(line)
215
- if (offence === null) continue
216
- context.report({
217
- node,
218
- loc: {
219
- line: index + 1,
220
- column: 0,
221
- },
222
- messageId: "nonAscii",
223
- data: {
224
- offence,
225
- },
226
- })
227
- }
228
- },
229
- }
230
- },
231
- }
232
-
233
- // -- law 6 -------------------------------------------------------------------------------------
234
-
235
- /** The line-level test for law 6: `vn-ok` present, with a colon and at least one reason character after it. */
236
- const REASON_AFTER_MARKER = /\bvn-ok\s*:\s*\S/
237
-
238
- /** Every `vn-ok` marker carries a reason; a bare marker is not an exemption. */
239
- export const requireVnOkReason = {
240
- meta: {
241
- type: "problem",
242
- docs: {
243
- description:
244
- "COMMENT-5, law 6: a `vn-ok` marker carries a reason - a bare marker is not an exemption.",
245
- },
246
- schema: [],
247
- messages: {
248
- bareMarker:
249
- "`vn-ok` on this line has no reason after it. The marker exists so the next sweep can read WHY the line stays instead of \"fixing\" it into a bug - a bare marker is only a way of switching that check off. Write `vn-ok: <reason>` and say what the marker is protecting.",
250
- },
251
- },
252
- create(context) {
253
- const sourceCode = context.sourceCode || context.getSourceCode()
254
- return {
255
- "Program:exit"(node) {
256
- const lines = sourceCode.getLines()
257
- for (let index = 0; index < lines.length; index += 1) {
258
- const line = lines[index]
259
- if (!KEEP_MARKER.test(line)) continue
260
- if (REASON_AFTER_MARKER.test(line)) continue
261
- context.report({
262
- node,
263
- loc: { line: index + 1, column: 0 },
264
- messageId: "bareMarker",
265
- })
266
- }
267
- },
268
- }
269
- },
270
- }
271
-
272
- // -- law 7 -------------------------------------------------------------------------------------
273
-
274
- /** Splits a declared name into its lowercase words, so `readUser` compares against "read user". */
275
- const wordsOf = (name) =>
276
- (String(name || "")
277
- .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
278
- .replace(/[_-]+/g, " ")
279
- .toLowerCase()
280
- .match(/[a-z0-9]+/g) || [])
281
-
282
- /**
283
- * Filler a doc contributes no information by using: articles and linking words, plus the two generic
284
- * nouns the law's own anchor examples restate with ("the pending state", "the read user function").
285
- * Nothing here strips a word that could carry real content, so a doc that explains anything beyond
286
- * the name keeps enough leftover words to fail the equality check below.
287
- */
288
- const RESTATEMENT_FILLER = new Set(["a", "an", "the", "is", "of", "for", "to", "this", "that", "function", "state"])
289
-
290
- /** The content words a doc comment contributes once filler is stripped out. */
291
- const docContentWords = (comment) =>
292
- (comment.value.match(/[A-Za-z]+/g) || [])
293
- .map((word) => word.toLowerCase())
294
- .filter((word) => !RESTATEMENT_FILLER.has(word))
295
-
296
- /** Whether a doc's leftover content words are exactly the declared name's own words - nothing added. */
297
- const isPureRestatement = (comment, declaredName) => {
298
- const content = docContentWords(comment)
299
- const identifier = wordsOf(declaredName)
300
- if (content.length === 0 || identifier.length === 0) return false
301
- const contentSet = new Set(content)
302
- const identifierSet = new Set(identifier)
303
- if (contentSet.size !== identifierSet.size) return false
304
- for (const word of contentSet) if (!identifierSet.has(word)) return false
305
- return true
306
- }
307
-
308
- /** The doc block immediately before a node, or null. */
309
- const jsdocBefore = (sourceCode, node) =>
310
- sourceCode.getCommentsBefore(node).find((comment) => comment.type === "Block" && comment.value.startsWith("*"))
311
- || null
312
-
313
- /** A doc block that only re-spells the declared name is COMMENT-3's violation wearing COMMENT-1's shape. */
314
- export const noRestatedNameJsdoc = {
315
- meta: {
316
- type: "suggestion",
317
- docs: {
318
- description:
319
- "COMMENT-3, law 7: a doc block that restates the declared name is a COMMENT-3 violation wearing a COMMENT-1 shape, even while the lint gate that checks a doc EXISTS stays green.",
320
- },
321
- schema: [],
322
- messages: {
323
- restated:
324
- "This doc block only re-spells `{{name}}` in words. A name plus a signature already says what it TAKES; the doc has to say what it is FOR, or what choosing it causes, or it is a COMMENT-3 violation wearing a COMMENT-1 shape. Delete the restatement and write the reason instead.",
325
- },
326
- },
327
- create(context) {
328
- const sourceCode = context.sourceCode || context.getSourceCode()
329
- const checkExport = (node) => {
330
- const declaration = node.declaration
331
- // a re-export has nothing here to attach a doc to
332
- if (!declaration) return
333
- if (declaration.type === "VariableDeclaration") {
334
- // only a const bound to a function has a surface; a data constant is already described
335
- const first = declaration.declarations[0]
336
- const init = first && first.init
337
- const isFunction = init
338
- && (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression")
339
- if (!isFunction) return
340
- } else if (!DOCUMENTED_KINDS.has(declaration.type)) {
341
- return
342
- }
343
- const doc = jsdocBefore(sourceCode, node)
344
- if (!doc) return
345
- const name = nameOf(declaration)
346
- if (!isPureRestatement(doc, name)) return
347
- context.report({ node: declaration.id || declaration, messageId: "restated", data: { name } })
348
- }
349
- return {
350
- ExportNamedDeclaration: checkExport,
351
- ExportDefaultDeclaration: checkExport,
352
- TSEnumDeclaration(node) {
353
- if (!node.parent || node.parent.type !== "ExportNamedDeclaration") return
354
- for (const member of node.members || []) {
355
- const doc = jsdocBefore(sourceCode, member)
356
- if (!doc) continue
357
- const name = member.id && (member.id.name || member.id.value)
358
- if (!name || !isPureRestatement(doc, name)) continue
359
- context.report({ node: member, messageId: "restated", data: { name } })
360
- }
361
- },
362
- }
363
- },
364
- }
365
-
366
- /** The rules this law contributes to the plugin. */
367
- export const rules = {
368
- "require-export-jsdoc": requireExportJsdoc,
369
- "require-enum-member-jsdoc": requireEnumMemberJsdoc,
370
- "no-non-ascii-source": noNonAsciiSource,
371
- "require-vn-ok-reason": requireVnOkReason,
372
- "no-restated-name-jsdoc": noRestatedNameJsdoc,
373
- }
374
-
375
- /**
376
- * The level this law asks for, as the plugin's own opinion.
377
- *
378
- * `no-non-ascii-source` replaces three separate rules in the reference plugin - one for Vietnamese,
379
- * one for emoji, one for decorative symbols. They were three character classes answering one
380
- * question, and a reader who hit the emoji rule learned nothing about the other two. One rule with
381
- * one reason is easier to obey, and impossible to satisfy by switching alphabets.
382
- */
383
- export const recommended = {
384
- "starci-be/require-export-jsdoc": "error",
385
- "starci-be/require-enum-member-jsdoc": "error",
386
- "starci-be/no-non-ascii-source": "error",
387
- "starci-be/require-vn-ok-reason": "error",
388
- "starci-be/no-restated-name-jsdoc": "error",
389
- }
1
+ /**
2
+ * The rules that hold `comments.md`.
3
+ *
4
+ * Five rules, and each is careful about a different false positive:
5
+ *
6
+ * - `require-export-jsdoc` skips plain data constants. `export const MAX_ATTEMPTS = 3` is already
7
+ * fully described by its own name, and demanding a sentence there produces sentences that
8
+ * restate the name - which COMMENT-3 forbids. Only declarations with a surface get the check.
9
+ * - `require-enum-member-jsdoc` can check that a doc EXISTS and never that it states a
10
+ * consequence. That half is read by a person, and the rule says so rather than pretending.
11
+ * - `no-non-ascii-source` exempts a marked line, because text a program matches on or emits is
12
+ * data wearing prose, and translating it breaks the program.
13
+ * - `require-vn-ok-reason` (law 6) holds the half `no-non-ascii-source` cannot: that marker is a
14
+ * bare escape hatch unless it says why the line stays, so a bare `vn-ok` is treated the same as
15
+ * no marker at all.
16
+ * - `no-restated-name-jsdoc` (law 7) holds the decidable slice of law 3 - a doc block whose only
17
+ * content is the declared name re-spelled in words teaches nothing beyond the import line, so it
18
+ * is COMMENT-3's violation wearing COMMENT-1's shape. It fires only on an exact match between the
19
+ * doc's content words and the name's own words, so a doc that adds any real information is left
20
+ * alone - the rest of law 3 (whether a sentence explains something outside the line) is not
21
+ * decidable and stays a human read.
22
+ */
23
+
24
+ import { normalizePath } from "./lib/path.mjs"
25
+
26
+ /** The marker that keeps a non-ASCII literal the program depends on. */
27
+ const KEEP_MARKER = /\bvn-ok\b/
28
+
29
+ /**
30
+ * The character classes this refuses, and why it is NOT simply "ASCII only".
31
+ *
32
+ * A first cut of this rule banned every non-ASCII codepoint. Measured against the reference
33
+ * repository it reported 857 offenders -- and every one was an em dash, a box-drawing run in a
34
+ * comment banner, or a middle dot. The code has used those freely and deliberately for its whole
35
+ * life, so "ASCII only" was not the law being recorded; it was a stricter law being invented, which
36
+ * is the one thing canon must not do.
37
+ *
38
+ * What the law actually refuses is three things, for three different reasons:
39
+ *
40
+ * - VIETNAMESE letters, because a reader who does not share the author's first language loses
41
+ * exactly the half of the reasoning that explains the surprising parts. Matched precisely, so a
42
+ * European loanword (`naive`, `facade`, `Muller`) is never a false positive.
43
+ * - EMOJI, because they carry tone rather than information, and tone reads differently to
44
+ * everybody.
45
+ * - DECORATIVE symbols -- check marks, crosses, arrows used as ornament -- for the same reason.
46
+ *
47
+ * Typographic punctuation is none of those and stays.
48
+ */
49
+ const VIETNAMESE_LETTER = /[À-ÃÈ-ÊÌÍÒ-ÕÙÚÝà-ãè-êìíò-õùúýĂăĐđĨĩŨũƠơƯưẠ-ỿ]/
50
+
51
+ /** Emoji and pictographs. */
52
+ const EMOJI = /[\u{1F300}-\u{1FAFF}\u{1F000}-\u{1F0FF}\u{2600}-\u{27BF}\u{FE0F}\u{1F1E6}-\u{1F1FF}]/u
53
+
54
+ /** Ornamental marks that stand in for a word. */
55
+ const ORNAMENT = /[✅❌✔✖✗✘⭐⬆⬇➡⬅]/
56
+
57
+ /** The Vietnamese language's own name, which is a label rather than prose. */
58
+ const ENDONYM = /Tiếng Việt/
59
+
60
+ /** Declarations whose surface other files depend on. */
61
+ const DOCUMENTED_KINDS = new Set([
62
+ "TSInterfaceDeclaration",
63
+ "TSTypeAliasDeclaration",
64
+ "TSEnumDeclaration",
65
+ "ClassDeclaration",
66
+ "FunctionDeclaration",
67
+ ])
68
+
69
+ /** What a line offends with, or null. */
70
+ const offenceIn = (line) => {
71
+ const withoutEndonym = line.replace(ENDONYM, "")
72
+ if (VIETNAMESE_LETTER.test(withoutEndonym)) return "a Vietnamese letter"
73
+ if (EMOJI.test(withoutEndonym)) return "an emoji"
74
+ if (ORNAMENT.test(withoutEndonym)) return "an ornamental symbol"
75
+ return null
76
+ }
77
+
78
+ /** Whether a JSDoc block sits immediately before a node. */
79
+ const hasJsdocBefore = (sourceCode, node) =>
80
+ sourceCode
81
+ .getCommentsBefore(node)
82
+ .some((comment) => comment.type === "Block" && comment.value.startsWith("*"))
83
+
84
+ /** The declared name, for the message. */
85
+ const nameOf = (declaration) => {
86
+ if (declaration.id) return declaration.id.name
87
+ const first = declaration.declarations && declaration.declarations[0]
88
+ return (first && first.id && first.id.name) || "this export"
89
+ }
90
+
91
+ // -- COMMENT-1 -------------------------------------------------------------------------------------
92
+
93
+ /** Every export with a surface opens with a doc block. */
94
+ export const requireExportJsdoc = {
95
+ meta: {
96
+ type: "suggestion",
97
+ docs: { description: "An exported class/interface/type/enum/function opens with JSDoc." },
98
+ schema: [],
99
+ messages: {
100
+ jsdoc:
101
+ "`{{name}}` is exported with no doc block. This is surface other files depend on, and a name plus a signature says what it TAKES - never what it is for, or when to reach for it rather than the thing beside it.",
102
+ },
103
+ },
104
+ create(context) {
105
+ const sourceCode = context.sourceCode || context.getSourceCode()
106
+ const check = (node) => {
107
+ const declaration = node.declaration
108
+ // a re-export has nothing here to attach a doc to
109
+ if (!declaration) return
110
+ if (declaration.type === "VariableDeclaration") {
111
+ // only a const bound to a function has a surface; a data constant is already described
112
+ const first = declaration.declarations[0]
113
+ const init = first && first.init
114
+ const isFunction = init
115
+ && (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression")
116
+ if (!isFunction) return
117
+ } else if (!DOCUMENTED_KINDS.has(declaration.type)) {
118
+ return
119
+ }
120
+ if (hasJsdocBefore(sourceCode, node)) return
121
+ context.report({
122
+ node: declaration.id || declaration,
123
+ messageId: "jsdoc",
124
+ data: { name: nameOf(declaration) },
125
+ })
126
+ }
127
+ return {
128
+ ExportNamedDeclaration: check,
129
+ ExportDefaultDeclaration: check,
130
+ }
131
+ },
132
+ }
133
+
134
+ // -- COMMENT-2 -------------------------------------------------------------------------------------
135
+
136
+ /** Every member of an exported enum carries its own doc. */
137
+ export const requireEnumMemberJsdoc = {
138
+ meta: {
139
+ type: "suggestion",
140
+ docs: { description: "Every member of an exported enum carries its own JSDoc." },
141
+ schema: [],
142
+ messages: {
143
+ jsdoc:
144
+ "Enum member `{{name}}` has no doc. State what CHOOSING it causes, not what it is called - a member is picked at a call site far from the switch that gives it meaning. (A rule can only see that a doc exists; whether it states a consequence is read by a person.)",
145
+ },
146
+ },
147
+ create(context) {
148
+ const sourceCode = context.sourceCode || context.getSourceCode()
149
+ return {
150
+ TSEnumDeclaration(node) {
151
+ if (!node.parent || node.parent.type !== "ExportNamedDeclaration") return
152
+ for (const member of node.members || []) {
153
+ if (hasJsdocBefore(sourceCode, member)) continue
154
+ const name = member.id && (member.id.name || member.id.value)
155
+ context.report({ node: member, messageId: "jsdoc", data: { name: name || "?" } })
156
+ }
157
+ },
158
+ }
159
+ },
160
+ }
161
+
162
+ // -- COMMENT-4 -------------------------------------------------------------------------------------
163
+
164
+ /** Source is English ASCII, except where a literal is data the program depends on. */
165
+ export const noNonAsciiSource = {
166
+ meta: {
167
+ type: "problem",
168
+ docs: { description: "Source stays English ASCII; a depended-upon literal is marked instead." },
169
+ schema: [],
170
+ messages: {
171
+ nonAscii:
172
+ "This line carries {{offence}}. The bar is a reader who does not share the author's first language: a codebase with two languages in it has somebody for whom half the reasoning is unavailable, and it is the half explaining the surprising parts - and an emoji or an ornament carries tone rather than information, which reads differently to everybody. If this is text the program MATCHES on or EMITS, it is data rather than prose: keep it and mark the line `vn-ok: <reason>` so the next sweep does not turn it into a bug.",
173
+ },
174
+ },
175
+ create(context) {
176
+ const sourceCode = context.sourceCode || context.getSourceCode()
177
+ const file = normalizePath(context.filename || context.getFilename())
178
+ // locale files are wholly product copy; policing them would be policing the product
179
+ if (/\/(?:messages|locales|i18n)\//.test(file)) return {}
180
+
181
+ /*
182
+ * IN A TEST LANE, A STRING IS A FIXTURE AND A COMMENT IS STILL PROSE.
183
+ *
184
+ * The rule's own reason draws this line already: prose in a second language leaves half the
185
+ * reasoning unavailable to a reader, while "text the program MATCHES on or EMITS is data rather
186
+ * than prose". A spec that feeds an agent the sentence a real customer would type - "khach vua
187
+ * chuyen khoan" - is feeding it data, and translating it would test a system nobody uses.
188
+ *
189
+ * Measured before it was written: of 92 findings in one back end, 89 were fixture strings and 3
190
+ * were comments. Demanding `vn-ok` on all 92 would have put a marker on every line of every
191
+ * conversation fixture, which is noise that teaches a reader to stop seeing the marker.
192
+ *
193
+ * The exemption is for STRINGS ONLY. A Vietnamese comment in a spec is the same problem it is
194
+ * anywhere else - the next reader still cannot follow the reasoning - so it is still refused.
195
+ */
196
+ const fixtureLane = /\.spec\.ts$|-spec\.ts$|\/src\/tests\//.test(file)
197
+ const commentLines = fixtureLane
198
+ ? new Set(sourceCode.getAllComments().flatMap((comment) => {
199
+ const span = []
200
+ for (let line = comment.loc.start.line; line <= comment.loc.end.line; line += 1) span.push(line)
201
+ return span
202
+ }))
203
+ : null
204
+
205
+ return {
206
+ "Program:exit"(node) {
207
+ const lines = sourceCode.getLines()
208
+ for (let index = 0; index < lines.length; index += 1) {
209
+ const line = lines[index]
210
+ if (KEEP_MARKER.test(line)) continue
211
+ // in a fixture lane only prose is policed; the data the fixture feeds is the point of it
212
+ if (commentLines && !commentLines.has(index + 1)) continue
213
+ const offence = offenceIn(line)
214
+ if (offence === null) continue
215
+ context.report({
216
+ node,
217
+ loc: {
218
+ line: index + 1,
219
+ column: 0,
220
+ },
221
+ messageId: "nonAscii",
222
+ data: {
223
+ offence,
224
+ },
225
+ })
226
+ }
227
+ },
228
+ }
229
+ },
230
+ }
231
+
232
+ // -- law 6 -------------------------------------------------------------------------------------
233
+
234
+ /** The line-level test for law 6: `vn-ok` present, with a colon and at least one reason character after it. */
235
+ const REASON_AFTER_MARKER = /\bvn-ok\s*:\s*\S/
236
+
237
+ /** Every `vn-ok` marker carries a reason; a bare marker is not an exemption. */
238
+ export const requireVnOkReason = {
239
+ meta: {
240
+ type: "problem",
241
+ docs: {
242
+ description:
243
+ "COMMENT-5, law 6: a `vn-ok` marker carries a reason - a bare marker is not an exemption.",
244
+ },
245
+ schema: [],
246
+ messages: {
247
+ bareMarker:
248
+ "`vn-ok` on this line has no reason after it. The marker exists so the next sweep can read WHY the line stays instead of \"fixing\" it into a bug - a bare marker is only a way of switching that check off. Write `vn-ok: <reason>` and say what the marker is protecting.",
249
+ },
250
+ },
251
+ create(context) {
252
+ const sourceCode = context.sourceCode || context.getSourceCode()
253
+ return {
254
+ "Program:exit"(node) {
255
+ const lines = sourceCode.getLines()
256
+ for (let index = 0; index < lines.length; index += 1) {
257
+ const line = lines[index]
258
+ if (!KEEP_MARKER.test(line)) continue
259
+ if (REASON_AFTER_MARKER.test(line)) continue
260
+ context.report({
261
+ node,
262
+ loc: { line: index + 1, column: 0 },
263
+ messageId: "bareMarker",
264
+ })
265
+ }
266
+ },
267
+ }
268
+ },
269
+ }
270
+
271
+ // -- law 7 -------------------------------------------------------------------------------------
272
+
273
+ /** Splits a declared name into its lowercase words, so `readUser` compares against "read user". */
274
+ const wordsOf = (name) =>
275
+ (String(name || "")
276
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
277
+ .replace(/[_-]+/g, " ")
278
+ .toLowerCase()
279
+ .match(/[a-z0-9]+/g) || [])
280
+
281
+ /**
282
+ * Filler a doc contributes no information by using: articles and linking words, plus the two generic
283
+ * nouns the law's own anchor examples restate with ("the pending state", "the read user function").
284
+ * Nothing here strips a word that could carry real content, so a doc that explains anything beyond
285
+ * the name keeps enough leftover words to fail the equality check below.
286
+ */
287
+ const RESTATEMENT_FILLER = new Set(["a", "an", "the", "is", "of", "for", "to", "this", "that", "function", "state"])
288
+
289
+ /** The content words a doc comment contributes once filler is stripped out. */
290
+ const docContentWords = (comment) =>
291
+ (comment.value.match(/[A-Za-z]+/g) || [])
292
+ .map((word) => word.toLowerCase())
293
+ .filter((word) => !RESTATEMENT_FILLER.has(word))
294
+
295
+ /** Whether a doc's leftover content words are exactly the declared name's own words - nothing added. */
296
+ const isPureRestatement = (comment, declaredName) => {
297
+ const content = docContentWords(comment)
298
+ const identifier = wordsOf(declaredName)
299
+ if (content.length === 0 || identifier.length === 0) return false
300
+ const contentSet = new Set(content)
301
+ const identifierSet = new Set(identifier)
302
+ if (contentSet.size !== identifierSet.size) return false
303
+ for (const word of contentSet) if (!identifierSet.has(word)) return false
304
+ return true
305
+ }
306
+
307
+ /** The doc block immediately before a node, or null. */
308
+ const jsdocBefore = (sourceCode, node) =>
309
+ sourceCode.getCommentsBefore(node).find((comment) => comment.type === "Block" && comment.value.startsWith("*"))
310
+ || null
311
+
312
+ /** A doc block that only re-spells the declared name is COMMENT-3's violation wearing COMMENT-1's shape. */
313
+ export const noRestatedNameJsdoc = {
314
+ meta: {
315
+ type: "suggestion",
316
+ docs: {
317
+ description:
318
+ "COMMENT-3, law 7: a doc block that restates the declared name is a COMMENT-3 violation wearing a COMMENT-1 shape, even while the lint gate that checks a doc EXISTS stays green.",
319
+ },
320
+ schema: [],
321
+ messages: {
322
+ restated:
323
+ "This doc block only re-spells `{{name}}` in words. A name plus a signature already says what it TAKES; the doc has to say what it is FOR, or what choosing it causes, or it is a COMMENT-3 violation wearing a COMMENT-1 shape. Delete the restatement and write the reason instead.",
324
+ },
325
+ },
326
+ create(context) {
327
+ const sourceCode = context.sourceCode || context.getSourceCode()
328
+ const checkExport = (node) => {
329
+ const declaration = node.declaration
330
+ // a re-export has nothing here to attach a doc to
331
+ if (!declaration) return
332
+ if (declaration.type === "VariableDeclaration") {
333
+ // only a const bound to a function has a surface; a data constant is already described
334
+ const first = declaration.declarations[0]
335
+ const init = first && first.init
336
+ const isFunction = init
337
+ && (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression")
338
+ if (!isFunction) return
339
+ } else if (!DOCUMENTED_KINDS.has(declaration.type)) {
340
+ return
341
+ }
342
+ const doc = jsdocBefore(sourceCode, node)
343
+ if (!doc) return
344
+ const name = nameOf(declaration)
345
+ if (!isPureRestatement(doc, name)) return
346
+ context.report({ node: declaration.id || declaration, messageId: "restated", data: { name } })
347
+ }
348
+ return {
349
+ ExportNamedDeclaration: checkExport,
350
+ ExportDefaultDeclaration: checkExport,
351
+ TSEnumDeclaration(node) {
352
+ if (!node.parent || node.parent.type !== "ExportNamedDeclaration") return
353
+ for (const member of node.members || []) {
354
+ const doc = jsdocBefore(sourceCode, member)
355
+ if (!doc) continue
356
+ const name = member.id && (member.id.name || member.id.value)
357
+ if (!name || !isPureRestatement(doc, name)) continue
358
+ context.report({ node: member, messageId: "restated", data: { name } })
359
+ }
360
+ },
361
+ }
362
+ },
363
+ }
364
+
365
+ /** The rules this law contributes to the plugin. */
366
+ export const rules = {
367
+ "require-export-jsdoc": requireExportJsdoc,
368
+ "require-enum-member-jsdoc": requireEnumMemberJsdoc,
369
+ "no-non-ascii-source": noNonAsciiSource,
370
+ "require-vn-ok-reason": requireVnOkReason,
371
+ "no-restated-name-jsdoc": noRestatedNameJsdoc,
372
+ }
373
+
374
+ /**
375
+ * The level this law asks for, as the plugin's own opinion.
376
+ *
377
+ * `no-non-ascii-source` replaces three separate rules in the reference plugin - one for Vietnamese,
378
+ * one for emoji, one for decorative symbols. They were three character classes answering one
379
+ * question, and a reader who hit the emoji rule learned nothing about the other two. One rule with
380
+ * one reason is easier to obey, and impossible to satisfy by switching alphabets.
381
+ */
382
+ export const recommended = {
383
+ "starci-be/require-export-jsdoc": "error",
384
+ "starci-be/require-enum-member-jsdoc": "error",
385
+ "starci-be/no-non-ascii-source": "error",
386
+ "starci-be/require-vn-ok-reason": "error",
387
+ "starci-be/no-restated-name-jsdoc": "error",
388
+ }