@starci/eslint-canon-be 1.8.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +95 -5
  2. package/README.md +41 -52
  3. package/async-discipline.mjs +2 -2
  4. package/authorization.mjs +38 -88
  5. package/builders.mjs +305 -0
  6. package/comments.mjs +19 -34
  7. package/config-owner.mjs +127 -125
  8. package/connections.mjs +254 -0
  9. package/cqrs.mjs +426 -135
  10. package/data-access.mjs +320 -391
  11. package/default-deny.mjs +14 -10
  12. package/e2e-flow.mjs +208 -180
  13. package/error-handling.mjs +254 -128
  14. package/idempotency.mjs +98 -67
  15. package/index.mjs +32 -9
  16. package/infra-owner.mjs +96 -0
  17. package/injection.mjs +602 -0
  18. package/input-bounds.mjs +166 -66
  19. package/lib/config.mjs +70 -33
  20. package/lib/db-writes.mjs +58 -0
  21. package/lib/declared.mjs +154 -0
  22. package/lib/hfs.mjs +61 -0
  23. package/lib/import-source.mjs +104 -0
  24. package/lib/persistence.mjs +160 -0
  25. package/lib/ports.mjs +138 -0
  26. package/lib/transport-slots.mjs +14 -0
  27. package/lib/types.mjs +133 -0
  28. package/lib/unit-spec.mjs +33 -0
  29. package/log-safety.mjs +94 -26
  30. package/module-layering.mjs +180 -297
  31. package/module-shape.mjs +296 -59
  32. package/naming.mjs +44 -12
  33. package/observability.mjs +57 -91
  34. package/operations.mjs +201 -0
  35. package/package.json +10 -7
  36. package/query-safety.mjs +189 -223
  37. package/resilience.mjs +126 -31
  38. package/runtime/engine/plain-object.mjs +5 -0
  39. package/runtime/engine/runtime-root.mjs +32 -0
  40. package/runtime/engine/yaml.mjs +161 -0
  41. package/runtime/knowledge/hfs/slots.yaml +1111 -0
  42. package/runtime/scripts/lib/git.mjs +53 -0
  43. package/runtime/scripts/lib/glob.mjs +23 -0
  44. package/runtime/scripts/lib/hfs-allows.mjs +57 -0
  45. package/runtime/scripts/lib/hfs-slots.mjs +729 -0
  46. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  47. package/runtime/scripts/lib/language.mjs +107 -0
  48. package/runtime/scripts/lib/path-key.mjs +17 -0
  49. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  50. package/schema-authority.mjs +273 -91
  51. package/size-budget.mjs +19 -61
  52. package/spec-no-skip.mjs +84 -0
  53. package/spec-quality.mjs +76 -35
  54. package/suppression.mjs +34 -16
  55. package/temporal.mjs +101 -36
  56. package/test-world.mjs +204 -0
  57. package/testing.mjs +99 -251
  58. package/transport.mjs +433 -142
  59. package/type-safety.mjs +50 -278
  60. package/unit-spec.mjs +452 -0
  61. package/user-copy.mjs +56 -37
  62. package/lib/slots.mjs +0 -83
