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