@starci/eslint-canon-be 1.8.1 → 2.0.1
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/CHANGELOG.md +99 -9
- package/README.md +41 -52
- package/async-discipline.mjs +2 -2
- package/authorization.mjs +38 -88
- package/builders.mjs +305 -0
- package/comments.mjs +19 -34
- package/config-owner.mjs +127 -125
- package/connections.mjs +254 -0
- package/cqrs.mjs +426 -135
- package/data-access.mjs +320 -391
- package/default-deny.mjs +14 -10
- package/e2e-flow.mjs +208 -180
- package/error-handling.mjs +254 -128
- package/idempotency.mjs +98 -67
- package/index.mjs +32 -9
- package/infra-owner.mjs +96 -0
- package/injection.mjs +602 -0
- package/input-bounds.mjs +166 -66
- package/lib/config.mjs +70 -33
- package/lib/db-writes.mjs +58 -0
- package/lib/declared.mjs +154 -0
- package/lib/hfs.mjs +61 -0
- package/lib/import-source.mjs +104 -0
- package/lib/persistence.mjs +160 -0
- package/lib/ports.mjs +138 -0
- package/lib/transport-slots.mjs +14 -0
- package/lib/types.mjs +133 -0
- package/lib/unit-spec.mjs +33 -0
- package/log-safety.mjs +94 -26
- package/module-layering.mjs +180 -297
- package/module-shape.mjs +296 -59
- package/naming.mjs +44 -12
- package/observability.mjs +57 -91
- package/operations.mjs +201 -0
- package/package.json +10 -7
- package/query-safety.mjs +189 -223
- package/resilience.mjs +126 -31
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/engine/runtime-root.mjs +32 -0
- package/runtime/engine/yaml.mjs +161 -0
- package/runtime/knowledge/hfs/slots.yaml +1111 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/glob.mjs +23 -0
- package/runtime/scripts/lib/hfs-allows.mjs +57 -0
- package/runtime/scripts/lib/hfs-slots.mjs +729 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/path-key.mjs +17 -0
- package/runtime/scripts/lib/recorded-lines.mjs +27 -0
- package/schema-authority.mjs +270 -155
- package/size-budget.mjs +19 -61
- package/spec-no-skip.mjs +84 -0
- package/spec-quality.mjs +76 -35
- package/suppression.mjs +34 -16
- package/temporal.mjs +101 -36
- package/test-world.mjs +204 -0
- package/testing.mjs +120 -251
- package/transport.mjs +433 -142
- package/type-safety.mjs +50 -278
- package/unit-spec.mjs +452 -0
- package/user-copy.mjs +56 -37
- 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
|
|
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 {
|
|
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
|
|
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 (
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
if (
|
|
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({
|