@starci/eslint-canon-be 1.2.0 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-be",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "The StarCi back-end lint canon: every rule its laws publish, gathered as one flat-config plugin.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/testing.mjs CHANGED
@@ -99,7 +99,7 @@ const matcherOf = (expectCall) => {
99
99
  // -- TESTING-6 -------------------------------------------------------------------------------------
100
100
 
101
101
  /** A spec whose every assertion is about a call restates the source instead of testing it. */
102
- export const noCallOnlySpec = {
102
+ export const noCallOnlySpec = {
103
103
  meta: {
104
104
  type: "problem",
105
105
  docs: { description: "A unit spec asserts a result or a state change, not only that a call happened." },
@@ -131,7 +131,36 @@ export const noCallOnlySpec = {
131
131
  },
132
132
  }
133
133
  },
134
- }
134
+ }
135
+
136
+ // -- TESTING-7 -------------------------------------------------------------------------------------
137
+
138
+ const UNIT_TEST_BUCKET = /\/(?:src\/tests|tests?\/unit)(?:\/|$)/
139
+
140
+ /** Backend units are colocated `.spec.ts` files; only backend E2E owns a separate tree. */
141
+ export const unitTestColocated = {
142
+ meta: {
143
+ type: "problem",
144
+ docs: { description: "Backend unit tests are colocated `.spec.ts` files; generic `.test.ts` and unit buckets are forbidden." },
145
+ schema: [],
146
+ messages: {
147
+ suffix: "`{{name}}` uses `.test.ts`. A backend unit is a colocated `.spec.ts` file beside its production owner.",
148
+ bucket: "`{{path}}` files a unit in a separate test bucket. Move it beside its production owner; only backend E2E owns a separate test tree.",
149
+ },
150
+ },
151
+ create(context) {
152
+ const file = normalizePath(context.filename || context.getFilename())
153
+ const genericUnit = /\.test\.ts$/.test(file)
154
+ const bucketedUnit = isUnitSpec(file) && UNIT_TEST_BUCKET.test(file)
155
+ if (!genericUnit && !bucketedUnit) return {}
156
+ return {
157
+ Program(node) {
158
+ if (genericUnit) context.report({ node, messageId: "suffix", data: { name: file.slice(file.lastIndexOf("/") + 1) } })
159
+ if (bucketedUnit) context.report({ node, messageId: "bucket", data: { path: file } })
160
+ },
161
+ }
162
+ },
163
+ }
135
164
 
136
165
  // -- TESTING-2 -------------------------------------------------------------------------------------
137
166
 
@@ -410,8 +439,9 @@ export const noMarkerModelStub = {
410
439
  }
411
440
 
412
441
  /** The rules this law contributes to the plugin. */