package/builders.mjs ADDED
@@ -0,0 +1,305 @@
1
+ /**
2
+ * The rules that hold the test data builder standard (knowledge BE-TEST-16, catalog R98 to R101).
3
+ *
4
+ * Builders live only in `src/tests/fixtures/builders/<area>.builder.ts` (slot `be.tests.fixtures.builders`). The slot manifest refuses the
5
+ * file names (`*.repository.ts`, `*.fixture.ts`, `*.factory.ts`, a `*.builder.ts` elsewhere: BE_SOURCE_FORM); these rules judge what
6
+ * the files DO, so a builder cannot hide under another name:
7
+ *
8
+ * - `persisting-builder-in-builders-slot` (R98): a module of the test tree that exports something creating persisted rows through an
9
+ * EntityManager, DataSource or QueryRunner IS a builder, wherever it sits and whatever it is called, so it must be in the builders slot.
10
+ * - `spec-no-raw-insert` (R99): a spec never runs a raw INSERT/UPDATE/DELETE or writes (`save`, `insert`, ...) through a database receiver.
11
+ * - `spec-no-repeated-row-literal` (R99): a spec never builds the same persistence entity as an object literal twice; the row comes from a builder.
12
+ * - `builder-arranges-only` (R101): a builder never asserts and has deterministic defaults.
13
+ * - `tests-keep-constraints` (R100): nothing in the test tree switches database constraints off.
14
+ *
15
+ * A receiver is recognised by its TYPE, an entity by its declaration (a class decorated with typeorm's `@Entity`), a builder file by
16
+ * its slot; nothing is matched by a name.
17
+ */
18
+ import { basename } from "node:path"
19
+ import ts from "typescript"
20
+ import { INSERTING_SQL, isDatabaseReceiver, methodOf, queryText, WRITING_SQL } from "./lib/db-writes.mjs"
21
+ import { hfsOf } from "./lib/hfs.mjs"
22
+ import { moduleOf, typed } from "./lib/types.mjs"
23
+
24
+ /** The file name of a linted path, in forward-slash form. */
25
+ const baseOf = (filename) => basename(String(filename || "").replace(/\\/g, "/"))
26
+
27
+ // -- spec-no-raw-insert ----------------------------------------------------------------------------
28
+
29
+ const WRITE_METHODS = new Set(["save", "insert", "upsert", "update", "delete", "remove", "softDelete", "softRemove", "recover", "restore"])
30
+
31
+ /** A unit service spec, an integration spec or an e2e spec. */
32
+ const isSpecName = (name) => /\.(?:spec|integration-spec|e2e-spec)\.[cm]?ts$/.test(name)
33
+
34
+ /** A spec arranges rows through a builder, never by writing them itself. */
35
+ export const specNoRawInsert = {
36
+ meta: {
37
+ type: "problem",
38
+ docs: { description: "A spec arranges rows through a builder, never through a raw INSERT or a direct write on an EntityManager." },
39
+ schema: [],
40
+ messages: {
41
+ write: "This spec writes rows itself with `{{method}}` on a database receiver. Arrange the row through the area's builder (`src/tests/fixtures/builders/<area>.builder.ts`), which creates the parent chain with constraints on; a spec reads state back, it does not write it.",
42
+ sql: "This spec runs a raw INSERT/UPDATE/DELETE. Arrange the row through the area's builder (`src/tests/fixtures/builders/<area>.builder.ts`); a spec reads state back with `find*`, `count` or a SELECT, it does not write it.",
43
+ },
44
+ },
45
+ create(context) {
46
+ if (!isSpecName(baseOf(context.filename || context.getFilename()))) return {}
47
+ const sourceCode = context.sourceCode || context.getSourceCode()
48
+ return {
49
+ CallExpression(node) {
50
+ const callee = node.callee
51
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return
52
+ const method = callee.property.name
53
+ if (!WRITE_METHODS.has(method) && method !== "query") return
54
+ if (!isDatabaseReceiver(context, callee.object)) return
55
+ if (method === "query") {
56
+ const text = queryText(node.arguments[0], sourceCode.getScope(node))
57
+ if (text !== null && WRITING_SQL.test(text)) context.report({ node, messageId: "sql" })
58
+ return
59
+ }
60
+ context.report({ node, messageId: "write", data: { method } })
61
+ },
62
+ }
63
+ },
64
+ }
65
+
66
+ // -- spec-no-repeated-row-literal ------------------------------------------------------------------
67
+
68
+ /** True when a class declaration carries typeorm's `@Entity(...)`, resolved by where the decorator is declared. */
69
+ const isEntityClass = (checker, declaration) => {
70
+ if (!ts.isClassDeclaration(declaration)) return false
71
+ return (ts.getDecorators(declaration) ?? []).some((decorator) => {
72
+ const callee = ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression
73
+ const symbol = checker.getSymbolAtLocation(callee)
74
+ if (!symbol) return false
75
+ const target = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol
76
+ return (target.getDeclarations?.() ?? []).some((found) => moduleOf(found, String(found.getSourceFile().fileName).replace(/\\/g, "/")) === "typeorm")
77
+ })
78
+ }
79
+
80
+ /** The entity class declarations a type stands for: itself, a union member, or the argument of a `Partial<Entity>` style alias. */
81
+ const entityClassesOf = (checker, type) => {
82
+ if (!type) return []
83
+ const parts = type.isUnion?.() ? type.types : [type]
84
+ const candidates = parts.flatMap((part) => [part, ...(part.aliasTypeArguments ?? [])])
85
+ return candidates.flatMap((candidate) => (candidate.getSymbol?.()?.getDeclarations?.() ?? []).filter((declaration) => isEntityClass(checker, declaration)))
86
+ }
87
+
88
+ /** A spec builds each persistence entity as an object literal at most once; further rows come from a builder. */
89
+ export const specNoRepeatedRowLiteral = {
90
+ meta: {
91
+ type: "problem",
92
+ docs: { description: "A spec does not hand-build the same persistence entity as an object literal more than once." },
93
+ schema: [],
94
+ messages: {
95
+ repeated: "This spec builds a `{{entity}}` row as an object literal again (first at line {{line}}). Repeated hand-built rows belong in the area's builder (`src/tests/fixtures/builders/<area>.builder.ts`): `orderRow(overrides)` for a unit spec, `orderBuilder(db).pending().build(overrides)` for integration and e2e.",
96
+ },
97
+ },
98
+ create(context) {
99
+ if (!isSpecName(baseOf(context.filename || context.getFilename()))) return {}
100
+ const { checker, toTs } = typed(context)
101
+ const firstLine = new Map()
102
+ return {
103
+ ObjectExpression(node) {
104
+ if (node.properties.length === 0 || node.properties.some((property) => property.type === "SpreadElement")) return
105
+ const tsNode = toTs(node)
106
+ const [entity] = entityClassesOf(checker, tsNode && checker.getContextualType(tsNode))
107
+ if (!entity) return
108
+ const seen = firstLine.get(entity)
109
+ if (seen === undefined) firstLine.set(entity, node.loc.start.line)
110
+ else context.report({ node, messageId: "repeated", data: { entity: entity.name?.text ?? "entity", line: String(seen) } })
111
+ },
112
+ }
113
+ },
114
+ }
115
+
116
+ // -- persisting-builder-in-builders-slot -----------------------------------------------------------
117
+
118
+ const BUILDERS = "be.tests.fixtures.builders"
119
+ /** The spec slots of the test tree; a spec is judged by the spec rules, not as a builder. */
120
+ const SPEC_SLOTS = new Set(["be.tests.integration", "be.tests.e2e", "be.tests.contract"])
121
+ const CREATING_METHODS = new Set(["save", "insert", "upsert"])
122
+
123
+ /** A file of the test tree that is not a spec and is not the builders slot. */
124
+ const isTestSupport = (slot) => typeof slot === "string" && slot.startsWith("be.tests.") && slot !== BUILDERS && !SPEC_SLOTS.has(slot)
125
+
126
+ /** The top-level statement that holds a node. */
127
+ const topStatementOf = (node) => {
128
+ let current = node
129
+ while (current.parent && current.parent.type !== "Program") current = current.parent
130
+ return current
131
+ }
132
+
133
+ /** The names a top-level statement declares. */
134
+ const declaredNames = (statement) => {
135
+ const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement
136
+ if (!declaration) return []
137
+ if (declaration.type === "VariableDeclaration") return declaration.declarations.flatMap((entry) => (entry.id.type === "Identifier" ? [entry.id.name] : []))
138
+ return declaration.id ? [declaration.id.name] : []
139
+ }
140
+
141
+ /** A module of the test tree that exports something creating persisted rows is a builder and lives in the builders slot. */
142
+ export const persistingBuilderInBuildersSlot = {
143
+ meta: {
144
+ type: "problem",
145
+ docs: { description: "A module that exports functions creating persisted rows through an EntityManager is a test data builder and lives in the builders slot." },
146
+ schema: [],
147
+ messages: {
148
+ misplaced: "`{{name}}` creates persisted rows through a database receiver, so it is a test data builder, whatever it is called. Move it to `src/tests/fixtures/builders/<area>.builder.ts` (its SQL text to `<area>.sql.ts` beside it); the test tree keeps one home for arranging data.",
149
+ },
150
+ },
151
+ create(context) {
152
+ if (!isTestSupport(hfsOf(context).slotOf(context.filename || context.getFilename()))) return {}
153
+ const sourceCode = context.sourceCode || context.getSourceCode()
154
+ const writes = new Set()
155
+ const references = new Map()
156
+ const isCreation = (node) => {
157
+ const method = methodOf(node)
158
+ if (!method || !isDatabaseReceiver(context, node.callee.object)) return false
159
+ if (CREATING_METHODS.has(method)) return true
160
+ return method === "query" && INSERTING_SQL.test(queryText(node.arguments[0], sourceCode.getScope(node)) ?? "")
161
+ }
162
+ return {
163
+ CallExpression(node) {
164
+ if (isCreation(node)) writes.add(topStatementOf(node))
165
+ },
166
+ Identifier(node) {
167
+ const statement = topStatementOf(node)
168
+ if (!references.has(statement)) references.set(statement, new Set())
169
+ references.get(statement).add(node.name)
170
+ },
171
+ "Program:exit"(program) {
172
+ const exported = new Set(program.body.flatMap((statement) => (statement.type === "ExportNamedDeclaration" && !statement.declaration && !statement.source ? statement.specifiers.map((specifier) => specifier.local.name) : [])))
173
+ const writerNames = new Set(program.body.filter((statement) => writes.has(statement)).flatMap(declaredNames))
174
+ for (const statement of program.body) {
175
+ const isExported = statement.type === "ExportNamedDeclaration" ? Boolean(statement.declaration) : declaredNames(statement).some((name) => exported.has(name))
176
+ if (!isExported) continue
177
+ const uses = references.get(statement) ?? new Set()
178
+ if (!writes.has(statement) && ![...uses].some((name) => writerNames.has(name))) continue
179
+ context.report({ node: statement, messageId: "misplaced", data: { name: declaredNames(statement)[0] ?? "This export" } })
180
+ }
181
+ },
182
+ }
183
+ },
184
+ }
185
+
186
+ // -- builder-arranges-only -------------------------------------------------------------------------
187
+
188
+ const JEST_GLOBALS = new Set(["expect", "jest", "describe", "it", "test", "beforeAll", "beforeEach", "afterAll", "afterEach", "fail"])
189
+ const RANDOM_FUNCTIONS = new Set(["randomUUID", "randomBytes", "randomInt", "randomFillSync", "randomFill", "getRandomValues"])
190
+
191
+ /** A builder arranges data only: no assertion, deterministic defaults. */
192
+ export const builderArrangesOnly = {
193
+ meta: {
194
+ type: "problem",
195
+ docs: { description: "A test data builder arranges data only: no assertion, deterministic defaults." },
196
+ schema: [],
197
+ messages: {
198
+ assertion: "A builder arranges data and never asserts: `{{name}}` is a test-framework global. Return the object or row and let the spec assert.",
199
+ random: "`{{name}}` makes a builder default non-deterministic. Take ids from constants or `fakeIds()` and use a fixed date, so a failing spec reproduces.",
200
+ },
201
+ },
202
+ create(context) {
203
+ if (hfsOf(context).slotOf(context.filename || context.getFilename()) !== BUILDERS) return {}
204
+ const sourceCode = context.sourceCode || context.getSourceCode()
205
+ /** True when the identifier is not declared in the file (a framework global, not a local named `test`). */
206
+ const isGlobal = (node) => {
207
+ for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
208
+ const variable = scope.set.get(node.name)
209
+ if (variable) return variable.defs.length === 0
210
+ }
211
+ return true
212
+ }
213
+ return {
214
+ ImportDeclaration(node) {
215
+ if (node.source.value === "@jest/globals") context.report({ node, messageId: "assertion", data: { name: "@jest/globals" } })
216
+ },
217
+ Identifier(node) {
218
+ const parent = node.parent
219
+ const used = (parent.type === "CallExpression" && parent.callee === node) || (parent.type === "MemberExpression" && parent.object === node)
220
+ if (used && JEST_GLOBALS.has(node.name) && isGlobal(node)) context.report({ node, messageId: "assertion", data: { name: node.name } })
221
+ },
222
+ CallExpression(node) {
223
+ const callee = node.callee
224
+ if (callee.type === "Identifier" && RANDOM_FUNCTIONS.has(callee.name)) {
225
+ context.report({ node, messageId: "random", data: { name: `${callee.name}()` } })
226
+ return
227
+ }
228
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier" || callee.object.type !== "Identifier") return
229
+ const owner = callee.object.name
230
+ const method = callee.property.name
231
+ const clock = owner === "Date" && method === "now"
232
+ const dice = owner === "Math" && method === "random"
233
+ const crypto = RANDOM_FUNCTIONS.has(method) || (/^(?:crypto|webcrypto)$/.test(owner) && method.startsWith("random"))
234
+ if (clock || dice || crypto) context.report({ node, messageId: "random", data: { name: `${owner}.${method}()` } })
235
+ },
236
+ NewExpression(node) {
237
+ if (node.callee.type === "Identifier" && node.callee.name === "Date" && node.arguments.length === 0) context.report({ node, messageId: "random", data: { name: "new Date()" } })
238
+ },
239
+ }
240
+ },
241
+ }
242
+
243
+ // -- tests-keep-constraints ------------------------------------------------------------------------
244
+
245
+ const CONSTRAINT_SQL = [
246
+ /session_replication_role/i,
247
+ /\bDISABLE\s+TRIGGER\b/i,
248
+ /\bDEFERRABLE\b/i,
249
+ /\bINITIALLY\s+DEFERRED\b/i,
250
+ /\bSET\s+CONSTRAINTS\b[\s\S]*\bDEFERRED\b/i,
251
+ /\bALTER\s+TABLE\b[\s\S]*\bDISABLE\b/i,
252
+ /\bDROP\s+CONSTRAINT\b/i,
253
+ ]
254
+ const CONSTRAINT_METHODS = new Set(["dropForeignKey", "dropForeignKeys", "dropCheckConstraint", "dropCheckConstraints", "dropUniqueConstraint", "dropUniqueConstraints"])
255
+
256
+ /** The test tree keeps every database constraint on. */
257
+ export const testsKeepConstraints = {
258
+ meta: {
259
+ type: "problem",
260
+ docs: { description: "Nothing in the test tree disables or drops a database constraint." },
261
+ schema: [],
262
+ messages: {
263
+ sql: "This test-tree SQL switches database constraints off (`{{what}}`). Tests run with constraints ON: create the parent chain through the builder instead of skipping the foreign key.",
264
+ method: "`{{method}}` drops a database constraint in the test tree. Tests run with constraints ON: create the parent chain through the builder instead of dropping the foreign key.",
265
+ },
266
+ },
267
+ create(context) {
268
+ const slot = hfsOf(context).slotOf(context.filename || context.getFilename())
269
+ if (typeof slot !== "string" || !slot.startsWith("be.tests.")) return {}
270
+ const sourceCode = context.sourceCode || context.getSourceCode()
271
+ const check = (node, text) => {
272
+ const found = CONSTRAINT_SQL.find((pattern) => pattern.test(text ?? ""))
273
+ if (found) context.report({ node, messageId: "sql", data: { what: text.match(found)[0] } })
274
+ }
275
+ return {
276
+ CallExpression(node) {
277
+ const method = methodOf(node)
278
+ if (!method || !isDatabaseReceiver(context, node.callee.object)) return
279
+ if (method === "query") check(node, queryText(node.arguments[0], sourceCode.getScope(node)))
280
+ else if (CONSTRAINT_METHODS.has(method)) context.report({ node, messageId: "method", data: { method } })
281
+ },
282
+ TaggedTemplateExpression(node) {
283
+ check(node, queryText(node, null))
284
+ },
285
+ }
286
+ },
287
+ }
288
+
289
+ /** The rules this law contributes to the plugin. */
290
+ export const rules = {
291
+ "persisting-builder-in-builders-slot": persistingBuilderInBuildersSlot,
292
+ "spec-no-raw-insert": specNoRawInsert,
293
+ "spec-no-repeated-row-literal": specNoRepeatedRowLiteral,
294
+ "builder-arranges-only": builderArrangesOnly,
295
+ "tests-keep-constraints": testsKeepConstraints,
296
+ }
297
+
298
+ /** Every rule of this law ships at `error`. */
299
+ export const recommended = {
300
+ "starci-be/persisting-builder-in-builders-slot": "error",
301
+ "starci-be/spec-no-raw-insert": "error",
302
+ "starci-be/spec-no-repeated-row-literal": "error",
303
+ "starci-be/builder-arranges-only": "error",
304
+ "starci-be/tests-keep-constraints": "error",
305
+ }
package/comments.mjs CHANGED
@@ -9,7 +9,8 @@
9
9
  * - `require-enum-member-jsdoc` can check that a doc EXISTS and never that it states a
