@starci/eslint-canon-be 3.0.7 → 3.0.9

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 (171) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +13 -7
  3. package/ambient-id.mjs +90 -0
  4. package/cli.mjs +181 -0
  5. package/comments.mjs +18 -21
  6. package/config-owner.mjs +20 -4
  7. package/connections.mjs +12 -12
  8. package/cookies.mjs +106 -0
  9. package/cqrs.mjs +22 -26
  10. package/data-access.mjs +9 -28
  11. package/default-deny.mjs +2 -1
  12. package/e2e-flow.mjs +4 -4
  13. package/event-bus.mjs +237 -0
  14. package/feature-thin.mjs +237 -0
  15. package/idempotency.mjs +3 -1
  16. package/index.mjs +22 -1
  17. package/infra-owner.mjs +1 -1
  18. package/injection.mjs +67 -22
  19. package/input-bounds.mjs +2 -1
  20. package/jobs.mjs +214 -0
  21. package/lib/ast.mjs +7 -0
  22. package/lib/config.mjs +1 -1
  23. package/lib/db-writes.mjs +1 -1
  24. package/lib/doors.mjs +81 -0
  25. package/lib/edition.mjs +45 -0
  26. package/lib/entity-writes.mjs +35 -0
  27. package/lib/hfs.mjs +3 -3
  28. package/lib/path.mjs +0 -6
  29. package/lib/persistence.mjs +11 -18
  30. package/lib/ports.mjs +5 -2
  31. package/lib/specifier.mjs +27 -0
  32. package/lib/transactions.mjs +37 -0
  33. package/lib/transport-slots.mjs +8 -3
  34. package/lib/unit-spec.mjs +63 -2
  35. package/module-layering.mjs +3 -24
  36. package/module-shape.mjs +2 -2
  37. package/naming.mjs +2 -2
  38. package/package.json +5 -3
  39. package/project-graph.mjs +8 -5
  40. package/projections.mjs +117 -0
  41. package/query-safety.mjs +1 -1
  42. package/realtime.mjs +181 -0
  43. package/runtime/engine/config.mjs +494 -0
  44. package/runtime/engine/invalid-config.mjs +15 -0
  45. package/runtime/engine/orca-config.mjs +27 -0
  46. package/runtime/engine/runtime-root.mjs +5 -0
  47. package/runtime/engine/yaml.mjs +3 -3
  48. package/runtime/knowledge/hfs/canon-pins.yaml +163 -32
  49. package/runtime/knowledge/hfs/rules.yaml +2059 -0
  50. package/runtime/knowledge/hfs/slots.yaml +915 -123
  51. package/runtime/knowledge/patterns/fe/folder.yaml +133 -27
  52. package/runtime/knowledge/sonar-gate.yaml +53 -19
  53. package/runtime/modules/models/registry.yaml +591 -0
  54. package/runtime/modules/models/runtimes.yaml +701 -0
  55. package/runtime/packages/hfs/templates/app/package-scripts/package.json +3 -10
  56. package/runtime/scripts/api/git/config-get.mjs +10 -0
  57. package/runtime/scripts/api/git/lib.mjs +17 -27
  58. package/runtime/scripts/api/git/ls-files.mjs +6 -0
  59. package/runtime/scripts/api/git/recorded-lines.mjs +19 -0
  60. package/runtime/scripts/api/git/remote.mjs +5 -0
  61. package/runtime/scripts/api/git/rev-parse-query.mjs +5 -0
  62. package/runtime/scripts/{lib/hfs-allows.mjs → hfs/allows.mjs} +3 -3
  63. package/runtime/scripts/hfs/architecture/ast-walks.mjs +220 -0
  64. package/runtime/scripts/{checks → hfs}/architecture/backend.mjs +17 -24
  65. package/runtime/scripts/{checks → hfs}/architecture/background-unowned.mjs +16 -34
  66. package/runtime/scripts/{checks → hfs}/architecture/client-reaches-server.mjs +3 -1
  67. package/runtime/scripts/{checks → hfs}/architecture/clones.mjs +4 -4
  68. package/runtime/scripts/{checks → hfs}/architecture/config.mjs +11 -8
  69. package/runtime/scripts/{checks → hfs}/architecture/connection-map.mjs +21 -9
  70. package/runtime/scripts/hfs/architecture/context-coupling.mjs +88 -0
  71. package/runtime/scripts/hfs/architecture/context-map.mjs +237 -0
  72. package/runtime/scripts/hfs/architecture/context-owner.mjs +82 -0
  73. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +47 -0
  74. package/runtime/scripts/hfs/architecture/context-transaction.mjs +60 -0
  75. package/runtime/scripts/{checks → hfs}/architecture/contract-fixture-guard.mjs +5 -11
  76. package/runtime/scripts/{checks → hfs}/architecture/contracts.mjs +33 -162
  77. package/runtime/scripts/{checks → hfs}/architecture/dead-exports.mjs +58 -29
  78. package/runtime/scripts/{checks → hfs}/architecture/doc-language.mjs +1 -1
  79. package/runtime/scripts/{checks → hfs}/architecture/entrypoint.mjs +2 -1
  80. package/runtime/scripts/{checks → hfs}/architecture/fe-slot-allows.mjs +4 -4
  81. package/runtime/scripts/{checks → hfs}/architecture/feature-shape.mjs +6 -3
  82. package/runtime/scripts/{checks → hfs}/architecture/framework-pinned.mjs +1 -1
  83. package/runtime/scripts/{checks → hfs}/architecture/frontend.mjs +17 -43
  84. package/runtime/scripts/{checks → hfs}/architecture/hfs-graph.mjs +1 -1
  85. package/runtime/scripts/{checks → hfs}/architecture/hfs.mjs +27 -42
  86. package/runtime/scripts/{checks → hfs}/architecture/i18n-keys.mjs +1 -1
  87. package/runtime/scripts/{checks → hfs}/architecture/index.mjs +17 -4
  88. package/runtime/scripts/{checks → hfs}/architecture/machine-ast.mjs +44 -11
  89. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +27 -0
  90. package/runtime/scripts/{checks → hfs}/architecture/module-per-transport.mjs +30 -6
  91. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +96 -0
  92. package/runtime/scripts/{checks → hfs}/architecture/next-data.mjs +40 -168
  93. package/runtime/scripts/{checks → hfs}/architecture/owners.mjs +35 -15
  94. package/runtime/scripts/{checks → hfs}/architecture/register-once.mjs +2 -1
  95. package/runtime/scripts/{checks → hfs}/architecture/registration.mjs +23 -81
  96. package/runtime/scripts/{checks → hfs}/architecture/required-files.mjs +21 -13
  97. package/runtime/scripts/{checks → hfs}/architecture/schema-owner.mjs +17 -114
  98. package/runtime/scripts/hfs/architecture/server-action.mjs +24 -0
  99. package/runtime/scripts/hfs/architecture/side-boundary.mjs +16 -0
  100. package/runtime/scripts/{checks → hfs}/architecture/source-names.mjs +27 -147
  101. package/runtime/scripts/{checks → hfs}/architecture/sql-owner.mjs +59 -18
  102. package/runtime/scripts/hfs/architecture/sql-returning.mjs +64 -0
  103. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +111 -0
  104. package/runtime/scripts/hfs/architecture/supabase-be.mjs +131 -0
  105. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +42 -0
  106. package/runtime/scripts/hfs/architecture/supabase.mjs +434 -0
  107. package/runtime/scripts/{checks → hfs}/architecture/surface.mjs +10 -4
  108. package/runtime/scripts/{checks → hfs}/architecture/test-world-files.mjs +11 -21
  109. package/runtime/scripts/{checks → hfs}/architecture/tiers.mjs +47 -5
  110. package/runtime/scripts/{checks → hfs}/architecture/transport-owner.mjs +3 -3
  111. package/runtime/scripts/{checks → hfs}/architecture/typescript.mjs +13 -27
  112. package/runtime/scripts/hfs/declaration-shape.mjs +62 -0
  113. package/runtime/scripts/hfs/declaration-slots.mjs +75 -0
  114. package/runtime/scripts/hfs/edition-slots.mjs +66 -0
  115. package/runtime/scripts/hfs/manifest-shape.mjs +247 -0
  116. package/runtime/scripts/hfs/param-names.mjs +18 -0
  117. package/runtime/scripts/{lib/hfs-path-findings.mjs → hfs/path-findings.mjs} +7 -7
  118. package/runtime/scripts/{lib → hfs}/project-graph.mjs +0 -0
  119. package/runtime/scripts/{lib → hfs}/project-rule.mjs +3 -3
  120. package/runtime/scripts/{lib → hfs}/repo-identity.mjs +9 -6
  121. package/runtime/scripts/hfs/rule-params-shape.mjs +50 -0
  122. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/fe-no-tests.mjs +6 -5
  123. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/read.mjs +1 -1
  124. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/secrets.mjs +4 -4
  125. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/spec-placement.mjs +1 -1
  126. package/runtime/scripts/{lib/hfs-slots.mjs → hfs/slots.mjs} +114 -150
  127. package/runtime/scripts/hfs/test-world-slot.mjs +6 -0
  128. package/runtime/scripts/{checks → hfs}/typescript-programs.mjs +0 -5
  129. package/runtime/scripts/{lib/hfs-view.mjs → hfs/view.mjs} +6 -5
  130. package/runtime/scripts/lib/dockerfile.mjs +124 -0
  131. package/runtime/scripts/lib/git.mjs +36 -0
  132. package/runtime/scripts/lib/i18n.mjs +86 -0
  133. package/runtime/scripts/lib/json.mjs +13 -3
  134. package/runtime/scripts/lib/language.mjs +26 -7
  135. package/runtime/scripts/lib/line-count.mjs +9 -0
  136. package/runtime/scripts/lib/list.mjs +20 -0
  137. package/runtime/scripts/lib/path-key.mjs +59 -2
  138. package/runtime/scripts/lib/read-text.mjs +34 -0
  139. package/runtime/scripts/lib/regex.mjs +4 -0
  140. package/runtime/scripts/lib/secret-patterns.mjs +4 -3
  141. package/runtime/scripts/lib/sops-envelope.mjs +58 -0
  142. package/runtime/scripts/lib/stack-services.mjs +7 -31
  143. package/runtime/scripts/lib/ts-ast.mjs +35 -0
  144. package/runtime/scripts/{checks/common.mjs → lib/walk.mjs} +5 -15
  145. package/schema-authority.mjs +14 -12
  146. package/service-deps.mjs +7 -13
  147. package/service-isolation.mjs +72 -0
  148. package/size-budget.mjs +3 -2
  149. package/test-world.mjs +5 -4
  150. package/testing.mjs +27 -24
  151. package/transport.mjs +12 -9
  152. package/unit-spec.mjs +1 -1
  153. package/user-copy.mjs +5 -4
  154. package/webhooks.mjs +173 -0
  155. package/runtime/scripts/api/sops/decrypt.mjs +0 -16
  156. package/runtime/scripts/lib/recorded-lines.mjs +0 -27
  157. package/runtime/scripts/lib/test-secrets.mjs +0 -116
  158. /package/runtime/scripts/{checks → hfs}/architecture/config-unread.mjs +0 -0
  159. /package/runtime/scripts/{checks → hfs}/architecture/constructor-deps.mjs +0 -0
  160. /package/runtime/scripts/{checks → hfs}/architecture/cross-app-duplicate.mjs +0 -0
  161. /package/runtime/scripts/{checks → hfs}/architecture/default-deny.mjs +0 -0
  162. /package/runtime/scripts/{checks → hfs}/architecture/error-codes.mjs +0 -0
  163. /package/runtime/scripts/{checks → hfs}/architecture/error-masked.mjs +0 -0
  164. /package/runtime/scripts/{checks → hfs}/architecture/hooks-are-hooks.mjs +0 -0
  165. /package/runtime/scripts/{checks → hfs}/architecture/injection-token-exported.mjs +0 -0
  166. /package/runtime/scripts/{checks → hfs}/architecture/package-shape.mjs +0 -0
  167. /package/runtime/scripts/{checks → hfs}/architecture/reachability.mjs +0 -0
  168. /package/runtime/scripts/{checks → hfs}/architecture/route-files-thin.mjs +0 -0
  169. /package/runtime/scripts/{checks → hfs}/architecture/sql-tokens.mjs +0 -0
  170. /package/runtime/scripts/{checks → hfs}/architecture/symbols.mjs +0 -0
  171. /package/runtime/scripts/{checks → hfs}/architecture/unit-spec-providers.mjs +0 -0
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parseYaml } from '../../engine/yaml.mjs';
4
+ import { words } from './dockerfile.mjs';
4
5
 