413
- export const rules = {
414
- "no-call-only-spec": noCallOnlySpec,
442
+ export const rules = {
443
+ "no-call-only-spec": noCallOnlySpec,
444
+ "unit-test-colocated": unitTestColocated,
415
445
  "e2e-asserts-persisted-state": e2eAssertsPersistedState,
416
446
  "no-model-call-in-e2e": noModelCallInE2e,
417
447
  "harness-calls-provider-directly": harnessCallsProviderDirectly,
@@ -444,8 +474,9 @@ export const rules = {
444
474
  * `Literal`, so the rule does not even see it as a candidate. It ships at `error` as a
445
475
  * regression guard on a shape that is already correct.
446
476
  */
447
- export const recommended = {
448
- "starci-be/no-call-only-spec": "error", // no=0 of 182 - burned down from 1
477
+ export const recommended = {
478
+ "starci-be/no-call-only-spec": "error", // no=0 of 182 - burned down from 1
479
+ "starci-be/unit-test-colocated": "error",
449
480
  "starci-be/e2e-asserts-persisted-state": "error", // no=0 of 47 - burned down from 1
450
481
  "starci-be/no-model-call-in-e2e": "error", // no=0 of 47
451
482
  "starci-be/harness-calls-provider-directly": "error",
package/type-safety.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The rules that hold `type-safety.md`.
3
3
  *
4
- * Five house rules plus two standard ones, and the split is worth knowing. `no-explicit-any` and
4
+ * Six house rules plus two standard ones, and the split is worth knowing. `no-explicit-any` and
5
5
  * the array-type spelling already exist in the TypeScript plugin and are named in `recommended`
6
6
  * rather than reimplemented -- a second implementation of a rule everybody has is a maintenance
7
7
  * cost with no gain. Rule 1 (`any` in a declaration, cast target or generic argument) is exactly
@@ -9,11 +9,14 @@
9
9
  * this canon exists to prevent, so rule 1 is deliberately not reimplemented here.
10
10
  *
11
11
  * What is house-written is what the standard set does not cover: the double cast (TYPE-2, rule 3),
12
- * the inline parameter type (TYPE-3, rule 4), the const enum (TYPE-4, rule 5), the unguarded cast
13
- * off an `unknown` value (TYPE-1's second half, rule 2), and the per-line suppression standing in
14
- * for a lane (TYPE-6, rule 7). Each is a way of switching the compiler off -- or of leaving it
15
- * turned further down than the law asks -- that looks locally reasonable, which is exactly the
16
- * class a rule is for.
12
+ * the inline parameter type (TYPE-3, rule 4), the inline object type in every other position --
13
+ * property, return, variable -- that rule 4 names once it is read without the word "destructured"
14
+ * (TYPE-3, rule 4, extended; see the export below for its `recommended` level and the measurement
15
+ * that set it), the const enum
16
+ * (TYPE-4, rule 5), the unguarded cast off an `unknown` value (TYPE-1's second half, rule 2), and
17
+ * the per-line suppression standing in for a lane (TYPE-6, rule 7). Each is a way of switching the
18
+ * compiler off -- or of leaving it turned further down than the law asks -- that looks locally
19
+ * reasonable, which is exactly the class a rule is for.
17
20
  *
18
21
  * TYPE-5 (rule 6, the boolean-flag product) is NOT among them, and that absence was tested rather
19
22
  * than assumed. See the comment where TYPE-5 would sit, below, for what was tried and why it was
@@ -253,6 +256,115 @@ export const noInlineParamType = {
253
256
  },
254
257
  }
255
258
 
259
+ // -- TYPE-3, extended (law 4) ------------------------------------------------------------------------
260
+
261
+ /**
262
+ * `no-inline-param-type` holds only the destructured-parameter half of law 4: "a destructured
263
+ * parameter's type has a name and a home a second caller can import from." The same sentence, read
264
+ * without the word "destructured", covers every inline object type -- a positional parameter, a
265
+ * property signature, a function return, a variable -- because the failure is identical in all four:
266
+ * the shape cannot be referenced, imported or extended, so a second site retypes it and the two
267
+ * copies drift apart in silence.
268
+ *
269
+ * Three things are deliberately left alone, and each is a different reason:
270
+ *
271
+ * - An EMPTY `{}` means "no properties", not "an unnamed shape". That is a different smell (the
272
+ * fix is a stricter type, not a name) and this rule does not touch it.
273
+ * - A type literal inside a `declare module` / module augmentation describes a vendor's own shape.
274
+ * It cannot be pulled into a named type this repository owns without the two drifting the moment
275
+ * the vendor changes theirs, so the ambient lane is out of scope the same way the test lanes are.
276
+ * - A type literal that is already the right-hand side of a named type alias (`type Foo = { ... }`)
277
+ * IS the fix this rule asks for. Flagging it would make the rule unsatisfiable -- there would be no
278
+ * way to hold an object type by name that this rule accepted. A type literal declared elsewhere is
279
+ * still checked in the ordinary way; only the exact node a type alias names is exempt.
280
+ */
281
+
282
+ /** A type literal with no members means "no properties" -- a different smell with a different fix. */
283
+ const isEmptyTypeLiteral = (typeLiteral) => !typeLiteral.members || typeLiteral.members.length === 0
284
+
285
+ /** A vendor's own declaration lives in a `declare module` / module augmentation and cannot be named separately. */
286
+ const isInsideAmbientModule = (node) => {
287
+ let current = node.parent
288
+ while (current) {
289
+ if (current.type === "TSModuleDeclaration" && current.declare) return true
290
+ current = current.parent
291
+ }
292
+ return false
293
+ }
294
+
295
+ /** The non-empty inline object type at `typeAnnotation.typeAnnotation`, or null if this is out of scope. */
296
+ const inlineObjectTypeOf = (typeAnnotation, scopeNode) => {
297
+ if (!typeAnnotation) return null
298
+ const literal = typeAnnotation.typeAnnotation
299
+ if (!literal || literal.type !== "TSTypeLiteral") return null
300
+ if (isEmptyTypeLiteral(literal)) return null
301
+ if (isInsideAmbientModule(scopeNode)) return null
302
+ return literal
303
+ }
304
+
305
+ export const noInlineObjectType = {
306
+ meta: {
307
+ type: "problem",
308
+ docs: {
309
+ description:
310
+ "A TypeScript object type written inline takes a named type instead, in every position (TYPE-3, law 4).",
311
+ },
312
+ schema: [],
313
+ messages: {
314
+ inlineObjectType:
315
+ "This inline object type ({{position}}) cannot be referenced, imported or extended, so a second site retypes it and the two copies drift apart in silence the moment a third field arrives on only one of them. Give it a name in the module's `types/` and reference that name here instead.",
316
+ },
317
+ },
318
+ create(context) {
319
+ if (isTestFile(context.filename || context.getFilename())) return {}
320
+
321
+ const reportLiteral = (node, position) => {
322
+ context.report({ node, messageId: "inlineObjectType", data: { position } })
323
+ }
324
+
325
+ const checkParams = (fnNode) => {
326
+ for (const raw of fnNode.params || []) {
327
+ const param = unwrapParam(raw)
328
+ if (inlineObjectTypeOf(param.typeAnnotation, param)) {
329
+ reportLiteral(param.typeAnnotation, "parameter")
330
+ }
331
+ }
332
+ }
333
+
334
+ const checkReturnType = (fnNode) => {
335
+ if (inlineObjectTypeOf(fnNode.returnType, fnNode)) {
336
+ reportLiteral(fnNode.returnType, "return type")
337
+ }
338
+ }
339
+
340
+ return {
341
+ FunctionDeclaration(node) {
342
+ checkParams(node)
343
+ checkReturnType(node)
344
+ },
345
+ FunctionExpression(node) {
346
+ checkParams(node)
347
+ checkReturnType(node)
348
+ },
349
+ ArrowFunctionExpression(node) {
350
+ checkParams(node)
351
+ checkReturnType(node)
352
+ },
353
+ TSPropertySignature(node) {
354
+ if (inlineObjectTypeOf(node.typeAnnotation, node)) {
355
+ reportLiteral(node.typeAnnotation, "property signature")
356
+ }
357
+ },
358
+ VariableDeclarator(node) {
359
+ if (node.id.type !== "Identifier") return
360
+ if (inlineObjectTypeOf(node.id.typeAnnotation, node)) {
361
+ reportLiteral(node.id.typeAnnotation, "variable")
362
+ }
363
+ },
364
+ }
365
+ },
366
+ }
367
+
256
368
  // -- TYPE-4 ----------------------------------------------------------------------------------------
257
369
 
258
370
  /** An enum keeps its runtime object. */
@@ -297,6 +409,7 @@ export const noConstEnum = {
297
409
  const TYPE_SAFETY_RULE_NAMES = [
298
410
  "no-double-cast",
299
411
  "no-inline-param-type",
412
+ "no-inline-object-type",
300
413
  "no-const-enum",
301
414
  "no-unguarded-unknown-cast",
302
415
  ]
@@ -350,6 +463,7 @@ export const noLineSuppression = {
350
463
  export const rules = {
351
464
  "no-double-cast": noDoubleCast,
352
465
  "no-inline-param-type": noInlineParamType,
466
+ "no-inline-object-type": noInlineObjectType,
353
467
  "no-const-enum": noConstEnum,
354
468
  "no-unguarded-unknown-cast": noUnguardedUnknownCast,
355
469
  "no-line-suppression": noLineSuppression,
@@ -365,10 +479,27 @@ export const rules = {
365
479
  * The test exit for `no-double-cast` is inside the rule rather than in a config glob, because
366
480
  * building a deliberately wrong value is how a spec proves a closed API refuses it, and that is a
367
481
  * property of the lane rather than of any repository's file layout.
482
+ *
483
+ * `no-inline-object-type` was published at `"off"`, not `"error"` or `"warn"` like its siblings, until
484
+ * it was measured against the reference backend. The gate this package ships (`index.test.mjs`)
485
+ * requires every published rule to carry a level, so `"off"` -- not absence -- was how the rule stayed
486
+ * registered and switched-off at once: present for a consuming repository to opt into deliberately,
487
+ * silent until the owner had the debt count in hand.
488
+ *
489
+ * That measurement is done: an ESLint Linter-API run, per file, across every file the reference
490
+ * backend's own `eslint.config.mjs` lints (`src/**`, `apps/**`, `libs/**`, `test/**`, `tests/**` --
491
+ * 4,738 files at measurement time) found zero remaining product-code fires once the three real
492
+ * ones (two in `apps/mock/src/main.ts`, one in `apps/playground-rag-agent/src/rag-agent.service.ts`,
493
+ * plus a fourth the same sweep turned up in `src/modules/ai/utils/extract-json-block.ts` that an
494
+ * earlier pass had missed) were each given a named type. The rule's own test-file exemption held
495
+ * throughout: every other hit was inside `*.spec.ts`. Zero debt is exactly the condition this
496
+ * canon's own convention requires before a rule moves from `off` to `error` -- the same standard
497
+ * `no-unguarded-unknown-cast` is still waiting to clear before its own `warn` becomes `error`.
368
498
  */
369
499
  export const recommended = {
370
500
  "starci-be/no-double-cast": "error",
371
501
  "starci-be/no-inline-param-type": "error",
502
+ "starci-be/no-inline-object-type": "error",
372
503
  "starci-be/no-const-enum": "error",
373
504
  "starci-be/no-unguarded-unknown-cast": "warn", // no=3 -- see type-safety.mjs's doc comment
374
505
  "starci-be/no-line-suppression": "error",