10
10
  * consequence. That half is read by a person, and the rule says so rather than pretending.
11
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).
12
+ * lives in a message catalog (slot be.domain.messages, of any module tier, or be.feature.messages), the only place Vietnamese may appear. Specs
13
+ * and fixtures get no exemption.
13
14
  * - `no-restated-name-jsdoc` (law 7) holds the decidable slice of law 3 - a doc block whose only
14
15
  * content is the declared name re-spelled in words teaches nothing beyond the import line, so it
15
16
  * is COMMENT-3's violation wearing COMMENT-1's shape. It fires only on an exact match between the
@@ -18,7 +19,11 @@
18
19
  * decidable and stays a human read.
19
20
  */
20
21
 
21
- import { normalizePath } from "./lib/path.mjs"
22
+ import { hfsOf } from "./lib/hfs.mjs"
23
+ import { hasSecondLanguage } from "./runtime/scripts/lib/language.mjs"
24
+
25
+ /** The slots of the per-owner message catalogs: the only source files that may hold Vietnamese. */
26
+ const CATALOG_SLOTS = new Set(["be.domain.messages", "be.feature.messages"])
22
27
 
23
28
  /**
24
29
  * The character classes this refuses, and why it is NOT simply "ASCII only".
@@ -40,7 +45,7 @@ import { normalizePath } from "./lib/path.mjs"
40
45
  *
41
46
  * Typographic punctuation is none of those and stays.
42
47
  */