5
6
  /**
6
7
  * The services of a repository's own stack definition, read from `.starcistacks/application-stacks.yaml` and the compose
@@ -14,11 +15,11 @@ import { parseYaml } from '../../engine/yaml.mjs';
14
15
  * service and is returned as `proxy`.
15
16
  */
16
17
  export const STACKS_DIRECTORY = '.starcistacks';
17
- export const STACKS_FILE = 'application-stacks.yaml';
18
+ const STACKS_FILE = 'application-stacks.yaml';
18
19
  export const DEFAULT_ENVIRONMENT = 'dev';
19
20
 
20
21
  /** Test-only value the `*_FILE` secret variables of a compose service become (the file variable is replaced by the plain variable of the same name). Never a real secret: the test stack listens on loopback only. */
21
- export const TEST_SECRET_VALUE = 'starci-test-secret';
22
+ const TEST_SECRET_VALUE = 'starci-test-secret';
22
23
 
23
24
  const NOT_RUN_ROLES = new Set(['service', 'observability']);
24
25
  const NOT_RUN_PROFILES = new Set(['app']);
@@ -44,8 +45,6 @@ const KNOWN_IMAGES = Object.freeze({
44
45
  clickhouse: { kind: 'database', aliases: ['clickhouse', 'database', 'db'], probe: { tcp: true } },
45
46
  kafka: { kind: 'queue', aliases: ['kafka', 'queue', 'broker'], probe: { tcp: true } },
46
47
  redpanda: { kind: 'queue', aliases: ['redpanda', 'kafka', 'queue', 'broker'], probe: { tcp: true } },
47
- rabbitmq: { kind: 'queue', aliases: ['rabbitmq', 'amqp', 'queue', 'broker'], probe: { tcp: true } },
48
- nats: { kind: 'queue', aliases: ['nats', 'queue', 'broker'], probe: { tcp: true } },
49
48
  elasticsearch: { kind: 'search', aliases: ['elasticsearch', 'elastic', 'search'], probe: { tcp: true } },
50
49
  opensearch: { kind: 'search', aliases: ['opensearch', 'search'], probe: { tcp: true } },
51
50
  });
