@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
@@ -2,8 +2,8 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { slash } from '../../lib/path-key.mjs';
4
4
  import { readJsonFile as readJson } from '../../lib/json.mjs';
5
- import { isInside } from '../common.mjs';
6
- import { openHfs } from '../../lib/hfs-slots.mjs';
5
+ import { isInside } from '../../lib/walk.mjs';
6
+ import { openHfs } from '../slots.mjs';
7
7
 
8
8
 
9
9
  /** A file's identity: its resolved real path, or the resolved path when it does not exist. */
@@ -120,9 +120,10 @@ function workspaceDirectories(root, { packageRoot: appPackageRoot = root, side =
120
120
  }
121
121
  candidates = next;
122
122
  }
123
- if (!candidates.length) throw Error(`Workspace pattern matched no package directories: ${normalized}.`);
124
123
  // npm expands a `*` segment to the directories that hold a package.json and skips the rest (an empty or untracked folder is
125
- // HFS_EMPTY_DIR / HFS_SLOT_UNDECLARED, never a reason to analyse nothing); a literal workspace path must resolve.
124
+ // HFS_EMPTY_DIR / HFS_SLOT_UNDECLARED, never a reason to analyse nothing); a literal workspace path must resolve. A `*`
125
+ // pattern may match nothing: every app declares the same workspaces (fe/apps/*, fe/packages/*, HFS_MONO_WORKSPACES) whether
126
+ // or not it has a package yet, and the root patterns are held to that fixed list by the rule, not here.
126
127
  const wildcard = segments.includes('*');
127
128
  for (const candidate of candidates) admit(candidate, `workspace ${normalized}`, !wildcard);
128
129
  }