43
- const VIETNAMESE_LETTER = /[À-ÃÈ-ÊÌÍÒ-ÕÙÚÝà-ãè-êìíò-õùúýĂăĐđĨĩŨũƠơƯưẠ-ỿ]/
48
+ const VIETNAMESE_LETTER_NOTE = "detected by scripts/lib/language.mjs (`hasSecondLanguage`), structural on characters and folded to NFC so a decomposed spelling is caught too"
44
49
 
45
50
  /** Emoji and pictographs. */
46
51
  const EMOJI = /[\u{1F300}-\u{1FAFF}\u{1F000}-\u{1F0FF}\u{2600}-\u{27BF}\u{FE0F}\u{1F1E6}-\u{1F1FF}]/u
@@ -48,6 +53,12 @@ const EMOJI = /[\u{1F300}-\u{1FAFF}\u{1F000}-\u{1F0FF}\u{2600}-\u{27BF}\u{FE0F}\
48
53
  /** Ornamental marks that stand in for a word. */
49
54
  const ORNAMENT = /[✅❌✔✖✗✘⭐⬆⬇➡⬅]/
50
55
 
56
+ /**
57
+ * The fixtures slot that may carry localized text: a test data file that reproduces a real localized string. Placement is
58
+ * the whole marker (there is no pragma), and specs never live there, so a test title is never exempt.
59
+ */
60
+ const FIXTURE_SLOTS = new Set(["be.tests.fixtures.i18n"])
61
+
51
62
  /** The Vietnamese language's own name, which is a label rather than prose. */
52
63
  const ENDONYM = /Tiếng Việt/
53
64
 
@@ -62,8 +73,8 @@ const DOCUMENTED_KINDS = new Set([
62
73
 
63
74
  /** What a line offends with, or null. */
64
75
  const offenceIn = (line) => {
65
- const withoutEndonym = line.replace(ENDONYM, "")
66
- if (VIETNAMESE_LETTER.test(withoutEndonym)) return "a Vietnamese letter"
76
+ const withoutEndonym = line.normalize("NFC").replace(ENDONYM, "")
77
+ if (hasSecondLanguage(withoutEndonym)) return "a Vietnamese letter"
67
78
  if (EMOJI.test(withoutEndonym)) return "an emoji"
68
79
  if (ORNAMENT.test(withoutEndonym)) return "an ornamental symbol"
69
80
  return null
@@ -168,41 +179,15 @@ export const noNonAsciiSource = {
168
179
  },
169
180
  create(context) {
170
181
  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
182
+ // a message catalog is product copy in two languages; every other file is prose for the next reader
183
+ const slot = hfsOf(context).slotOf(context.filename)
184
+ if (CATALOG_SLOTS.has(slot) || FIXTURE_SLOTS.has(slot)) return {}
198
185
 
199
186
  return {
200
187
  "Program:exit"(node) {
201
188
  const lines = sourceCode.getLines()
202
189
  for (let index = 0; index < lines.length; index += 1) {
203
190
  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
191
  const offence = offenceIn(line)
207
192
  if (offence === null) continue
208
193
  context.report({