@@ -58,14 +57,14 @@ const KNOWN_IMAGES = Object.freeze({
58
57
  export const STATEFUL_KINDS = Object.freeze(new Set(['database', 'cache', 'identity', 'storage', 'mail', 'queue', 'search']));
59
58
 
60
59
  /** The repository name of an image reference: `quay.io/keycloak/keycloak:26.0` -> `keycloak`. */
61
- export const repositoryOf = image => {
60
+ const repositoryOf = image => {
62
61
  const withoutDigest = image.split('@')[0];
63
62
  const lastSegment = withoutDigest.split('/').at(-1) ?? withoutDigest;
64
63
  return lastSegment.split(':')[0];
65
64
  };
66
65
 
67
66
  /** The repository path of an image reference without its tag: `quay.io/keycloak/keycloak:26.0` -> `quay.io/keycloak/keycloak`. */
68
- export const imagePathOf = image => {
67
+ const imagePathOf = image => {
69
68
  const withoutDigest = image.split('@')[0];
70
69
  const lastSlash = withoutDigest.lastIndexOf('/');
71
70
  const colon = withoutDigest.indexOf(':', lastSlash + 1);
@@ -73,30 +72,7 @@ export const imagePathOf = image => {
73
72
  };
74
73
 
75
74
  /** True when the image reference carries a tag or digest (an external image, not a placeholder of the repository's own build). */
76
- export const isTagged = image => image.includes('@') || image.slice(image.lastIndexOf('/') + 1).includes(':');
77
-
78
- /** The shell-like words of a compose `command` string, double and single quotes grouping a word. */
79
- const words = text => {
80
- const out = [];
81
- let current = '';
82
- let quote = null;
83
- let started = false;
84
- for (const char of text) {
85
- if (quote) {
86
- if (char === quote) quote = null;
87
- else current += char;
88
- } else if (char === '"' || char === "'") {
89
- quote = char;
90
- started = true;
91
- } else if (/\s/u.test(char)) {
92
- if (started || current) out.push(current);
93
- current = '';
94
- started = false;
95
- } else current += char;
96
- }
97
- if (started || current) out.push(current);
98
- return out;
99
- };
75
+ const isTagged = image => image.includes('@') || image.slice(image.lastIndexOf('/') + 1).includes(':');
100
76
 
101
77
  const asList = value => (Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]);
102
78
 
@@ -129,7 +105,7 @@ const mountsOf = (value, directory) => {
129
105
  continue;
130
106
  }
131
107
  const parts = String(entry).split(':');
132
- // A Windows drive letter is not a separator: `C:\x:/y` keeps its drive.
108
+ // A Windows drive letter is not a separator: a `<drive>:<path>:/y` mount keeps its drive.
133
109
  const source = /^[A-Za-z]$/u.test(parts[0]) && parts.length > 2 ? `${parts.shift()}:${parts.shift()}` : parts.shift();
134
110
  const target = parts.shift();
135
111
  if (!source || !target || !(source.startsWith('.') || path.isAbsolute(source))) continue;
@@ -0,0 +1,35 @@
1
+ // ts-ast.mjs — the small reads the hfs checks share over TypeScript's own AST: a node's name or
2
+ // literal text, a node's 1-based position, a named property's initializer text. `ts` is the caller's
3
+ // already-resolved compiler (see package-at.mjs loadTypescript); nothing here resolves a package.
4
+
5
+ /** The text of an identifier or a string-literal-like `node`, or null (property names, literal specifiers). */
6
+ export const nameText = (ts, node) => (node && (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) ? node.text : null);
7
+
8
+ /** The text of a string-literal-like `node` (a string literal or a no-substitution template), or null. */
9
+ export const literalText = (ts, node) => (node && ts.isStringLiteralLike(node) ? node.text : null);
10
+
11
+ /** The 1-based {line, column} of `node` in `sourceFile`. */
12
+ export const sourceLocation = (sourceFile, node) => {
13
+ const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
14
+ return { line: position.line + 1, column: position.character + 1 };
15
+ };
16
+
17
+ /**
18
+ * The `.text` of `key`'s property initializer in `text` (parsed as `file`): a property assignment
19
+ * inside an object literal, or — `declaration: true` — a class property declaration. `kind` is
20
+ * 'string' (a string-literal-like initializer) or 'identifier'. The last match wins; null when none.
21
+ */
22
+ export function propertyText(ts, text, { file = 'source.ts', key, declaration = false, kind = 'string' } = {}) {
23
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
24
+ let value = null;
25
+ const visit = (node) => {
26
+ const isProp = declaration ? ts.isPropertyDeclaration(node) : ts.isPropertyAssignment(node);
27
+ if (isProp && node.name && ts.isIdentifier(node.name) && node.name.text === key && node.initializer) {
28
+ const ok = kind === 'identifier' ? ts.isIdentifier(node.initializer) : ts.isStringLiteralLike(node.initializer);
29
+ if (ok) value = node.initializer.text;
30
+ }
31
+ ts.forEachChild(node, visit);
32
+ };
33
+ visit(source);
34
+ return value;
35
+ }
@@ -1,23 +1,13 @@
1
+ // walk.mjs - filesystem walking helpers: isInside (containment) and walkFiles (depth-first file listing).
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
- import {fileURLToPath} from 'node:url';
4
4
 
5
- /** Whether `target` is `root` itself or inside it (filesystem paths, either separator). */
6
- export function isInside(root, target) {
5
+ /** Whether `target` is `root` itself or inside it (filesystem paths, either separator). `includeSelf: false` excludes `target === root`. */
6
+ export function isInside(root, target, { includeSelf = true } = {}) {
7
7
  const relative = path.relative(root, target);
8
- return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
8
+ if (relative === '') return includeSelf;
9
+ return !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
9
10
  }
10
-
11
- /** True when this module is the process entry point (`node file.mjs`, not an import). */
12
- /** The real path of a file, or its resolved path when it cannot be read (a missing file is never the entry module). */
13
- const realPathOf = (file) => { try { return fs.realpathSync.native(path.resolve(file)); } catch { return path.resolve(file); } };
14
- /**
15
- * True when the module at `metaUrl` is the process entry point. Both sides are compared as REAL paths: Node gives a module
16
- * its symlink-resolved URL, while argv[1] keeps the path it was started with, so an entry reached through a node_modules
17
- * junction or symlink (a lane worktree, an npx shim) would otherwise look like an import and silently do nothing.
18
- */
19
- export const isMain = (metaUrl, argv = process.argv) => Boolean(argv[1]) && realPathOf(argv[1]) === realPathOf(fileURLToPath(metaUrl));
20
-
21
11
  /**
22
12
  * Every entry under `dir` that is not a directory, depth-first; `filter` sees the entry name and full
23
13
  * path. `sorted` sorts each directory's entries by name (default: readdir order). `exclude`
@@ -8,11 +8,11 @@
8
8
  * `@nestjs/typeorm` options type) with no literal `synchronize: false`; a `synchronize` that is anything but
9
9
  * the literal `false`; `new DataSource(<not an object literal>)` whose options cannot be read; a
10
10
  * `dataSource.synchronize()` or `.dropDatabase()` call; the `migrationsRun` option in any form and a `runMigrations()` /
11
- * `undoLastMigration()` call outside `apps/migrate` and the test world; `dropSchema` set to anything but `false`; a schema
11
+ * `undoLastMigration()` call outside the cli (apps/cli and src/features/cli) and the test world; `dropSchema` set to anything but `false`; a schema
12
12
  * builder (`createSchemaBuilder()`, `.build()` of typeorm's `SchemaBuilder`); a `synchronize` key in the options of typeorm's
13
13
  * `@Entity` (a per-entity switch is a second authority); a lifecycle hook (`onModuleInit`, `onApplicationBootstrap`) that
14
- * writes rows through an `EntityManager` or `DataSource` (seeding runs in `apps/migrate`); a DDL statement in any string
15
- * outside the migrations of `persistence/`; and an entity or migration glob. Only `apps/migrate` runs migrations and seeds.
14
+ * writes rows through an `EntityManager` or `DataSource` (seeding is a cli command); a DDL statement in any string
15
+ * outside the migrations of `persistence/`; and an entity or migration glob. Only the cli (its migrate and seed commands) runs migrations and seeds.
16
16
  * - `no-entity-in-contract` (R37 `BE_ENTITY_IN_CONTRACT`) keeps ORM entities out of the types that cross a boundary:
17
17
  * any type whose declaration carries `@Entity` (from `typeorm`), reached through the type arguments and properties of
18
18
  * a transport signature, the `execute` of a handler, or a `*.contracts.ts`, `*.command.ts` or `*.query.ts` file; and
@@ -22,9 +22,11 @@
22
22
  * never of a path pattern.
23
23
  */
24
24
  import ts from "typescript"
25
+ import { isTransportSlot } from "./lib/transport-slots.mjs"
25
26
  import { keyName, walk } from "./lib/ast.mjs"
26
27
  import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
27
28
  import { baseName, isMigrationFile, packageOfFile } from "./lib/ports.mjs"
29
+ import { inCli } from "./lib/persistence.mjs"
28
30
  import { isPackageType, typed } from "./lib/types.mjs"
29
31
  import { isDeclarationFile } from "./lib/path.mjs"
30
32
 
@@ -37,7 +39,7 @@ const OPTION_PACKAGES = new Set(["typeorm", "@nestjs/typeorm"])
37
39
  /** The DataSource operations that change the schema at runtime. */
38
40
  const SCHEMA_OPERATIONS = new Set(["synchronize", "dropDatabase"])
39
41
 
40
- /** The DataSource operations that run migrations: allowed only in `apps/migrate` and the test world that runs its bootstrap. */
42
+ /** The DataSource operations that run migrations: allowed only in the cli (apps/cli, src/features/cli) and the test world. */
41
43
  const MIGRATION_RUNNERS = new Set(["runMigrations", "undoLastMigration"])
42
44
 
43
45
  /** The typeorm types whose methods write rows. */
@@ -99,8 +101,8 @@ const isOperationsObject = (context, node) => {
99
101
  return members.length > 0 && members.every(isMethod)
100
102
  }
101
103
 
102
- /** Whether a file is `apps/migrate` (slot `be.app.migrate`) or the test world that runs its bootstrap: the only places that run migrations and seed. */
103
- const isMigrationHost = (hfs, filename) => hfs.slotOf(filename) === "be.app.migrate" || inTestWorld(hfs, filename)
104
+ /** Whether a file is the cli (the cli app or the cli feature root, whose migrate and seed commands run them) or the test world: the only places that run migrations and seed. */
105
+ const isMigrationHost = (hfs, filename) => inCli(hfs, filename) || inTestWorld(hfs, filename)
104
106
 
105
107
  /** The SQL text of a call argument: a literal, a template, or the quasis of a tagged template. */
106
108
  const sqlTextOf = (argument) => (argument ? stringsUnder(argument).join(" ") : "")
@@ -133,7 +135,7 @@ const bootWriteOf = (context, method, klass, visited) => {
133
135
  /** Whether an object literal has a property called `name`. */
134
136
  const hasKey = (objectNode, name) => objectNode.properties.some((property) => property.type === "Property" && !property.computed && keyName(property.key) === name)
135
137
 
136
- /** The schema is decided by migrations run by `apps/migrate`, never by the running process. */
138
+ /** The schema is decided by migrations run by the cli migrate command, never by the running process. */
137
139
  export const noRuntimeSchema = {
138
140
  meta: {
139
141
  type: "problem",
@@ -143,13 +145,13 @@ export const noRuntimeSchema = {
143
145
  synchronize: "`synchronize` is set to something other than the literal `false`. The schema changes by migration only; a boot-time ORM diff decides it otherwise.",
144
146
  synchronizeMissing: "This DataSource options object does not state `synchronize: false`. Write the literal `false` in every options object, so what the connection may do to the schema is readable where it is built.",
145
147
  optionsNotLiteral: "`new DataSource(...)` is given options that are not an object literal, so `synchronize: false` cannot be seen. Pass the options as an object literal that states `synchronize: false`.",
146
- synchronizeCall: "`.{{name}}()` changes the schema at runtime. Write a migration and let `apps/migrate` run it.",
147
- migrationsRun: "`migrationsRun` is banned. Only `apps/migrate` runs migrations, once per connection, before api and worker start.",
148
- runMigrations: "`.{{name}}()` runs migrations outside `apps/migrate`. Only `apps/migrate` (and the test world, which runs its bootstrap) runs them, once per connection, before api and worker start.",
148
+ synchronizeCall: "`.{{name}}()` changes the schema at runtime. Write a migration and let the cli migrate command run it.",
149
+ migrationsRun: "`migrationsRun` is banned. Only the cli migrate command (`cli migrate run`) runs migrations, once per connection, before api and worker start.",
150
+ runMigrations: "`.{{name}}()` runs migrations outside the cli. Only the cli migrate command (and the test world, over the same connections) runs them, once per connection, before api and worker start.",
149
151
  dropSchema: "`dropSchema` is set to something other than the literal `false`. A connection never drops the schema it opens; a migration changes it.",
150
152
  schemaBuilder: "`{{name}}` builds or logs the schema from the entity metadata at runtime. The schema changes by migration only.",
151
153
  entitySynchronize: "`synchronize` is set in the options of `@Entity`. A per-entity switch is a second schema authority next to the connection's literal `false`; delete the key.",
152
- bootSeed: "`{{hook}}` writes rows (`{{call}}`), so every process that composes this provider seeds at boot. Seeding runs in `apps/migrate` only, once per connection.",
154
+ bootSeed: "`{{hook}}` writes rows (`{{call}}`), so every process that composes this provider seeds at boot. Seeding is a cli command, run once per connection.",
153
155
  ddl: "A schema-changing statement (`CREATE|ALTER|DROP ...`) outside `persistence/migrations/`. Schema-changing SQL belongs in a migration.",
154
156
  glob: "`{{key}}` is found by glob. List the entities and migrations explicitly from each capability's `index.ts` so what runs is what was reviewed.",
155
157
  },
@@ -269,7 +271,7 @@ const PARAMETER_HOLDERS = new Set(["Identifier", "ObjectPattern", "ArrayPattern"
269
271
  const boundaryOf = (hfs, filename) => {
270
272
  const slot = hfs.slotOf(filename) ?? ""
271
273
  const name = baseName(filename)
272
- if (slot.startsWith("be.transport.") || slot === "be.feature.transport.cli") return "file"
274
+ if (isTransportSlot(slot)) return "file"
273
275
  if (/\.(?:contracts|command|query)\.ts$/.test(name)) return "file"
274
276
  if (name.endsWith(".handler.ts")) return "execute"
275
277
  return null
package/service-deps.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  * token: it is either a broken provider or a raw string/number injection.
12
12
  * - `no-repository-class` (R83): a class that receives or returns an `EntityManager`, a typeorm `Repository`, a
13
13
  * `DataSource` or a `QueryRunner` in any member lives only where the manager may be held (an application handler,
14
- * a domain service, a platform persistence capability), in the migrate app, in the test world or in a migration.
14
+ * a domain service, a platform persistence capability), in the cli app, in the test world or in a migration.
15
15
  * A per-entity persistence class anywhere else is a repository under another name.
16
16
  * - `no-test-double-in-source` (R89): a class, function or constant of production source is not named as a test
17
17
  * double (`Mock`, `Fake`, `Stub` as a PascalCase or camelCase word). A double lives in a spec or `src/tests`;
@@ -21,9 +21,10 @@
21
21
  * slot view. A name is read only for the fixed vocabulary of the double markers, as whole words.
22
22
  */
23
23
  import ts from "typescript"
24
- import { hfsOf } from "./lib/hfs.mjs"
24
+ import { paramParts } from "./lib/ast.mjs"
25
+ import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
25
26
  import { isSpecFile, normalizePath } from "./lib/path.mjs"
26
- import { implementsMigration, inMigrateApp, inTestBootstrap, isMigrationFile, mayHoldEntityManager } from "./lib/persistence.mjs"
27
+ import { implementsMigration, inCli, isMigrationFile, mayHoldEntityManager } from "./lib/persistence.mjs"
27
28
  import { typed, typeOrigins } from "./lib/types.mjs"
28
29
 
29
30
  // -- provider-param-token --------------------------------------------------------------------------
@@ -31,13 +32,6 @@ import { typed, typeOrigins } from "./lib/types.mjs"
31
32
  /** The convention's injector vocabulary: `Inject` or `Inject<Thing>`. */
32
33
  const INJECT_NAME = /^Inject(?:[A-Z][A-Za-z0-9]*)?$/
33
34
 
34
- /** The decorators of a constructor parameter: on the parameter, its wrapper (`private readonly x`) or its inner identifier. */
35
- const paramParts = (param) => {
36
- const inner = param.type === "TSParameterProperty" ? param.parameter : param
37
- const target = inner.type === "AssignmentPattern" ? inner.left : inner
38
- return { target, decorators: [...new Set([...(param.decorators ?? []), ...(inner.decorators ?? []), ...(target.decorators ?? [])])] }
39
- }
40
-
41
35
  /** True when a decorator expression is a call of an `Inject*` function, or of anything typed `TypedParameterDecorator<T>`. */
42
36
  const isInjector = (context, decorator) => {
43
37
  const expression = decorator.expression
@@ -140,11 +134,11 @@ const persistenceMember = (context, classNode) => {
140
134
  export const noRepositoryClass = {
141
135
  meta: {
142
136
  type: "problem",
143
- docs: { description: "A class with a member typed `EntityManager`, `Repository`, `DataSource` or `QueryRunner` lives only in an application handler, a domain service, a platform persistence capability, the migrate app, the test world or a migration." },
137
+ docs: { description: "A class with a member typed `EntityManager`, `Repository`, `DataSource` or `QueryRunner` lives only in an application handler, a domain service, a platform persistence capability, the cli app, the test world or a migration." },
144
138
  schema: [],
145
139
  messages: {
146
140
  statements:
147
- "`{{name}}` is exported and takes an `EntityManager` as its first parameter: a statement module is a repository without a class. Put the SQL text in `<name>.sql.ts` and run it with `this.entityManager.query(...)` from the capability `*.service.ts` or the application `*.handler.ts`; only `platform/database`, `apps/migrate` and `src/tests` take a manager in a free function.",
141
+ "`{{name}}` is exported and takes an `EntityManager` as its first parameter: a statement module is a repository without a class. Put the SQL text in `<name>.sql.ts` and run it with `this.entityManager.query(...)` from the capability `*.service.ts` or the application `*.handler.ts`; only `platform/database`, the cli (`apps/cli`, `src/features/cli`) and `src/tests` take a manager in a free function.",
148
142
  repository:
149
143
  "`{{name}}` takes or holds a `{{type}}` in `{{member}}` but is not an application handler, a domain service or a platform persistence capability. A class wrapping the manager per entity is a repository whatever it is called: it hides which connection and transaction a call runs in and cannot be unit-tested with a token double. Move the queries into the handler or the domain service that owns the use, and call the injected EntityManager directly.",
150
144
  },
@@ -153,7 +147,7 @@ export const noRepositoryClass = {
153
147
  const hfs = hfsOf(context)
154
148
  const filename = context.filename || context.getFilename()
155
149
  const file = normalizePath(filename)
156
- if (mayHoldEntityManager(hfs, file) || inMigrateApp(hfs, file) || inTestBootstrap(hfs, file) || isMigrationFile(hfs, file) || (hfs.slotOf(file) ?? "").startsWith("be.tests.")) return {}
150
+ if (mayHoldEntityManager(hfs, file) || inCli(hfs, file) || inTestWorld(hfs, file) || isMigrationFile(hfs, file) || (hfs.slotOf(file) ?? "").startsWith("be.tests.")) return {}
157
151
  const check = (node) => {
158
152
  if (implementsMigration(context, node)) return
159
153
  const found = persistenceMember(context, node)
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The rule that keeps the services of one product apart (catalog R168 `BE_SERVICE_ISOLATION`).
3
+ *
4
+ * A back-end service is a Nest app at `be/apps/<service>/` of the one repository; services share the `be/src` libraries and talk
5
+ * to each other only through the wire (a client of the sibling's API, or the events of its vendored contract under
6
+ * `be/contracts/<service>/`). One service importing a file of another service's `apps/<other>/` folder reaches into a sibling's
7
+ * composition and options, which makes the two one deployable. The check asks the HFS slot view which app owns a file (the owner of
8
+ * an `apps/<app>/src/` slot) for both the importing file and the file the specifier resolves to, through a relative path or the
9
+ * program's own `compilerOptions.paths`; it never matches a path by name.
10
+ */
11
+ import { posix } from "node:path"
12
+ import { hfsOf } from "./lib/hfs.mjs"
13
+ import { resolveAlias } from "./lib/specifier.mjs"
14
+
15
+ /** The app slots: the slot ids of a service app's `apps/<app>/src/` folder. */
16
+ const isAppSlot = (slotId) => typeof slotId === "string" && slotId.startsWith("be.app.")
17
+
18
+ const isRelative = (specifier) => specifier === "." || specifier === ".." || specifier.startsWith("./") || specifier.startsWith("../")
19
+
20
+ /** Calls `check(node, specifier)` for every import and re-export that names a module. */
21
+ const onSpecifiers = (check) => ({
22
+ ImportDeclaration(node) {
23
+ check(node.source, node.source.value)
24
+ },
25
+ ExportNamedDeclaration(node) {
26
+ if (node.source) check(node.source, node.source.value)
27
+ },
28
+ ExportAllDeclaration(node) {
29
+ if (node.source) check(node.source, node.source.value)
30
+ },
31
+ })
32
+
33
+ /** A file of one service app imports no file of a sibling service app. */
34
+ export const serviceIsolation = {
35
+ meta: {
36
+ type: "problem",
37
+ docs: { description: "A service app (`apps/<service>/`) imports no file of a sibling service app; services share only the `src` libraries and meet through the wire." },
38
+ schema: [],
39
+ messages: {
40
+ sibling:
41
+ "`{{specifier}}` reaches into the service app `{{other}}` from `{{self}}`. Services share only the `src` libraries; call the sibling through its API client or consume its events through the vendored contract (`be/contracts/{{other}}/`).",
42
+ },
43
+ },
44
+ create(context) {
45
+ const filename = context.filename || context.getFilename()
46
+ const hfs = hfsOf(context)
47
+ if (!isAppSlot(hfs.slotOf(filename))) return {}
48
+ const self = hfs.ownerOf(filename)
49
+ if (!self) return {}
50
+ return onSpecifiers((node, specifier) => {
51
+ if (typeof specifier !== "string") return
52
+ const target = isRelative(specifier)
53
+ ? posix.normalize(`${posix.dirname(filename.replaceAll("\\", "/"))}/${specifier}`)
54
+ : resolveAlias(context, specifier)?.target
55
+ if (!target) return
56
+ const reached = [`${target}.ts`, `${target}/index.ts`].find((candidate) => isAppSlot(hfs.slotOf(candidate)))
57
+ if (!reached) return
58
+ const other = hfs.ownerOf(reached)
59
+ if (!other || other === self) return
60
+ context.report({ node, messageId: "sibling", data: { specifier, self: posix.basename(posix.dirname(self)), other: posix.basename(posix.dirname(other)) } })
61
+ })
62
+ },
63
+ }
64
+
65
+ export const rules = {
66
+ "service-isolation": serviceIsolation,
67
+ }
68
+
69
+ /** The level this law asks for: `error`, switched off nowhere. */
70
+ export const recommended = {
71
+ "starci-be/service-isolation": "error",
72
+ }
package/size-budget.mjs CHANGED
@@ -9,14 +9,15 @@
9
9
  * baseline is git's.
10
10
  *
11
11
  * There is no soft-limit lint rule: a warning under a zero-warning gate is an exception in disguise. Listing the
12
- * files over the soft budget (`fileLines.soft`) is a report item of the hfs check, never a block.
12
+ * files over the soft budget (`fileLines.soft`) is a report item of the starci app check, never a block.
13
13
  * There is no baseline file and no allowlist: the record is the repository's own history. A spec, a fixture and an e2e
14
14
  * file are source files like any other. A migration is exempt (a file of the `be.persistence` slot named
15
15
  * `<timestamp>-<name>.ts`: append-only, never edited), and so is a declaration file.
16
16
  */
17
17
  import { basename } from "node:path"
18
18
  import { hfsOf } from "./lib/hfs.mjs"
19
- import { hardGrowthLines, lineCount, recordedLines } from "./runtime/scripts/lib/recorded-lines.mjs"
19
+ import { recordedLines } from "./runtime/scripts/api/git/recorded-lines.mjs"
20
+ import { hardGrowthLines, lineCount } from "./runtime/scripts/lib/line-count.mjs"
20
21
 
21
22
  /** A migration is a file of the persistence slot named `<timestamp>-<name>.ts`. */
22
23
  const isMigration = (hfs, filename) => hfs.slotOf(filename) === "be.persistence" && /^\d{13,14}-[^/]+\.[cm]?ts$/.test(basename(filename))
package/test-world.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  * Unit specs sit beside their subject. Everything else lives under `src/tests/`, one folder per kind:
5
5
  * - `world/` the ONLY test infrastructure (slot `be.tests.world`): `global-setup.ts` starts (or attaches to) the stack
6
6
  * the repository declares in `.starcistacks/<env>` (the test-world library) - every service of it runs
7
- * REAL behind toxiproxy - and runs `apps/migrate`'s exported bootstrap once; `use-test-world.ts` exports
7
+ * REAL behind toxiproxy - and runs the migrations once over the cli app's connections; `use-test-world.ts` exports
8
8
  * `useTestWorld({ apps } | { modules })` -> `world.apps.<name>.api`, `world.db.<connection>`,
9
9
  * `world.infra.<service>` (`latency(ms)`, `cut()`, `restore()` on the real service),
10
10
  * `world.fake.<provider>` (network-edge fakes of external SaaS with failNext/replayWebhook/delay),
@@ -18,6 +18,7 @@
18
18
  * Receivers and origins are judged by type and by the slot of their declaring file, never by name.
19
19
  */
20
20
  import { hfsOf } from "./lib/hfs.mjs"
21
+ import { inCli } from "./lib/persistence.mjs"
21
22
  import { isPackageType, typeOrigins } from "./lib/types.mjs"
22
23
 
23
24
  /** Every test slot under `src/tests/` except the world, where the infrastructure lives. */
@@ -36,7 +37,7 @@ const isEnvMember = (node) => node?.type === "MemberExpression" && isProcessEnv(
36
37
  /**
37
38
  * The world is the only infrastructure location. Refused in fixtures, integration, e2e and contract specs: importing a
38
39
  * migration (a class declared in a `be.persistence` `migrations/` file, or a value whose type is an array of them) or
39
- * anything the migrate app declares; importing typeorm's `DataSource` or a testcontainers package; calling
40
+ * anything the cli app declares; importing typeorm's `DataSource` or a testcontainers package; calling
40
41
  * `runMigrations`/`synchronize`/`dropDatabase`/`createSchema`... on a typeorm receiver; constructing a container; writing
41
42
  * `process.env`. Migration behaviour is tested by the e2e world.
42
43
  */
@@ -46,7 +47,7 @@ export const testsInfraOnlyInWorld = {
46
47
  docs: { description: "Only src/tests/world starts infrastructure, migrates, holds a DataSource or writes process.env." },
47
48
  schema: [],
48
49
  messages: {
49
- migration: "This test imports a migration or the migrate app. The schema is prepared once by `src/tests/world/global-setup.ts` running the real `apps/migrate` bootstrap; migration behaviour is tested through the e2e world.",
50
+ migration: "This test imports a migration or the cli. The schema is prepared once by `src/tests/world/global-setup.ts`, which runs the migrations over the cli app's connections; migration behaviour is tested through the e2e world.",
50
51
  call: "`{{name}}` builds or changes the schema outside `src/tests/world`. The world migrates once; a test uses `world.db.<connection>`.",
51
52
  infra: "This test imports or starts infrastructure (testcontainers, typeorm's DataSource). It belongs to `src/tests/world`; a test uses `useTestWorld(...)` and `world.db.<connection>`.",
52
53
  env: "This test writes `process.env`. The environment is set once by `src/tests/world`; a test takes the world as it is.",
@@ -57,7 +58,7 @@ export const testsInfraOnlyInWorld = {
57
58
  const filename = context.filename || context.getFilename()
58
59
  if (!OUTSIDE_WORLD.has(hfs.slotOf(filename))) return {}
59
60
  const isMigrationDecl = (file) => hfs.slotOf(file) === "be.persistence" && /\/migrations\/[^/]+$/.test(hfs.relative(file))
60
- const fromMigrate = (file) => hfs.slotOf(file) === "be.app.migrate"
61
+ const fromMigrate = (file) => inCli(hfs, file)
61
62
  const migrationTyped = (node) => {
62
63
  const services = context.sourceCode.parserServices
63
64
  const checker = services.program.getTypeChecker()
package/testing.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The rules that hold `testing.md` (catalog R47 `BE_TEST_TOPOLOGY` and R48 `BE_SPEC_QUALITY`).
2
+ * The rules that hold `knowledge/patterns/be/test.yaml` (catalog R47 `BE_TEST_TOPOLOGY` and R48 `BE_SPEC_QUALITY`).
3
3
  *
4
4
  * MOST OF THAT LAW IS NOT MACHINE-CHECKABLE, and pretending otherwise would be worse than checking nothing. No rule
5
5
  * can tell whether a file represents a business flow, whether the unhappy path it covers drags a critical flow
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * - a spec whose every assertion is about a call rather than a result (`no-call-only-spec`);
10
10
  * - a unit spec of anything but a service, a service with no `<name>.service.spec.ts` beside it, a service spec with no
11
- * service beside it, and a file of a kind the convention bans (`.test.ts`, `int-spec`, `harness-spec`)
11
+ * service beside it, and a file of a kind the convention bans (`.test.ts`, `.int-spec.ts`, `.harness-spec.ts`)
12
12
  * (`unit-test-colocated`);
13
13
  * - an e2e that never reads persisted state back, and an e2e that reaches a model provider
14
14
  * (`e2e-asserts-persisted-state`, `no-model-call-in-e2e`);
@@ -24,17 +24,17 @@
24
24
 
25
25
  import { statSync } from "node:fs"
26
26
  import { basename, dirname, join } from "node:path"
27
- import { hfsOf } from "./lib/hfs.mjs"
27
+ import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
28
28
  import { isPackageType, typeOrigins } from "./lib/types.mjs"
29
- import { isServiceSpecFile, isUnitSpecFile, serviceNameOfSpec } from "./lib/unit-spec.mjs"
29
+ import { doorOfSpec, isUnitSpecFile, unitRoleOfSpec, unitRoleOfSubject } from "./lib/unit-spec.mjs"
30
30
 
31
31
  /** The file name of a linted path, in forward-slash form. */
32
32
  const baseOf = (filename) => basename(String(filename || "").replace(/\\/g, "/"))
33
33
 
34
- /** Test kind 1 of exactly two: a unit spec, `<name>.spec.ts` beside its subject. */
34
+ /** Test kind `spec`: a unit spec, `<name>.service.spec.ts` beside its service. */
35
35
  const isUnitSpec = (filename) => /\.spec\.ts$/.test(baseOf(filename))
36
36
 
37
- /** Test kind 2 of exactly two: `*.e2e-spec.ts`. */
37
+ /** Test kind `e2e-spec`: `*.e2e-spec.ts` under `src/tests/e2e/` (`integration-spec` and `contract-spec` are the other two kinds of the tests slots). */
38
38
  const isE2eSpec = (filename) => /\.e2e-spec\.ts$/.test(baseOf(filename))
39
39
 
40
40
  /**
@@ -121,23 +121,23 @@ const exists = (path) => {
121
121
  }
122
122
  }
123
123
 
124
- /** Kinds of test file the convention bans: `.test.ts`, `*.int-spec.ts`, `*.harness-spec.ts` (any spec kind other than `spec` and `e2e-spec`). */
124
+ /** Kinds of test file the convention bans: `.test.ts`, `*.int-spec.ts`, `*.harness-spec.ts` (any spec kind other than `spec`, `e2e-spec`, `integration-spec` and `contract-spec`). */
125
125
  const BANNED_TEST_FILE = /(?:\.test|\.int-spec|\.harness-spec)\.[cm]?[jt]sx?$/
126
126
 
127
127
  /**
128
128
  * Unit specs are for services only: every `<name>.service.ts` has its `<name>.service.spec.ts` beside it, every unit spec is
129
- * one of those, and there are no other kinds of test file.
129
+ * one of those, and there are no kinds of test file beyond `spec`, `e2e-spec`, `integration-spec` and `contract-spec`.
130
130
  */
131
131
  export const unitTestColocated = {
132
132
  meta: {
133
133
  type: "problem",
134
- docs: { description: "Only a `<name>.service.ts` is unit-tested: it has its `<name>.service.spec.ts` beside it, any other unit spec is a finding, and there are only the spec kinds `spec`, `e2e-spec`, `integration-spec` and `contract-spec`." },
134
+ docs: { description: "Only the unit-tested roles (`ruleParams.be.unitRoles`: a `<name>.service.ts`, a cli command `<name>.cli.ts` of `src/features/cli`) own a REQUIRED unit spec, each beside it as `<name>.<role>.spec.ts`; any other logic role (`ruleParams.be.logicRoles`: policy, client, guard, mapper, ...) inside a slot whose coverage is required may have one; any other unit spec is a finding, and there are only the spec kinds `spec`, `e2e-spec`, `integration-spec` and `contract-spec`." },
135
135
  schema: [],
136
136
  messages: {
137
- suffix: "`{{name}}` is a banned kind of test file. A test is a `<name>.service.spec.ts` unit beside its service or a `*.e2e-spec.ts` flow under `src/tests/e2e/`; there is no `.test.ts`, `int-spec` or `harness-spec`.",
138
- notService: "`{{name}}` is a unit spec of something that is not a service. Only `<name>.service.ts` is unit-tested; a handler, resolver, controller, consumer, mapper, entity, guard, module, policy, helper or composition is covered through the service tests and the e2e flows. Delete this spec and move any business rule it checks into a service.",
139
- orphan: "`{{name}}` has no service beside it. A unit spec is `<name>.service.spec.ts` next to `<name>.service.ts` and tests that one service; move it beside its service, or rename it after the service it tests.",
140
- missing: "This service has no spec. Add `{{spec}}` beside it: it builds the service with `Test.createTestingModule`, provides only its constructor dependencies as typed doubles and asserts results or state.",
137
+ suffix: "`{{name}}` is a banned kind of test file. A test is a `<name>.service.spec.ts` unit beside its service, or a `*.e2e-spec.ts`, `*.integration-spec.ts` or `*.contract-spec.ts` under its `src/tests/` folder; there is no `.test.ts`, `.int-spec.ts` or `.harness-spec.ts`.",
138
+ notService: "`{{name}}` is a unit spec of something that carries no unit-tested logic here. A unit spec sits beside a `<name>.<role>.ts` of a spec-required role (a service, a cli command of `src/features/cli`) or of a logic role (`ruleParams.be.logicRoles`: policy, client, guard, mapper, ...) inside the logic of `src/modules/**`, where every file owes 100 per file; a feature file (handler, resolver, controller, consumer, processor, step, ...) is thin (BE_FEATURE_THIN) and is proven by the integration and e2e flows, and an entity, module or composition is declaration. Delete this spec and move any business rule it checks into a service, policy or mapper of `src/modules/**`.",
139
+ orphan: "`{{name}}` has nothing beside it to test. A unit spec is `<name>.<role>.spec.ts` next to the `<name>.<role>.ts` it tests (a service, or a cli command of `src/features/cli`; the roles are `ruleParams.be.unitRoles`); move it beside its subject, or rename it after the subject it tests.",
140
+ missing: "This unit-tested subject has no spec. Add `{{spec}}` beside it: it builds the subject with `Test.createTestingModule`, provides only its constructor dependencies as typed doubles and asserts results or state.",
141
141
  },
142
142
  },
143
143
  create(context) {
@@ -149,17 +149,20 @@ export const unitTestColocated = {
149
149
  return { Program(node) { context.report({ node, messageId: "suffix", data: { name } }) } }
150
150
  }
151
151
  if (isUnitSpecFile(hfs, filename)) {
152
- if (!isServiceSpecFile(hfs, filename)) return { Program(node) { context.report({ node, messageId: "notService", data: { name } }) } }
153
- if (exists(join(dirname(filename), `${serviceNameOfSpec(filename)}.service.ts`))) return {}
152
+ const door = doorOfSpec(hfs, filename)
153
+ if (door !== null) return exists(join(dirname(filename), door)) ? {} : { Program(node) { context.report({ node, messageId: "orphan", data: { name } }) } }
154
+ const role = unitRoleOfSpec(hfs, filename)
155
+ if (!role) return { Program(node) { context.report({ node, messageId: "notService", data: { name } }) } }
156
+ const subject = `${name.slice(0, -`.${role.spec}.ts`.length)}.${role.role}.ts`
157
+ if (exists(join(dirname(filename), subject))) return {}
154
158
  return { Program(node) { context.report({ node, messageId: "orphan", data: { name } }) } }
155
159
  }
156
- if (!name.endsWith(".service.ts")) return {}
157
- // a service outside the test tree; the slot manifest says where the tests live
158
- const slot = hfs.slotOf(filename)
159
- if (!slot || slot.startsWith("be.tests.")) return {}
160
- const stem = join(dirname(filename), name.slice(0, -".ts".length))
161
- if (exists(`${stem}.spec.ts`)) return {}
162
- return { Program(node) { context.report({ node, messageId: "missing", data: { spec: `${name.slice(0, -".ts".length)}.spec.ts` } }) } }
160
+ // a unit-tested subject outside the test tree (ruleParams.be.unitRoles): its spec sits beside it
161
+ const role = unitRoleOfSubject(hfs, filename)
162
+ if (!role) return {}
163
+ const spec = `${name.slice(0, -`.${role.role}.ts`.length)}.${role.spec}.ts`
164
+ if (exists(join(dirname(filename), spec))) return {}
165
+ return { Program(node) { context.report({ node, messageId: "missing", data: { spec } }) } }
163
166
  },
164
167
  }
165
168
 
@@ -175,7 +178,7 @@ const isStateReader = (context, node) => STATE_TYPES.some((name) => isPackageTyp
175
178
  /** The handle `useTestWorld` answers, by where its type is declared: the `@starci/test-world` package or the repository's world slot. */
176
179
  const isWorldHandle = (context, node) => {
177
180
  const hfs = hfsOf(context)
178
- return typeOrigins(context, node).some((origin) => origin.name === "TestWorld" && (origin.module === "@starci/test-world" || hfs.slotOf(origin.file) === "be.tests.world"))
181
+ return typeOrigins(context, node).some((origin) => origin.name === "TestWorld" && (origin.module === "@starci/test-world" || inTestWorld(hfs, origin.file)))
179
182
  }
180
183
 
181
184
  /** The name a non-computed member access reads, or null. */
@@ -302,7 +305,7 @@ export const noApiShapedE2eFilename = {
302
305
  // -- TESTING-7 -------------------------------------------------------------------------------------
303
306
 
304
307
  /** Test infrastructure: the fixtures and the test world, where the shared model stub lives (slots `be.tests.fixtures`, `be.tests.world`). */
305
- const isTestInfrastructure = (hfs, filename) => ["be.tests.fixtures", "be.tests.world"].includes(hfs.slotOf(filename))
308
+ const isTestInfrastructure = (hfs, filename) => hfs.slotOf(filename) === "be.tests.fixtures" || inTestWorld(hfs, filename)
306
309
 
307
310
  /** Bare markers a stub returns when nobody gave it a real answer to stand in for. */
308
311
  const MARKER_STRINGS = new Set(["stubbed", "stub", "ok", "test", "mock", "fake", "todo", "tbd", "n/a", "pending", ""])