@@ -262,13 +263,13 @@ const GRAMMAR_PACKAGE = '@starci/grammar';
262
263
  function derivedGrammar(root, packageRoot, workspaces, apps = []) {
263
264
  const styleSources = apps.map(app => `apps/${app.name}/src/app/globals.css`).filter(relative => existingRegularFile(root, relative));
264
265
  if (!styleSources.length) return null;
265
- // The apps have no package.json of their own: the app root's one manifest declares their dependencies, and it is a consumer
266
- // when it declares the Grammar package; so is every workspace package that does.
266
+ // Every fe app and package is an npm workspace that declares what it imports (HFS_MONO_WORKSPACE_DEP): each workspace that
267
+ // declares the Grammar package is a consumer, and so is the app root's manifest when it does.
267
268
  const declaresGrammar = (pkg) => Boolean(pkg && [pkg.dependencies, pkg.peerDependencies, pkg.devDependencies].some(section => section && Object.hasOwn(section, GRAMMAR_PACKAGE)));
268
269
  const appManifest = slash(path.relative(root, path.join(packageRoot, 'package.json')));
269
270
  const consumerManifests = declaresGrammar(readJson(path.join(packageRoot, 'package.json'))) ? [appManifest] : [];
270
271
  for (const workspace of workspaces) {
271
- if (!workspace.startsWith('packages/')) continue;
272
+ if (!workspace.startsWith('packages/') && !workspace.startsWith('apps/')) continue;
272
273
  const manifest = `${workspace}/package.json`;
273
274
  const pkg = readJson(path.join(root, ...manifest.split('/')));
274
275
  if (declaresGrammar(pkg)) consumerManifests.push(manifest);
@@ -298,7 +299,9 @@ export function loadArchitectureConfig(repositoryRoot, { hfs } = {}) {
298
299
  if (!projects.length) throw Error('The repository has no tsconfig.json to derive a TypeScript project from.');
299
300
  const backend = {
300
301
  modules: ['src/modules'],
301
- features: ['src/features'],
302
+ // The layered feature root is the api kind (src/features/api/<feature>/: application/ and transport/<protocol>/); the cli
303
+ // feature root (src/features/cli/) has its own shape, held by its slot and the cli rules.
304
+ features: ['src/features/api'],
302
305
  apps: ['apps'],
303
306
  moduleRegistration: { providerIdentity: 'exported-class-token', handlerDecorators: [...DECLARED_HANDLER_DECORATORS] },
304
307
  };
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { machineKit, pascal, upperSnake } from './machine-ast.mjs';
4
- import { locateDeclaration } from '../../lib/hfs-slots.mjs';
4
+ import { locateDeclaration } from '../slots.mjs';
5
5
 
6
6
  /**
7
7
  * R84 `connection-map` (BE_CONNECTION_DUPLICATE), folding RED01 and RED02. One physical database is one connection, one
@@ -14,11 +14,14 @@ import { locateDeclaration } from '../../lib/hfs-slots.mjs';
14
14
  * - a connection file for a name hfs.json does not declare is refused;
15
15
  * - `getEntityManagerToken(` and `InjectEntityManager(` (from @nestjs/typeorm) resolve their argument through the
16
16
  * checker to a connection name and may be called only in that connection's decorators file, once; the DataSource
17
- * forms only in platform/database, the migrate app and the test fixtures; an exported `Inject*EntityManager`
17
+ * forms only in platform/database, the cli app and the test fixtures; an exported `Inject*EntityManager`
18
18
  * anywhere else (a capability-specific injector, a duplicate) is refused;
19
19
  * - inside one app every connection is passed to the platform/database module registration once;
20
+ * - a connection whose hfs.json `isolation` is `schema` reads `<PREFIX>_SCHEMA` in its config (a context is its own database or its own
21
+ * schema of a shared one; splitting it out later is an env change only);
20
22
  * - in every `.starcistacks/<env>/runtime/env` two connections whose `<PREFIX>_HOST`, `<PREFIX>_PORT` and
21
- * `<PREFIX>_NAME` (or `_DATABASE`, whichever the config reads) resolve to one triple are the same database.
23
+ * `<PREFIX>_NAME` (or `_DATABASE`, whichever the config reads) resolve to one triple are the same database, unless both are
24
+ * `isolation: schema` with different `<PREFIX>_SCHEMA` values (two contexts as schemas of one database).
22
25
  */
23
26
  export const CONNECTION_RULE_IDS = ['BE_CONNECTION_DUPLICATE'];
24
27
 
@@ -92,12 +95,14 @@ export function checkConnectionMap(input) {
92
95
  report(configRel, `Connection ${connection.name} needs ${configRel}, the one place its ${connection.envPrefix}_* keys are read.`, { connection: connection.name });
93
96
  continue;
94
97
  }
98
+ let readsSchema = false;
95
99
  kit.walk(configFile.sourceFile, node => {
96
100
  let key = null;
97
101
  if (ts.isStringLiteralLike(node) && ENV_KEY.test(node.text)) key = node.text;
98
102
  else if (ts.isPropertyAccessExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'env'
99
103
  && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === 'process' && ENV_KEY.test(node.name.text)) key = node.name.text;
100
104
  if (key === null) return true;
105
+ if (key === `${connection.envPrefix}_SCHEMA`) readsSchema = true;
101
106
  const own = key.startsWith(`${connection.envPrefix}_`);
102
107
  const foreign = declared.find(other => other !== connection && key.startsWith(`${other.envPrefix}_`));
103
108
  if (!own || foreign) {
@@ -105,6 +110,7 @@ export function checkConnectionMap(input) {
105
110
  }
106
111
  return false;
107
112
  });
113
+ if (connection.isolation === 'schema' && !readsSchema) report(configRel, `Connection ${connection.name} is isolated as a schema of a shared database (hfs.json isolation), so ${configRel} must read ${connection.envPrefix}_SCHEMA and hand it to the data source; splitting the context out into its own database is then an env change only.`, { connection: connection.name });
108
114
  }
109
115
  for (const file of databaseFiles) {
110
116
  const match = /^(.+)\.(connection|decorators|config)\.ts$/u.exec(path.posix.basename(file.rel));
@@ -133,8 +139,8 @@ export function checkConnectionMap(input) {
133
139
  if (perDecorators.get(value) > 1) report(file.rel, `${home} resolves connection ${value} more than once; one connection has one injector.`, { ...kit.at(file.rel, file.sourceFile, node), connection: value });
134
140
  }
135
141
  } else if (binding?.module === TYPEORM && SOURCE_CALLS.has(binding.name)) {
136
- const allowed = file.rel.startsWith(`${DATABASE_DIR}/`) || slotIs('be.app.migrate') || slotIs('be.tests.fixtures');
137
- if (!allowed) report(file.rel, `${binding.name}() reaches the DataSource outside platform/database and the migrate app; inject the shared EntityManager of the connection instead.`, kit.at(file.rel, file.sourceFile, node));
142
+ const allowed = file.rel.startsWith(`${DATABASE_DIR}/`) || slotIs('be.app.cli') || slotIs('be.cli') || slotIs('be.tests.fixtures');
143
+ if (!allowed) report(file.rel, `${binding.name}() reaches the DataSource outside platform/database and the cli; inject the shared EntityManager of the connection instead.`, kit.at(file.rel, file.sourceFile, node));
138
144
  }
139
145
  }
140
146
  const exportedName = (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && ts.isVariableStatement(node.parent?.parent) && kit.isExported(node.parent.parent)) ? node.name.text
@@ -189,7 +195,7 @@ export function checkConnectionMap(input) {
189
195
  const rel = path.relative(config.root, path.join(stacksRoot, env, 'runtime', 'env')).split(path.sep).join('/');
190
196
  const values = readEnvFiles(path.join(stacksRoot, env, 'runtime', 'env'));
191
197
  if (!values || values.size === 0) { stacksSkipped += 1; continue; }
192
- const seen = new Map();
198
+ const seen = new Map(); // host|port|database -> [{name, isolation, schema}]
193
199
  let resolved = 0;
194
200
  for (const connection of declared) {
195
201
  const configFile = kit.graphFile(`${DATABASE_DIR}/${connection.name}.config.ts`);
@@ -200,9 +206,15 @@ export function checkConnectionMap(input) {
200
206
  if (!host || !port || !database) continue;
201
207
  resolved += 1;
202
208
  const triple = `${host.toLowerCase()}|${port}|${database}`;
203
- if (seen.has(triple)) {
204
- report(rel, `Connections ${seen.get(triple)} and ${connection.name} resolve to the same database (${host}:${port}/${database}) in stack ${env}; one physical database is one connection.`, { connection: connection.name, env });
205
- } else seen.set(triple, connection.name);
209
+ const schema = connection.isolation === 'schema' ? (values.get(`${connection.envPrefix}_SCHEMA`) ?? '') : null;
210
+ const sharing = seen.get(triple) ?? [];
211
+ // Two contexts may share one database only as distinct, named schemas; a context that is its own database shares it with nobody.
212
+ const clash = sharing.find(other => schema === null || other.schema === null || other.schema === schema);
213
+ if (clash) {
214
+ report(rel, `Connections ${clash.name} and ${connection.name} resolve to the same database (${host}:${port}/${database}${schema !== null && clash.schema === schema ? `, schema ${schema || '(none)'}` : ''}) in stack ${env}; contexts share a database only as distinct schemas (isolation schema, a different ${connection.envPrefix}_SCHEMA), otherwise one physical database is one connection.`, { connection: connection.name, env });
215
+ }
216
+ sharing.push({ name: connection.name, schema });
217
+ seen.set(triple, sharing);
206
218
  }
207
219
  if (resolved) stacksChecked += 1; else stacksSkipped += 1;
208
220
  }
@@ -0,0 +1,88 @@
1
+ import path from 'node:path';
2
+ import { contextModelOf } from './context-map.mjs';
3
+ import { machineKit } from './machine-ast.mjs';
4
+ import { entitiesOf } from './sql-owner.mjs';
5
+
6
+ /**
7
+ * R175 `context-coupling` (BE_CONTEXT_COUPLING). Bounded contexts are coupled by events only, never by their schema or their code:
8
+ * - an entity relation (`OneToOne`, `OneToMany`, `ManyToOne`, `ManyToMany` imported from typeorm) whose target class is an entity of a capability
9
+ * of another context is refused;
10
+ * - a migration foreign key (`REFERENCES <table>` in a string of a migration file) onto a table whose entity belongs to another context is refused;
11
+ * - a domain or projection file imports no file of a capability of another context (its entities, services or SQL).
12
+ * A capability's context is the connection its persistence arrays are registered on (context-map.mjs). A file or table with no proven context
13
+ * (a platform capability, a capability registered nowhere) is never judged, and the table of a foreign key is looked up through the entities the
14
+ * program declares, never guessed from a name.
15
+ */
16
+ export const CONTEXT_COUPLING_RULE_IDS = ['BE_CONTEXT_COUPLING'];
17
+
18
+ const RULE = 'BE_CONTEXT_COUPLING';
19
+ const TYPEORM = 'typeorm';
20
+ const RELATIONS = new Set(['OneToOne', 'OneToMany', 'ManyToOne', 'ManyToMany']);
21
+ // The table identifier after the SQL keyword REFERENCES, optionally schema-qualified and quoted.
22
+ const REFERENCES = /\bREFERENCES\s+(?:"?[A-Za-z0-9_]+"?\s*\.\s*)?"?([A-Za-z0-9_]+)"?/giu;
23
+
24
+ export function checkContextCoupling(input) {
25
+ const { graph } = input;
26
+ const kit = machineKit(input);
27
+ const { ts, resolver } = kit;
28
+ const model = contextModelOf(kit, graph);
29
+ const { tables } = entitiesOf(kit, graph);
30
+ const violations = [];
31
+ const seen = new Set();
32
+ const once = (key, violation) => { if (!seen.has(key)) { seen.add(key); violations.push({ ruleId: RULE, ...violation }); } };
33
+ const counts = { relations: 0, foreignKeys: 0, imports: 0 };
34
+ const covered = new Set(); // `${fromRel}|${targetRoot}` pairs a relation already reported, so the import edge is not a second finding
35
+
36
+ for (const file of graph.files.values()) {
37
+ if (file.slot !== 'be.persistence' || !file.owner) continue;
38
+ const base = path.posix.basename(file.rel);
39
+ const ownContext = model.contextOfCapability(file.owner.root);
40
+ if (!ownContext) continue;
41
+ const checker = kit.checkerOf(file.sourceFile);
42
+ if (base.endsWith('.entity.ts')) {
43
+ kit.walk(file.sourceFile, node => {
44
+ if (!ts.isDecorator(node) || !ts.isCallExpression(node.expression)) return true;
45
+ const binding = kit.importBinding(checker, node.expression.expression);
46
+ if (binding?.module !== TYPEORM || !RELATIONS.has(binding.name)) return true;
47
+ counts.relations += 1;
48
+ const argument = node.expression.arguments[0];
49
+ const target = argument && (ts.isArrowFunction(argument) || ts.isFunctionExpression(argument)) && !ts.isBlock(argument.body) ? model.unparen(argument.body) : null;
50
+ if (!target || !(ts.isIdentifier(target) || ts.isPropertyAccessExpression(target))) return true;
51
+ const home = kit.declarationsOf(checker, ts.isPropertyAccessExpression(target) ? target.name : target).map(kit.ownerOfDeclaration).find(Boolean);
52
+ const targetContext = home ? model.contextOfCapability(home.root) : null;
53
+ if (!home || !targetContext || targetContext === ownContext) return true;
54
+ covered.add(`${file.rel}|${home.root}`);
55
+ once(`${file.rel}|${node.getStart()}`, { ...kit.at(file.rel, file.sourceFile, node), message: `A ${binding.name} relation in ${file.owner.root} (context ${ownContext}) targets an entity of ${home.root} (context ${targetContext}); a relation never crosses a context. Keep the other context's id as a plain column and fill a local copy or a projection from its events.`, context: ownContext, targetContext });
56
+ return true;
57
+ });
58
+ }
59
+ if (file.rel.slice(file.owner.root.length + 1).startsWith('persistence/migrations/')) {
60
+ kit.walk(file.sourceFile, node => {
61
+ if (!(ts.isStringLiteralLike(node) || ts.isTemplateExpression(node))) return true;
62
+ const text = ts.isTemplateExpression(node) ? node.head.text + node.templateSpans.map(span => ` ${span.literal.text}`).join('') : node.text;
63
+ for (const match of text.matchAll(REFERENCES)) {
64
+ counts.foreignKeys += 1;
65
+ const entity = tables.get(match[1].toLowerCase());
66
+ const targetContext = entity ? model.contextOfCapability(entity.owner) : null;
67
+ if (!entity || !targetContext || targetContext === ownContext) continue;
68
+ once(`${file.rel}|${node.getStart()}|${match[1]}`, { ...kit.at(file.rel, file.sourceFile, node), message: `A foreign key in ${file.rel} references table ${entity.table} of ${entity.owner} (context ${targetContext}), but this migration belongs to context ${ownContext}; a foreign key never crosses a context. Keep the id as a plain column and validate it through the other context's API or a local copy fed by its events.`, context: ownContext, targetContext, table: entity.table });
69
+ }
70
+ return true;
71
+ });
72
+ }
73
+ }
74
+
75
+ for (const edge of graph.edges) {
76
+ const from = graph.files.get(edge.from);
77
+ const to = graph.files.get(edge.to);
78
+ if (!from?.owner || !to?.owner || from.owner.root === to.owner.root) continue;
79
+ const fromContext = model.contextOfFile(edge.from);
80
+ const toContext = model.contextOfFile(edge.to);
81
+ if (!fromContext || !toContext || fromContext === toContext) continue;
82
+ counts.imports += 1;
83
+ if (covered.has(`${edge.from}|${to.owner.root}`)) continue;
84
+ once(`${edge.from}|${edge.to}|${edge.line}`, { path: edge.from, line: edge.line, column: edge.column, message: `${from.owner.root} (context ${fromContext}) imports ${to.owner.root} (context ${toContext}); a context never imports another context's entities, services or SQL. Keep a local copy fed by its events, or read a projection.`, context: fromContext, targetContext: toContext });
85
+ }
86
+ void resolver;
87
+ return { violations, coverage: { status: 'checked', ...counts } };
88
+ }
@@ -0,0 +1,237 @@
1
+ import path from 'node:path';
2
+ import { pascal } from './machine-ast.mjs';
3
+ import { DATABASE_DIR } from './connection-map.mjs';
4
+
5
+ const camel = name => { const text = pascal(name); return text[0].toLowerCase() + text.slice(1); };
6
+
7
+ /**
8
+ * Where the persistence arrays of every capability are registered: the object literals (`DatabaseModule.register` connections and the
9
+ * migrate/cli app's) that list `<c>Entities` / `<c>Migrations` of a capability's `persistence/connection.ts` under a connection `name`.
10
+ * The one walk BE_SCHEMA_OWNER and the bounded-context rules (R174 to R178) share, so a capability's context is decided once, by origin.
11
+ *
12
+ * @param {{kit: object, graph: object, persistenceOf: (rel: string) => ({root: string, capability: string, folder: string, below: string[]} | null)}} input
13
+ * @returns {{registered: Map<string, {name: string, connections: Map<string, {file: object, node: object}>}>, registrations: number, all: Array<{file: object, node: object, connection: string | null}>}}
14
+ * `registered`: capability root -> its name and the connections it is registered on (first site each).
15
+ */
16
+ export function collectRegistrations({ kit, graph, persistenceOf }) {
17
+ const { ts } = kit;
18
+ const unparen = node => { let current = node; while (current && (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression?.(current) || ts.isNonNullExpression(current))) current = current.expression; return current; };
19
+ const returned = declaration => {
20
+ const fn = ts.isVariableDeclaration(declaration) && declaration.initializer ? unparen(declaration.initializer) : declaration;
21
+ if (!(ts.isArrowFunction(fn) || ts.isFunctionDeclaration(fn) || ts.isFunctionExpression(fn) || ts.isMethodDeclaration(fn)) || !fn.body) return null;
22
+ if (!ts.isBlock(fn.body)) return fn.body;
23
+ const statements = fn.body.statements.filter(ts.isReturnStatement);
24
+ return statements.length === 1 ? statements[0].expression ?? null : null;
25
+ };
26
+ const propertySources = new Map(); // property name -> [{declaration, initializer, checker}] over the program
27
+ const assignmentsOf = name => {
28
+ if (propertySources.has(name)) return propertySources.get(name);
29
+ const list = [];
30
+ for (const file of graph.files.values()) {
31
+ if (!file.sourceFile.text.includes(name)) continue;
32
+ const checker = kit.checkerOf(file.sourceFile);
33
+ kit.walk(file.sourceFile, node => {
34
+ if (ts.isPropertyAssignment(node) && kit.propertyNameText(node.name) === name && ts.isObjectLiteralExpression(node.parent)) {
35
+ // the property of the type the literal is checked against, so a typed const and a typed return meet the same declaration
36
+ const contextual = checker.getContextualType(node.parent)?.getProperty(name);
37
+ const symbol = contextual ?? checker.getSymbolAtLocation(node.name);
38
+ list.push({ declaration: symbol?.declarations?.[0] ?? null, initializer: node.initializer, checker });
39
+ }
40
+ return true;
41
+ });
42
+ }
43
+ propertySources.set(name, list);
44
+ return list;
45
+ };
46
+ const nameOfLiteral = (checker, literal, depth) => {
47
+ const own = kit.propertyOf(literal, 'name');
48
+ if (own) return kit.stringValue(checker, kit.valueOfProperty(own));
49
+ for (const property of literal.properties) {
50
+ if (!ts.isSpreadAssignment(property)) continue;
51
+ const value = nameOfExpression(checker, property.expression, depth + 1);
52
+ if (value) return value;
53
+ }
54
+ return null;
55
+ };
56
+ const nameOfExpression = (checker, expression, depth) => {
57
+ const node = unparen(expression);
58
+ if (!node || depth > 5) return null;
59
+ if (ts.isObjectLiteralExpression(node)) return nameOfLiteral(checker, node, depth);
60
+ if (ts.isCallExpression(node)) {
61
+ for (const declaration of kit.declarationsOf(checker, node.expression)) {
62
+ const body = returned(declaration);
63
+ const value = body ? nameOfExpression(kit.checkerOf(declaration.getSourceFile()), body, depth + 1) : null;
64
+ if (value) return value;
65
+ }
66
+ return null;
67
+ }
68
+ if (ts.isIdentifier(node)) {
69
+ const declaration = kit.declarationsOf(checker, node)[0];
70
+ return declaration && ts.isVariableDeclaration(declaration) && declaration.initializer ? nameOfExpression(kit.checkerOf(declaration.getSourceFile()), declaration.initializer, depth + 1) : null;
71
+ }
72
+ if (ts.isPropertyAccessExpression(node)) {
73
+ const target = kit.declarationsOf(checker, node.name)[0];
74
+ if (!target) return null;
75
+ const names = new Set(assignmentsOf(node.name.text).filter(item => item.declaration === target).map(item => nameOfExpression(item.checker, item.initializer, depth + 1)).filter(Boolean));
76
+ return names.size === 1 ? [...names][0] : null;
77
+ }
78
+ return null;
79
+ };
80
+ /** The capability arrays (`<c>Entities` / `<c>Migrations` of persistence/connection.ts) an expression lists. */
81
+ const arraysIn = (checker, expression, out, depth) => {
82
+ const node = unparen(expression);
83
+ if (!node || depth > 6) return;
84
+ if (ts.isArrayLiteralExpression(node)) { for (const element of node.elements) arraysIn(checker, ts.isSpreadElement(element) ? element.expression : element, out, depth + 1); return; }
85
+ if (!ts.isIdentifier(node) && !ts.isPropertyAccessExpression(node)) return;
86
+ const declaration = kit.declarationsOf(checker, ts.isPropertyAccessExpression(node) ? node.name : node)[0];
87
+ if (!declaration || !ts.isVariableDeclaration(declaration) || !ts.isIdentifier(declaration.name)) return;
88
+ const rel = kit.graphPath(declaration);
89
+ const owner = rel ? persistenceOf(rel) : null;
90
+ if (owner && owner.below.length === 1 && owner.below[0] === 'connection.ts' && [`${camel(owner.capability)}Entities`, `${camel(owner.capability)}Migrations`].includes(declaration.name.text)) {
91
+ out.push({ key: path.posix.dirname(owner.root), name: owner.capability });
92
+ } else if (declaration.initializer) arraysIn(kit.checkerOf(declaration.getSourceFile()), declaration.initializer, out, depth + 1);
93
+ };
94
+ const all = []; // every registration literal: {file, node, connection | null when the name cannot be resolved}
95
+ const registered = new Map(); // capability root -> {name, connections: Map(connection -> first site)}
96
+ let registrations = 0;
97
+ for (const file of graph.files.values()) {
98
+ const checker = kit.checkerOf(file.sourceFile);
99
+ kit.walk(file.sourceFile, node => {
100
+ if (!ts.isObjectLiteralExpression(node)) return true;
101
+ const arrays = [];
102
+ for (const key of ['entities', 'migrations']) {
103
+ const property = kit.propertyOf(node, key);
104
+ if (property) arraysIn(checker, kit.valueOfProperty(property), arrays, 0);
105
+ }
106
+ if (!arrays.length) return true;
107
+ registrations += 1;
108
+ const connection = nameOfLiteral(checker, node, 0);
109
+ all.push({ file, node, connection });
110
+ if (connection === null) return true;
111
+ for (const { key, name } of new Map(arrays.map(item => [item.key, item])).values()) {
112
+ if (!registered.has(key)) registered.set(key, { name, connections: new Map() });
113
+ const sites = registered.get(key).connections;
114
+ if (!sites.has(connection)) sites.set(connection, { file, node });
115
+ }
116
+ return true;
117
+ });
118
+ }
119
+ return { registered, registrations, all };
120
+ }
121
+
122
+ const PERSISTENCE_SLOT = 'be.persistence';
123
+ /** The slot tiers whose capabilities belong to a bounded context: the domain and the projections (a projection is owned by the context whose connection holds its tables). */
124
+ const CONTEXT_TIERS = new Set(['domain', 'projections']);
125
+
126
+ /** True for a platform capability the manifest lists as `perConnection` (its tables exist on every connection that uses it); `root` is the capability root. */
127
+ export const isPerConnection = (resolver, root, name) => root.split('/').includes('platform') && (resolver.slot(PERSISTENCE_SLOT)?.perConnection ?? []).includes(name);
128
+
129
+ /**
130
+ * `persistenceOf(rel)`: {root, capability, folder, below} of a be.persistence file (`folder` the directory below persistence/, '' at its root),
131
+ * null for any other file. `root` is the persistence root, its dirname the capability root.
132
+ */
133
+ export const persistenceOfFactory = resolver => rel => {
134
+ const classified = resolver.classifyPath(rel);
135
+ if (classified.slot !== PERSISTENCE_SLOT) return null;
136
+ const below = rel.slice(classified.root.length + 1).split('/');
137
+ return { root: classified.root, capability: path.posix.basename(path.posix.dirname(classified.root)), folder: below.length > 1 ? below[0] : '', below };
138
+ };
139
+
140
+ /**
141
+ * The bounded-context model of a program: which context (declared connection) each capability belongs to, and which connection an expression's
142
+ * entity manager is. A context is a connection; a capability belongs to the one declared connection its persistence arrays are registered on
143
+ * (collectRegistrations), a platform capability named by the manifest's `perConnection` list to none (it is infrastructure on every connection).
144
+ * Everything is decided by declarations and origins: the registration literal, the `Inject<Conn>EntityManager` decorator's home file, the
145
+ * transaction callback parameter's receiver. Nothing is matched by a variable name.
146
+ */
147
+ const MODELS = new WeakMap();
148
+
149
+ export function contextModelOf(kit, graph) {
150
+ if (MODELS.has(graph)) return MODELS.get(graph);
151
+ const { ts, resolver } = kit;
152
+ const persistenceOf = persistenceOfFactory(resolver);
153
+ const declared = new Map(resolver.repo.connections.map(connection => [connection.name, connection]));
154
+ const { registered, all } = collectRegistrations({ kit, graph, persistenceOf });
155
+ const perConnection = new Set();
156
+ const contextOfRoot = new Map(); // capability root -> declared connection name (single registration only)
157
+ for (const [root, { name, connections }] of registered) {
158
+ if (isPerConnection(resolver, root, name)) { perConnection.add(root); continue; }
159
+ const names = [...connections.keys()].filter(connection => declared.has(connection));
160
+ if (names.length === 1) contextOfRoot.set(root, names[0]);
161
+ }
162
+ /** The declared connection a capability root belongs to, or null (no registration, several, or a per-connection platform capability). */
163
+ const contextOfCapability = root => contextOfRoot.get(root) ?? null;
164
+ /** The context of the capability owning `rel` when that capability is of a context tier (domain, projections), else null: only these are bounded contexts. */
165
+ const contextOfFile = rel => {
166
+ const file = graph.files.get(rel);
167
+ if (!file?.owner || !CONTEXT_TIERS.has(file.tier)) return null;
168
+ return contextOfCapability(file.owner.root);
169
+ };
170
+ const unparen = node => { let current = node; while (current && (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isNonNullExpression(current))) current = current.expression; return current; };
171
+ /** The declared connection named by a decorator whose callee is declared in src/modules/platform/database/<conn>.decorators.ts, else null. */
172
+ const connectionOfDecorators = (checker, declaration) => {
173
+ for (const decorator of kit.decorators(declaration)) {
174
+ const callee = ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression;
175
+ for (const target of kit.declarationsOf(checker, callee)) {
176
+ const rel = kit.graphPath(target);
177
+ const name = rel ? [...declared.keys()].find(connection => rel === `${DATABASE_DIR}/${connection}.decorators.ts`) : null;
178
+ if (name) return name;
179
+ }
180
+ }
181
+ return null;
182
+ };
183
+ /**
184
+ * The declared connection whose entity manager `expression` is: a property or parameter injected with `Inject<Conn>EntityManager`, or the
185
+ * manager parameter of the callback of a `.transaction(...)` on such a manager. Null when the origin cannot be proven (never guessed).
186
+ */
187
+ const connectionOfManager = (checker, expression, depth = 0) => {
188
+ const node = unparen(expression);
189
+ if (!node || depth > 4) return null;
190
+ const target = ts.isPropertyAccessExpression(node) ? node.name : (ts.isIdentifier(node) ? node : null);
191
+ if (!target) return null;
192
+ const declaration = kit.declarationsOf(checker, target)[0];
193
+ if (!declaration) return null;
194
+ if (ts.isPropertyDeclaration(declaration) || ts.isParameter(declaration)) {
195
+ const direct = connectionOfDecorators(checker, declaration);
196
+ if (direct) return direct;
197
+ }
198
+ if (ts.isParameter(declaration) && (ts.isArrowFunction(declaration.parent) || ts.isFunctionExpression(declaration.parent)) && declaration.parent.parameters[0] === declaration) {
199
+ const call = declaration.parent.parent;
200
+ if (ts.isCallExpression(call) && call.arguments.includes(declaration.parent) && ts.isPropertyAccessExpression(call.expression) && call.expression.name.text === 'transaction') {
201
+ return connectionOfManager(checker, call.expression.expression, depth + 1);
202
+ }
203
+ }
204
+ return null;
205
+ };
206
+ const model = { declared, registered, registrations: all, perConnection, contextOfCapability, contextOfFile, connectionOfManager, unparen };
207
+ MODELS.set(graph, model);
208
+ return model;
209
+ }
210
+
211
+ /** The declared connection names the composition root of `app` passes to the database module registration, as [{connection, node}] in source order. */
212
+ export function connectionsComposedBy(kit, app) {
213
+ const { ts } = kit;
214
+ const root = kit.appRoot(app);
215
+ if (!root) return { root: null, items: [] };
216
+ const declared = new Set(kit.resolver.repo.connections.map(connection => connection.name));
217
+ const checker = kit.checkerOf(root.sourceFile);
218
+ const items = [];
219
+ kit.walk(root.sourceFile, node => {
220
+ if (!(ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register')) return true;
221
+ const owner = kit.declarationsOf(checker, node.expression.expression).map(kit.ownerOfDeclaration).find(Boolean);
222
+ if (!owner || owner.tier !== 'platform' || owner.name !== 'database') return true;
223
+ for (const argument of node.arguments) {
224
+ kit.walk(argument, inner => {
225
+ if (ts.isPropertyAssignment(inner.parent ?? {}) && inner.parent.name === inner) return true;
226
+ if (ts.isPropertyAccessExpression(inner.parent ?? {}) && inner.parent.name === inner) return true;
227
+ if (!(ts.isIdentifier(inner) || ts.isStringLiteralLike(inner) || ts.isPropertyAccessExpression(inner))) return true;
228
+ const value = kit.stringValue(checker, inner);
229
+ if (value === null || !declared.has(value)) return true;
230
+ items.push({ connection: value, node: inner });
231
+ return false;
232
+ });
233
+ }
234
+ return true;
235
+ });
236
+ return { root, items };
237
+ }
@@ -0,0 +1,82 @@
1
+ import { contextModelOf, connectionsComposedBy } from './context-map.mjs';
2
+ import { machineKit } from './machine-ast.mjs';
3
+
4
+ /**
5
+ * R174 `context-owner` (BE_CONTEXT_OWNER). A connection of hfs.json IS a bounded context, and its `owner` is the one service app that composes it:
6
+ * - an api or worker app passes to the database module registration (or lists in its options) only the connections it owns (a monolith's one
7
+ * app owns them all);
8
+ * - an api or worker app imports no domain or projection capability of a context it does not own: it composes only its own contexts;
9
+ * - the cli app is the migration runner and composes every declared connection
10
+ * that holds registered tables (migrations run only through it, once per connection).
11
+ * Which connection an app composes is read from two typed origins in its own files: the connection passed to the database module registration
12
+ * and the connection literal that lists a capability's `<c>Entities` / `<c>Migrations`. Which context a capability belongs to is the connection
13
+ * its arrays are registered on (context-map.mjs); an import is judged by the owner graph edge and the target's slot tier, never by a path or
14
+ * name pattern. A runner app with a registration whose connection name cannot be resolved is not judged for missing connections.
15
+ */
16
+ export const CONTEXT_OWNER_RULE_IDS = ['BE_CONTEXT_OWNER'];
17
+
18
+ const RULE = 'BE_CONTEXT_OWNER';
19
+ const SERVICE_KINDS = new Set(['api', 'worker']);
20
+ const RUNNER_KINDS = new Set(['migrate', 'cli']);
21
+
22
+ export function checkContextOwner(input) {
23
+ const { graph } = input;
24
+ const kit = machineKit(input);
25
+ const { resolver } = kit;
26
+ const model = contextModelOf(kit, graph);
27
+ const violations = [];
28
+ const owners = new Map(resolver.repo.connections.map(connection => [connection.name, connection.owner]));
29
+ let compositions = 0;
30
+ let imports = 0;
31
+ const seen = new Set();
32
+ const once = (key, violation) => { if (!seen.has(key)) { seen.add(key); violations.push({ ruleId: RULE, ...violation }); } };
33
+ const appOf = rel => { const classified = resolver.classifyPath(rel); return classified.slot?.startsWith('be.app.') ? classified.bindings?.app ?? null : null; };
34
+
35
+ // What each app composes: [{connection, file, node}] from the database module registration and from the registration literals under its files.
36
+ const composed = new Map(resolver.repo.apps.map(app => [app.name, { items: [], unresolved: false }]));
37
+ for (const app of resolver.repo.apps) {
38
+ const { root, items } = connectionsComposedBy(kit, app.name);
39
+ for (const item of items) composed.get(app.name).items.push({ ...item, file: root });
40
+ }
41
+ for (const site of model.registrations) {
42
+ const app = appOf(site.file.rel);
43
+ if (!app || !composed.has(app)) continue;
44
+ if (site.connection === null) composed.get(app).unresolved = true;
45
+ else composed.get(app).items.push({ connection: site.connection, file: site.file, node: site.node });
46
+ }
47
+
48
+ for (const app of resolver.repo.apps) {
49
+ const { items, unresolved } = composed.get(app.name);
50
+ compositions += items.length;
51
+ if (SERVICE_KINDS.has(app.kind)) {
52
+ for (const { connection, file, node } of items) {
53
+ const owner = owners.get(connection);
54
+ if (owner && owner !== app.name) {
55
+ once(`${app.name}|${connection}`, { ...kit.at(file.rel, file.sourceFile, node), message: `App ${app.name} composes connection ${connection}, the context owned by app ${owner}; an app composes only the contexts it owns. Reach that context through the API or the events of ${owner}.`, connection, app: app.name, owner });
56
+ }
57
+ }
58
+ } else if (RUNNER_KINDS.has(app.kind) && !unresolved) {
59
+ const root = kit.appRoot(app.name);
60
+ const present = new Set(items.map(item => item.connection));
61
+ const held = new Set([...model.registered.values()].flatMap(entry => [...entry.connections.keys()]));
62
+ for (const connection of owners.keys()) {
63
+ if (!root || present.has(connection) || !held.has(connection) || !items.length) continue;
64
+ once(`${app.name}|missing|${connection}`, { ...kit.at(root.rel, root.sourceFile, root.sourceFile), message: `The ${app.kind} app ${app.name} does not compose connection ${connection}; migrations run only through it, once per connection, so it registers every declared connection that holds tables.`, connection, app: app.name });
65
+ }
66
+ }
67
+ }
68
+
69
+ const kinds = new Map(resolver.repo.apps.map(app => [app.name, app.kind]));
70
+ for (const edge of graph.edges) {
71
+ const app = appOf(edge.from);
72
+ if (!app || !SERVICE_KINDS.has(kinds.get(app))) continue;
73
+ const context = model.contextOfFile(edge.to);
74
+ if (!context) continue;
75
+ imports += 1;
76
+ const owner = owners.get(context);
77
+ if (owner && owner !== app) {
78
+ once(`${edge.from}|${edge.to}`, { path: edge.from, line: edge.line, column: edge.column, message: `App ${app} imports ${graph.files.get(edge.to).owner.root}, a capability of context ${context} owned by app ${owner}; an app composes only the contexts it owns, so use the API or the events of ${owner} instead.`, connection: context, app, owner });
79
+ }
80
+ }
81
+ return { violations, coverage: { status: 'checked', apps: resolver.repo.apps.length, compositions, contextImports: imports } };
82
+ }
@@ -0,0 +1,47 @@
1
+ import { contextModelOf } from './context-map.mjs';
2
+ import { machineKit } from './machine-ast.mjs';
3
+
4
+ /**
5
+ * R178 `context-platform-tables` (BE_CONTEXT_PLATFORM_TABLES). A platform capability the manifest lists as `perConnection` (the event-bus outbox,
6
+ * the job table) keeps its tables on EVERY connection that uses it: when a call into the capability passes the entity manager of connection C
7
+ * (an `Inject<C>EntityManager` property, or the manager of a transaction on one), the capability's `<c>Entities` and `<c>Migrations` must be
8
+ * registered on C, or the row the call writes has no table in C's database. The caller's connection is proven by the manager's origin; a call
9
+ * whose manager origin cannot be proven is not judged.
10
+ */
11
+ export const CONTEXT_PLATFORM_TABLES_RULE_IDS = ['BE_CONTEXT_PLATFORM_TABLES'];
12
+
13
+ const RULE = 'BE_CONTEXT_PLATFORM_TABLES';
14
+
15
+ export function checkContextPlatformTables(input) {
16
+ const { graph } = input;
17
+ const kit = machineKit(input);
18
+ const { ts } = kit;
19
+ const model = contextModelOf(kit, graph);
20
+ const violations = [];
21
+ let calls = 0;
22
+ let unproven = 0;
23
+ if (!model.perConnection.size) return { violations, coverage: { status: 'checked', perConnectionCapabilities: 0, calls, unproven } };
24
+ const seen = new Set();
25
+ for (const file of graph.files.values()) {
26
+ const checker = kit.checkerOf(file.sourceFile);
27
+ kit.walk(file.sourceFile, node => {
28
+ if (!ts.isCallExpression(node)) return true;
29
+ const callee = ts.isPropertyAccessExpression(node.expression) ? node.expression.name : node.expression;
30
+ const home = kit.declarationsOf(checker, callee).map(kit.ownerOfDeclaration).find(Boolean);
31
+ if (!home || !model.perConnection.has(home.root) || file.owner?.root === home.root) return true;
32
+ calls += 1;
33
+ const connections = new Set(node.arguments.map(argument => model.connectionOfManager(checker, argument)).filter(Boolean));
34
+ if (!connections.size) { unproven += 1; return true; }
35
+ const registeredOn = model.registered.get(home.root)?.connections ?? new Map();
36
+ for (const connection of connections) {
37
+ if (registeredOn.has(connection)) continue;
38
+ const key = `${file.rel}|${node.getStart()}|${connection}`;
39
+ if (seen.has(key)) continue;
40
+ seen.add(key);
41
+ violations.push({ ruleId: RULE, ...kit.at(file.rel, file.sourceFile, node), message: `${file.rel} passes the entity manager of connection ${connection} to ${home.name}, but the tables of ${home.name} are not registered on ${connection} (registered: ${[...registeredOn.keys()].sort().join(', ') || 'none'}); register its entities and migrations on ${connection} too, so the table exists in the database of that context.`, connection, capability: home.name });
42
+ }
43
+ return true;
44
+ });
45
+ }
46
+ return { violations, coverage: { status: 'checked', perConnectionCapabilities: model.perConnection.size, calls, unproven } };
47
+ }