@starci/hfs 4.0.9 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/LICENSE +21 -0
  3. package/emit/operations.mjs +1 -1
  4. package/emit/type-schema.mjs +2 -1
  5. package/lint/run.mjs +82 -8
  6. package/package.json +3 -2
  7. package/report/order.mjs +2 -0
  8. package/runtime/config.example.yaml +187 -0
  9. package/runtime/engine/by-code-unit.mjs +12 -0
  10. package/runtime/engine/config.mjs +247 -273
  11. package/runtime/engine/invalid-config.mjs +5 -5
  12. package/runtime/engine/model-config.mjs +90 -0
  13. package/runtime/engine/orca-config.mjs +3 -1
  14. package/runtime/engine/removed-vocabulary.mjs +28 -0
  15. package/runtime/engine/resources-config.mjs +52 -0
  16. package/runtime/engine/runtime-root.mjs +17 -0
  17. package/runtime/engine/secrets.mjs +134 -0
  18. package/runtime/engine/sonar-config.mjs +21 -0
  19. package/runtime/engine/temp-root.mjs +32 -0
  20. package/runtime/knowledge/hfs/canon-pins.yaml +18 -18
  21. package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
  22. package/runtime/knowledge/hfs/rules.yaml +102 -69
  23. package/runtime/knowledge/hfs/slots.yaml +11 -14
  24. package/runtime/knowledge/patterns/be/api.yaml +7 -24
  25. package/runtime/knowledge/patterns/be/cli.yaml +3 -12
  26. package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
  27. package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
  28. package/runtime/modules/kernel/failure-codes.yaml +1 -1
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
  30. package/runtime/modules/models/registry.yaml +25 -397
  31. package/runtime/modules/models/runtimes.yaml +92 -262
  32. package/runtime/modules/models/tiers.yaml +65 -0
  33. package/runtime/scripts/api/fs/claim-file.mjs +30 -0
  34. package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
  35. package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
  36. package/runtime/scripts/api/fs/lib.mjs +6 -0
  37. package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
  38. package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
  39. package/runtime/scripts/api/git/lib.mjs +36 -1
  40. package/runtime/scripts/api/process/resolve-real-tool.mjs +52 -0
  41. package/runtime/scripts/api/process/run-program.mjs +8 -0
  42. package/runtime/scripts/api/sops/decrypt.mjs +9 -16
  43. package/runtime/scripts/api/sops/lib.mjs +309 -11
  44. package/runtime/scripts/api/sops/seal.mjs +8 -4
  45. package/runtime/scripts/hfs/allows.mjs +3 -3
  46. package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
  47. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
  48. package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
  49. package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
  50. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
  51. package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
  52. package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
  53. package/runtime/scripts/hfs/architecture/config.mjs +95 -80
  54. package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
  55. package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
  56. package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
  57. package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
  58. package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
  59. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
  60. package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
  61. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
  62. package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
  63. package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
  64. package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
  65. package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
  66. package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
  67. package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
  68. package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
  69. package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
  70. package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
  71. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +3 -2
  72. package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
  73. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  74. package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
  75. package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
  76. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
  77. package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
  78. package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
  79. package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
  80. package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
  81. package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
  82. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
  83. package/runtime/scripts/hfs/architecture/index.mjs +158 -102
  84. package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
  85. package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
  86. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
  87. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
  88. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
  89. package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
  90. package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
  91. package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
  92. package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
  93. package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
  94. package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
  95. package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
  96. package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
  97. package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
  98. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
  99. package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
  100. package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
  101. package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
  102. package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
  103. package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
  104. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
  105. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
  106. package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
  107. package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
  108. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
  109. package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
  110. package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
  111. package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
  112. package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
  113. package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
  114. package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
  115. package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
  116. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
  117. package/runtime/scripts/hfs/architecture.mjs +3 -4
  118. package/runtime/scripts/hfs/check.mjs +61 -70
  119. package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
  120. package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
  121. package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
  122. package/runtime/scripts/hfs/edition-slots.mjs +5 -3
  123. package/runtime/scripts/hfs/linear-text.mjs +30 -0
  124. package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
  125. package/runtime/scripts/hfs/path-findings.mjs +58 -38
  126. package/runtime/scripts/hfs/pin-findings.mjs +31 -0
  127. package/runtime/scripts/hfs/repo-identity.mjs +5 -2
  128. package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
  129. package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
  130. package/runtime/scripts/hfs/rules/cli.mjs +5 -1
  131. package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
  132. package/runtime/scripts/hfs/rules/contract.mjs +35 -27
  133. package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
  134. package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
  135. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
  136. package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
  137. package/runtime/scripts/hfs/rules/database.mjs +36 -23
  138. package/runtime/scripts/hfs/rules/deps.mjs +64 -32
  139. package/runtime/scripts/hfs/rules/docker.mjs +113 -65
  140. package/runtime/scripts/hfs/rules/edition.mjs +118 -111
  141. package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
  142. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
  143. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
  144. package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
  145. package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
  146. package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
  147. package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
  148. package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
  149. package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
  150. package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
  151. package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
  152. package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
  153. package/runtime/scripts/hfs/rules/saga.mjs +128 -61
  154. package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
  155. package/runtime/scripts/hfs/rules/services.mjs +34 -16
  156. package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
  157. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
  158. package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
  159. package/runtime/scripts/hfs/secret.mjs +90 -45
  160. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  161. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  162. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  163. package/runtime/scripts/hfs/slot-imports.mjs +76 -0
  164. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  165. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  166. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  167. package/runtime/scripts/hfs/slot-required.mjs +76 -0
  168. package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
  169. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  170. package/runtime/scripts/hfs/slots.mjs +72 -693
  171. package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
  172. package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
  173. package/runtime/scripts/hfs/tree.mjs +19 -14
  174. package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
  175. package/runtime/scripts/hfs/view.mjs +1 -1
  176. package/runtime/scripts/lib/dockerfile.mjs +56 -33
  177. package/runtime/scripts/lib/event-contract.mjs +17 -13
  178. package/runtime/scripts/lib/fs-kind.mjs +14 -4
  179. package/runtime/scripts/lib/git.mjs +2 -2
  180. package/runtime/scripts/lib/graphql-contract.mjs +158 -322
  181. package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
  182. package/runtime/scripts/lib/i18n.mjs +3 -2
  183. package/runtime/scripts/lib/in-order.mjs +71 -0
  184. package/runtime/scripts/lib/language.mjs +3 -3
  185. package/runtime/scripts/lib/list.mjs +8 -1
  186. package/runtime/scripts/lib/mutation-fence.mjs +15 -0
  187. package/runtime/scripts/lib/path-key.mjs +42 -9
  188. package/runtime/scripts/lib/pid-alive.mjs +7 -0
  189. package/runtime/scripts/lib/regex.mjs +1 -1
  190. package/runtime/scripts/lib/same-text.mjs +1 -1
  191. package/runtime/scripts/lib/secret-patterns.mjs +6 -2
  192. package/runtime/scripts/lib/sleep-sync.mjs +2 -2
  193. package/runtime/scripts/lib/sops-envelope.mjs +103 -2
  194. package/runtime/scripts/lib/stack-services.mjs +1 -2
  195. package/runtime/scripts/lib/ts-ast.mjs +9 -0
  196. package/runtime/scripts/lib/walk.mjs +6 -1
  197. package/scaffold/add-cli-lite.mjs +2 -1
  198. package/scaffold/add-table.mjs +2 -2
  199. package/scaffold/add.mjs +3 -2
  200. package/scaffold/app.mjs +4 -2
  201. package/scaffold/edition-gate.mjs +25 -25
  202. package/scaffold/lite-exports.mjs +2 -1
  203. package/scaffold/service.mjs +5 -4
  204. package/sync/hygiene.mjs +15 -9
  205. package/sync/index.mjs +7 -3
  206. package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
  207. package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
  208. package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
  209. package/templates/app/ci-workflows/sonar-steps.yml +2 -2
  210. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
  211. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
  212. package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
  213. package/templates/app/starciwork.gitignore +1 -1
  214. package/templates/be/image/api/Dockerfile +1 -1
  215. package/templates/be/image/cli/Dockerfile +1 -1
  216. package/templates/be/image/worker/Dockerfile +1 -1
  217. package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
  218. package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
  219. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
  220. package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
  221. package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
  222. package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
  223. package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
  224. package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
  225. package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
  226. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
  227. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
  228. package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
  229. package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
  230. package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
  231. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
  232. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
  233. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
  234. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
  235. package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
  236. package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
  237. package/templates/fe/image/next/Dockerfile +2 -1
  238. package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
  239. package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
  240. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
  241. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
  242. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
  243. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
  244. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
  245. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
  246. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
  247. package/upgrade/index.mjs +9 -2
@@ -29,26 +29,39 @@ const TEST_CALLEES = new Set(['it', 'test']);
29
29
  const typescriptFor = (repoRoot) => loadTypescript(repoRoot, HERE);
30
30
 
31
31
  /** The event-bus pattern file name of an event name: dots become dashes (`order.placed` -> `order-placed`). */
32
- export const stemOfEvent = (name) => name.split('.').join('-');
32
+ export const stemOfEvent = (name) => name.replaceAll('.', '-');
33
+
34
+ /** The initializer of a `static` property member with an identifier name, parentheses and `as` casts stripped, else null. */
35
+ function staticMemberValue(ts, member) {
36
+ if (!ts.isPropertyDeclaration(member) || !member.initializer || !member.name || !ts.isIdentifier(member.name)) return null;
37
+ const isStatic = member.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword);
38
+ if (!isStatic) return null;
39
+ let value = member.initializer;
40
+ while (ts.isAsExpression(value) || ts.isParenthesizedExpression(value)) value = value.expression;
41
+ return value;
42
+ }
33
43
 
34
44
  /** The `eventName` and `version` literals a class declares as `static readonly` members, with the line of the class. */
45
+ function eventClassInfo(ts, node) {
46
+ let name = null;
47
+ let version = null;
48
+ for (const member of node.members) {
49
+ const value = staticMemberValue(ts, member);
50
+ if (!value) continue;
51
+ if (member.name.text === 'eventName') name = literalText(ts, value);
52
+ if (member.name.text === 'version' && ts.isNumericLiteral(value)) version = Number(value.text);
53
+ }
54
+ if (name !== null || version !== null || node.name) return { className: node.name?.text ?? '<anonymous>', name, version };
55
+ return null;
56
+ }
57
+
35
58
  function readEventClass(ts, text, file) {
36
59
  const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
37
60
  const classes = [];
38
61
  const visit = (node) => {
39
62
  if (ts.isClassDeclaration(node)) {
40
- let name = null;
41
- let version = null;
42
- for (const member of node.members) {
43
- if (!ts.isPropertyDeclaration(member) || !member.initializer || !member.name || !ts.isIdentifier(member.name)) continue;
44
- const isStatic = member.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword);
45
- if (!isStatic) continue;
46
- let value = member.initializer;
47
- while (ts.isAsExpression(value) || ts.isParenthesizedExpression(value)) value = value.expression;
48
- if (member.name.text === 'eventName') name = literalText(ts, value);
49
- if (member.name.text === 'version' && ts.isNumericLiteral(value)) version = Number(value.text);
50
- }
51
- if (name !== null || version !== null || node.name) classes.push({ className: node.name?.text ?? '<anonymous>', name, version });
63
+ const info = eventClassInfo(ts, node);
64
+ if (info) classes.push(info);
52
65
  }
53
66
  ts.forEachChild(node, visit);
54
67
  };
@@ -56,34 +69,42 @@ function readEventClass(ts, text, file) {
56
69
  return classes;
57
70
  }
58
71
 
59
- /** Findings of BE_EVENT_CLASS_CONTRACT. */
60
- export function eventClassContractFindings({ repoRoot, files, repo }) {
61
- const patterns = repo.sides?.be?.patterns ?? [];
62
- if (!patterns.includes('event-bus')) return [];
63
- const ts = typescriptFor(repoRoot);
64
- if (!ts) return [];
72
+ /** The service and file stem of a `be/src/modules/events/<service>/<event>.event.ts` path, else null. */
73
+ function eventFileParts(file) {
74
+ if (!file.startsWith(EVENT_FILE_PREFIX) || !file.endsWith(EVENT_SUFFIX)) return null;
75
+ const rest = file.slice(EVENT_FILE_PREFIX.length).split('/');
76
+ if (rest.length !== 2) return null;
77
+ const [service, base] = rest;
78
+ return { service, stem: base.slice(0, -EVENT_SUFFIX.length) };
79
+ }
80
+
81
+ function recordEventClass(ts, text, file, { service, stem }, findings, classesByService) {
82
+ const classes = readEventClass(ts, text, file).filter((c) => c.name !== null || c.version !== null);
83
+ if (classes.length !== 1 || classes[0].name === null || classes[0].version === null) {
84
+ const candidates = classes.length === 1 ? 'one without both literals' : `${classes.length} candidates`;
85
+ findings.push(found(EVENT_CLASS_CONTRACT, file, `${file} must declare exactly one event class with literal \`static readonly eventName\` and \`static readonly version\` members (the typed event of be/contracts/${service}/${CONTRACT_NAME}); found ${candidates}.`, { service }));
86
+ return;
87
+ }
88
+ const [event] = classes;
89
+ if (stemOfEvent(event.name) !== stem) findings.push(found(EVENT_CLASS_CONTRACT, file, `${file} declares the event \`${event.name}\`, so the file is \`${stemOfEvent(event.name)}${EVENT_SUFFIX}\`: a class file is named after its event.`, { service, event: event.name }));
90
+ if (!classesByService.has(service)) classesByService.set(service, new Map());
91
+ classesByService.get(service).set(event.name, { file, version: event.version });
92
+ }
93
+
94
+ function eventClassesOf(repoRoot, files, ts) {
65
95
  const findings = [];
66
96
  const classesByService = new Map();
67
97
  for (const file of files) {
68
- if (!file.startsWith(EVENT_FILE_PREFIX) || !file.endsWith(EVENT_SUFFIX)) continue;
69
- const rest = file.slice(EVENT_FILE_PREFIX.length).split('/');
70
- if (rest.length !== 2) continue;
71
- const [service, base] = rest;
72
- const stem = base.slice(0, -EVENT_SUFFIX.length);
98
+ const parts = eventFileParts(file);
99
+ if (!parts) continue;
73
100
  const text = readText(repoRoot, file);
74
101
  if (text === null) continue;
75
- const classes = readEventClass(ts, text, file).filter((c) => c.name !== null || c.version !== null);
76
- if (classes.length !== 1 || classes[0].name === null || classes[0].version === null) {
77
- findings.push(found(EVENT_CLASS_CONTRACT, file, `${file} must declare exactly one event class with literal \`static readonly eventName\` and \`static readonly version\` members (the typed event of be/contracts/${service}/${CONTRACT_NAME}); found ${classes.length === 1 ? 'one without both literals' : `${classes.length} candidates`}.`, { service }));
78
- continue;
79
- }
80
- const [event] = classes;
81
- if (stemOfEvent(event.name) !== stem) {
82
- findings.push(found(EVENT_CLASS_CONTRACT, file, `${file} declares the event \`${event.name}\`, so the file is \`${stemOfEvent(event.name)}${EVENT_SUFFIX}\`: a class file is named after its event.`, { service, event: event.name }));
83
- }
84
- if (!classesByService.has(service)) classesByService.set(service, new Map());
85
- classesByService.get(service).set(event.name, { file, version: event.version });
102
+ recordEventClass(ts, text, file, parts, findings, classesByService);
86
103
  }
104
+ return { findings, classesByService };
105
+ }
106
+
107
+ function eventContractsOf(repoRoot, files) {
87
108
  const contracts = new Map();
88
109
  for (const file of files) {
89
110
  if (!file.startsWith(CONTRACT_PREFIX) || !file.endsWith(`/${CONTRACT_NAME}`)) continue;
@@ -91,6 +112,11 @@ export function eventClassContractFindings({ repoRoot, files, repo }) {
91
112
  if (rest.length !== 2) continue;
92
113
  contracts.set(rest[0], { file, doc: readJson(repoRoot, file) });
93
114
  }
115
+ return contracts;
116
+ }
117
+
118
+ function classContractFindings(classesByService, contracts) {
119
+ const findings = [];
94
120
  for (const [service, byName] of classesByService) {
95
121
  const contract = contracts.get(service);
96
122
  for (const [name, { file, version }] of byName) {
@@ -100,6 +126,11 @@ export function eventClassContractFindings({ repoRoot, files, repo }) {
100
126
  else if (entry.version !== version) findings.push(found(EVENT_CLASS_CONTRACT, file, `${file} declares \`${name}\` at version ${version} but ${contract.file} lists version ${entry.version}; a version changes in the class and the contract together.`, { service, event: name }));
101
127
  }
102
128
  }
129
+ return findings;
130
+ }
131
+
132
+ function contractClassFindings(classesByService, contracts) {
133
+ const findings = [];
103
134
  for (const [service, contract] of contracts) {
104
135
  const events = contract.doc?.events;
105
136
  if (events === null || typeof events !== 'object') continue;
@@ -110,6 +141,30 @@ export function eventClassContractFindings({ repoRoot, files, repo }) {
110
141
  return findings;
111
142
  }
112
143
 
144
+ /** Findings of BE_EVENT_CLASS_CONTRACT. */
145
+ export function eventClassContractFindings({ repoRoot, files, repo }) {
146
+ const patterns = repo.sides?.be?.patterns ?? [];
147
+ if (!patterns.includes('event-bus')) return [];
148
+ const ts = typescriptFor(repoRoot);
149
+ if (!ts) return [];
150
+ const classes = eventClassesOf(repoRoot, files, ts);
151
+ const contracts = eventContractsOf(repoRoot, files);
152
+ return [...classes.findings, ...classContractFindings(classes.classesByService, contracts), ...contractClassFindings(classes.classesByService, contracts)];
153
+ }
154
+
155
+ /** Adds the `<pattern>` of every `<pattern>/<scenario>:` test title (the first argument of an `it(` or `test(` call) of the source. */
156
+ function addProvenScenarios(ts, source, proven) {
157
+ const visit = (node) => {
158
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && TEST_CALLEES.has(node.expression.text)) {
159
+ const title = literalText(ts, node.arguments[0]);
160
+ const colon = title === null ? -1 : title.indexOf(':');
161
+ if (colon > 0) proven.add(title.slice(0, colon));
162
+ }
163
+ ts.forEachChild(node, visit);
164
+ };
165
+ visit(source);
166
+ }
167
+
113
168
  /** The scenario ids a back end proves: every `<pattern>/<scenario>:` test title of an e2e or integration spec. */
114
169
  function provenScenarios(ts, repoRoot, files) {
115
170
  const proven = new Set();
@@ -117,16 +172,7 @@ function provenScenarios(ts, repoRoot, files) {
117
172
  if (!TEST_FOLDERS.some((folder) => file.startsWith(folder)) || !SPEC_SUFFIXES.some((suffix) => file.endsWith(suffix))) continue;
118
173
  const text = readText(repoRoot, file);
119
174
  if (text === null) continue;
120
- const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
121
- const visit = (node) => {
122
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && TEST_CALLEES.has(node.expression.text)) {
123
- const title = literalText(ts, node.arguments[0]);
124
- const colon = title === null ? -1 : title.indexOf(':');
125
- if (colon > 0) proven.add(title.slice(0, colon));
126
- }
127
- ts.forEachChild(node, visit);
128
- };
129
- visit(source);
175
+ addProvenScenarios(ts, ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true), proven);
130
176
  }
131
177
  return proven;
132
178
  }
@@ -7,6 +7,7 @@
7
7
  // an object without one is a finding on the document. The finding names the service and every problem.
8
8
  import { operationProblems, parseDocument, parseSchema, rootFieldsOf } from '../../lib/graphql-contract.mjs';
9
9
  import { found, readText } from './read.mjs';
10
+ import { byCodeUnit } from '../../lib/list.mjs';
10
11
 
11
12
  export const FE_GRAPHQL_CONTRACT = 'FE_GRAPHQL_CONTRACT';
12
13
 
@@ -30,9 +31,23 @@ function contractsOf(repoRoot, files, findings) {
30
31
  return contracts;
31
32
  }
32
33
 
34
+ function operationFindings(file, document, operation, contracts) {
35
+ const label = operation.name ?? `anonymous ${operation.operation}`;
36
+ const [first] = rootFieldsOf(operation);
37
+ const serving = contracts.filter(({ schema }) => schema.types.get(schema.roots[operation.operation])?.fields?.has(first));
38
+ if (serving.length === 0) {
39
+ const where = contracts.length === 0 ? 'the app keeps no be/contracts/<service>/schema.graphql' : `no contract of ${contracts.map((c) => c.path).join(', ')} declares ${operation.operation} ${first}`;
40
+ return [found(FE_GRAPHQL_CONTRACT, file, `${file}: ${label} asks ${operation.operation} ${first}, but ${where}; the front end sends only what a back end serves.`, { operation: label, service: null })];
41
+ }
42
+ const [contract] = serving;
43
+ const problems = operationProblems(contract.schema, document, operation);
44
+ if (problems.length > 0) return [found(FE_GRAPHQL_CONTRACT, file, `${file}: ${label} does not match the ${contract.service} contract (${contract.path}): ${problems.join('; ')}.`, { operation: label, service: contract.service, problems })];
45
+ return [];
46
+ }
47
+
33
48
  /** The findings of R113 over the tracked paths `files` of the app at `repoRoot`. */
34
49
  export function feContractFindings({ repoRoot, files }) {
35
- const documents = files.filter(isDocument).sort();
50
+ const documents = files.filter(isDocument).sort(byCodeUnit);
36
51
  if (documents.length === 0) return [];
37
52
  const findings = [];
38
53
  const contracts = contractsOf(repoRoot, files, findings);
@@ -46,21 +61,7 @@ export function feContractFindings({ repoRoot, files }) {
46
61
  findings.push(found(FE_GRAPHQL_CONTRACT, file, `${file} is not a GraphQL document: ${error.message}.`));
47
62
  continue;
48
63
  }
49
- for (const operation of document.operations) {
50
- const label = operation.name ?? `anonymous ${operation.operation}`;
51
- const [first] = rootFieldsOf(operation);
52
- const serving = contracts.filter(({ schema }) => schema.types.get(schema.roots[operation.operation])?.fields?.has(first));
53
- if (serving.length === 0) {
54
- const where = contracts.length === 0 ? 'the app keeps no be/contracts/<service>/schema.graphql' : `no contract of ${contracts.map((c) => c.path).join(', ')} declares ${operation.operation} ${first}`;
55
- findings.push(found(FE_GRAPHQL_CONTRACT, file, `${file}: ${label} asks ${operation.operation} ${first}, but ${where}; the front end sends only what a back end serves.`, { operation: label, service: null }));
56
- continue;
57
- }
58
- const [contract] = serving;
59
- const problems = operationProblems(contract.schema, document, operation);
60
- if (problems.length > 0) {
61
- findings.push(found(FE_GRAPHQL_CONTRACT, file, `${file}: ${label} does not match the ${contract.service} contract (${contract.path}): ${problems.join('; ')}.`, { operation: label, service: contract.service, problems }));
62
- }
63
- }
64
+ for (const operation of document.operations) findings.push(...operationFindings(file, document, operation, contracts));
64
65
  }
65
66
  return findings;
66
67
  }
@@ -15,34 +15,54 @@ const TEST_DIRECTORY = /(?:^|\/)(?:e2e|__tests__|__mocks__|test-support)\//;
15
15
  export const TEST_TOOL_FILE = /(?:^|\/)(?:(?:vitest|jest|playwright)\.[^/]+|cypress\.config\.[^/]+|tsconfig\.e2e\.json|e2e\.ya?ml)$/;
16
16
  // The edition rule (rules/edition.mjs, L01) applies the same lists to the whole app tree in lite: they are the test vocabulary.
17
17
  export const TEST_SCRIPT_NAME = /^(?:pre|post)?test(?::|$)/;
18
- export const TEST_RUNNER_COMMAND = /(?:^|[\s&|;(])(?:npx\s+)?(?:vitest|jest|playwright|cypress|mocha)(?=$|[\s&|;)])|\bnode\s+(?:--\S+\s+)*--test\b/;
19
- export const TEST_DEPENDENCY = /^(?:vitest|@vitest\/.+|playwright|playwright-core|@playwright\/.+|jest|@jest\/.+|ts-jest|babel-jest|@types\/jest|jest-[\w-]+|mocha|@types\/mocha|cypress|@testing-library\/.+|jsdom|@types\/jsdom|happy-dom|axe-core|@axe-core\/.+|vitest-axe|@starci\/(?:vitest|jest|playwright)-preset)$/;
18
+ const TEST_RUNNER_COMMAND_SOURCE = String.raw`(?:^|[\s&|;(])(?:npx\s+)?(?:vitest|jest|playwright|cypress|mocha)(?=$|[\s&|;)])|\bnode\s+(?:--\S+\s+)*--test\b`;
19
+ export const TEST_RUNNER_COMMAND = new RegExp(TEST_RUNNER_COMMAND_SOURCE);
20
+ const TEST_DEPENDENCY_SOURCE = String.raw`^(?:vitest|@vitest\/.+|playwright|playwright-core|@playwright\/.+|jest|@jest\/.+|ts-jest|babel-jest|@types\/jest|jest-[\w-]+|mocha|@types\/mocha|cypress|@testing-library\/.+|jsdom|@types\/jsdom|happy-dom|axe-core|@axe-core\/.+|vitest-axe|@starci\/(?:vitest|jest|playwright)-preset)$`;
21
+ export const TEST_DEPENDENCY = new RegExp(TEST_DEPENDENCY_SOURCE);
20
22
  export const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
21
23
 
22
24
  /** True for a path FE_NO_TESTS owns: a spec file, a test directory or a test-tool file. */
23
25
  export const isFeTestPath = (file) => SPEC_FILE.test(file) || TEST_DIRECTORY.test(file) || TEST_TOOL_FILE.test(file);
24
26
 
27
+ function testPathFinding(file) {
28
+ let what = 'test tooling';
29
+ if (SPEC_FILE.test(file)) what = 'a test file';
30
+ else if (TEST_DIRECTORY.test(file)) what = 'inside a test directory';
31
+ return found(FE_NO_TESTS, file, `${file} is ${what}; a front-end repository has no tests, no test tooling and no exception: delete it`);
32
+ }
33
+
34
+ function testDependencyFindings(file, pkg) {
35
+ const findings = [];
36
+ for (const section of DEPENDENCY_SECTIONS) {
37
+ for (const name of Object.keys(pkg[section] ?? {})) {
38
+ if (TEST_DEPENDENCY.test(name)) findings.push(found(FE_NO_TESTS, file, `${file} ${section} names ${name}, a test dependency; a front-end repository has no tests: delete the dependency`));
39
+ }
40
+ }
41
+ return findings;
42
+ }
43
+
44
+ function packageTestFindings(repoRoot, file) {
45
+ if (file !== 'package.json' && !file.endsWith('/package.json')) return [];
46
+ if (file.includes('node_modules/')) return [];
47
+ const pkg = readJson(repoRoot, file);
48
+ if (!pkg || typeof pkg !== 'object') return [];
49
+ const findings = [];
50
+ for (const [name, command] of Object.entries(pkg.scripts ?? {})) {
51
+ if (TEST_SCRIPT_NAME.test(name) || TEST_RUNNER_COMMAND.test(String(command))) findings.push(found(FE_NO_TESTS, file, `${file} script ${name} is a test script (${String(command).slice(0, 80)}); a front-end repository has no tests: delete the script`));
52
+ }
53
+ findings.push(...testDependencyFindings(file, pkg));
54
+ return findings;
55
+ }
56
+
25
57
  /** The findings of R97 over the tracked paths `files` of a front-end repository at `repoRoot`. */
26
58
  export function feNoTestsFindings({ repoRoot, files }) {
27
59
  const findings = [];
28
60
  for (const file of files) {
29
61
  if (isFeTestPath(file)) {
30
- const what = SPEC_FILE.test(file) ? 'a test file' : TEST_DIRECTORY.test(file) ? 'inside a test directory' : 'test tooling';
31
- findings.push(found(FE_NO_TESTS, file, `${file} is ${what}; a front-end repository has no tests, no test tooling and no exception: delete it`));
62
+ findings.push(testPathFinding(file));
32
63
  continue;
33
64
  }
34
- if (file !== 'package.json' && !file.endsWith('/package.json')) continue;
35
- if (file.includes('node_modules/')) continue;
36
- const pkg = readJson(repoRoot, file);
37
- if (!pkg || typeof pkg !== 'object') continue;
38
- for (const [name, command] of Object.entries(pkg.scripts ?? {})) {
39
- if (TEST_SCRIPT_NAME.test(name) || TEST_RUNNER_COMMAND.test(String(command))) findings.push(found(FE_NO_TESTS, file, `${file} script ${name} is a test script (${String(command).slice(0, 80)}); a front-end repository has no tests: delete the script`));
40
- }
41
- for (const section of DEPENDENCY_SECTIONS) {
42
- for (const name of Object.keys(pkg[section] ?? {})) {
43
- if (TEST_DEPENDENCY.test(name)) findings.push(found(FE_NO_TESTS, file, `${file} ${section} names ${name}, a test dependency; a front-end repository has no tests: delete the dependency`));
44
- }
45
- }
65
+ findings.push(...packageTestFindings(repoRoot, file));
46
66
  }
47
67
  return findings;
48
68
  }
@@ -13,6 +13,7 @@
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { found, readJson } from './read.mjs';
16
+ import { byCodeUnit } from '../../lib/list.mjs';
16
17
 
17
18
  const WIRE_GENERATED = 'FE_WIRE_GENERATED';
18
19
  const I18N_PLACEMENT = 'FE_I18N_PLACEMENT';
@@ -77,7 +78,7 @@ function placementFindings({ repoRoot, app, tracked }) {
77
78
  const base = `apps/${app}`;
78
79
  const findings = [];
79
80
  if (!tracked.includes(`${base}/src/proxy.ts`)) findings.push(found(I18N_PLACEMENT, `${base}/src/proxy.ts`, `${app} has no src/proxy.ts; locale routing lives in proxy.ts`, { app }));
80
- for (const file of tracked.filter((f) => new RegExp(`^${base}/src/middleware\.[cm]?[jt]s$`).test(f))) findings.push(found(I18N_PLACEMENT, file, `${file} is a middleware file; Next 16 routes through src/proxy.ts`, { app }));
81
+ for (const file of tracked.filter((f) => new RegExp(`^${base}/src/middleware.[cm]?[jt]s$`).test(f))) findings.push(found(I18N_PLACEMENT, file, `${file} is a middleware file; Next 16 routes through src/proxy.ts`, { app }));
81
82
  const appDir = `${base}/src/app/`;
82
83
  for (const file of tracked.filter((f) => f.startsWith(appDir) && ROUTE_FILE.test(f))) {
83
84
  const rel = file.slice(appDir.length);
@@ -96,7 +97,7 @@ function placementFindings({ repoRoot, app, tracked }) {
96
97
 
97
98
  function catalogFindings({ repoRoot, app, tracked }) {
98
99
  const messages = `apps/${app}/src/modules/i18n/messages/`;
99
- const catalogs = tracked.filter((f) => f.startsWith(messages) && f.endsWith('.json') && f.slice(messages.length).indexOf('/') === -1);
100
+ const catalogs = tracked.filter((f) => f.startsWith(messages) && f.endsWith('.json') && !f.slice(messages.length).includes('/'));
100
101
  const keys = new Map();
101
102
  for (const file of catalogs) {
102
103
  const parsed = readJson(repoRoot, file);
@@ -106,7 +107,7 @@ function catalogFindings({ repoRoot, app, tracked }) {
106
107
  const all = new Set([...keys.values()].flatMap((set) => [...set]));
107
108
  const findings = [];
108
109
  for (const [file, set] of keys) {
109
- const missing = [...all].filter((key) => !set.has(key)).sort();
110
+ const missing = [...all].filter((key) => !set.has(key)).sort(byCodeUnit);
110
111
  if (missing.length) findings.push(found(I18N_CATALOG, file, `${file} lacks ${missing.length} key${missing.length === 1 ? '' : 's'} another locale has (${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', ...' : ''}); every catalog holds the same keys`, { app, missing }));
111
112
  }
112
113
  return findings;
@@ -16,6 +16,7 @@ import path from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { loadTypescript } from '../../lib/package-at.mjs';
18
18
  import { found, readText } from './read.mjs';
19
+ import { byCodeUnit } from '../../lib/list.mjs';
19
20
 
20
21
  const INTEGRATION_SPEC_MISSING = 'BE_INTEGRATION_SPEC_MISSING';
21
22
 
@@ -70,6 +71,17 @@ function chainOf(ts, expression) {
70
71
  return ts.isIdentifier(current) ? [current.text, ...names] : null;
71
72
  }
72
73
 
74
+ /** The initializer of `const <name> = ...` at the top level of `file`. */
75
+ function topLevelConst(ts, file, name) {
76
+ for (const statement of file.sourceFile.statements) {
77
+ if (!ts.isVariableStatement(statement)) continue;
78
+ for (const declaration of statement.declarationList.declarations) {
79
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === name && declaration.initializer) return declaration.initializer;
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
73
85
  /**
74
86
  * The judge of one app: resolves an import of a parsed file to a repository-relative path, finds the top-level const of a file,
75
87
  * and reads files once.
@@ -87,17 +99,7 @@ function judgeOf(ts, repoRoot, side) {
87
99
  const rel = path.relative(repoRoot, resolved).split(path.sep).join('/');
88
100
  return rel.startsWith('..') ? null : rel;
89
101
  };
90
- /** The initializer of `const <name> = ...` at the top level of `file`. */
91
- const constOf = (file, name) => {
92
- for (const statement of file.sourceFile.statements) {
93
- if (!ts.isVariableStatement(statement)) continue;
94
- for (const declaration of statement.declarationList.declarations) {
95
- if (ts.isIdentifier(declaration.name) && declaration.name.text === name && declaration.initializer) return declaration.initializer;
96
- }
97
- }
98
- return null;
99
- };
100
- return { parsed, resolve, constOf };
102
+ return { parsed, resolve, constOf: (file, name) => topLevelConst(ts, file, name) };
101
103
  }
102
104
 
103
105
  /** Whether `rel` lies in the integration folder `folder` (its index or any file of it). */
@@ -134,53 +136,85 @@ function registersIntegration(ts, judge, element, folder) {
134
136
  }
135
137
 
136
138
  /** Whether `name`, exported by the module `rel`, is declared as an enum there or in the file it is re-exported from. */
139
+ function enumExportResult(ts, judge, rel, name, depth, statement) {
140
+ if (ts.isEnumDeclaration(statement) && statement.name.text === name) return true;
141
+ if (!ts.isExportDeclaration(statement) || !statement.exportClause || !ts.isNamedExports(statement.exportClause)) return null;
142
+ for (const element of statement.exportClause.elements) {
143
+ if (element.name.text !== name) continue;
144
+ const original = (element.propertyName ?? element.name).text;
145
+ if (statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) return exportsEnum(ts, judge, judge.resolve(rel, statement.moduleSpecifier.text), original, depth + 1);
146
+ return exportsEnum(ts, judge, rel, original, depth + 1);
147
+ }
148
+ return null;
149
+ }
150
+
137
151
  function exportsEnum(ts, judge, rel, name, depth = 0) {
138
152
  const file = rel ? judge.parsed(rel) : null;
139
153
  if (!file || depth > 4) return false;
140
154
  for (const statement of file.sourceFile.statements) {
141
- if (ts.isEnumDeclaration(statement) && statement.name.text === name) return true;
142
- if (!ts.isExportDeclaration(statement) || !statement.exportClause || !ts.isNamedExports(statement.exportClause)) continue;
143
- for (const element of statement.exportClause.elements) {
144
- if (element.name.text !== name) continue;
145
- const original = (element.propertyName ?? element.name).text;
146
- if (statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) return exportsEnum(ts, judge, judge.resolve(rel, statement.moduleSpecifier.text), original, depth + 1);
147
- return exportsEnum(ts, judge, rel, original, depth + 1);
148
- }
155
+ const result = enumExportResult(ts, judge, rel, name, depth, statement);
156
+ if (result !== null) return result;
149
157
  }
150
158
  return false;
151
159
  }
152
160
 
153
- /** What one spec proves about the integration in `folder`: { modules, errorCode, outage }. */
154
- function judgeSpec(ts, judge, rel, folder) {
155
- const spec = judge.parsed(rel);
156
- const result = { modules: false, errorCode: false, outage: false };
157
- if (!spec) return result;
158
- const worlds = new Set();
161
+ /** The `modules` property of a `useTestWorld({ modules })` call that `node` is, or null. */
162
+ function worldModulesProperty(ts, spec, node) {
163
+ if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression) || node.expression.text !== 'useTestWorld' || !spec.imports.has('useTestWorld')) return null;
164
+ const [argument] = node.arguments;
165
+ if (!argument || !ts.isObjectLiteralExpression(argument)) return null;
166
+ return argument.properties.find((property) => ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === 'modules') ?? null;
167
+ }
168
+
169
+ /** Whether a `useTestWorld` call of `spec` registers the integration; the variables holding a world join `worlds`. */
170
+ function registersInWorld(ts, judge, spec, folder, worlds) {
171
+ let proven = false;
159
172
  walk(ts, spec.sourceFile, (node) => {
160
- if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression) || node.expression.text !== 'useTestWorld' || !spec.imports.has('useTestWorld')) return;
161
- const [argument] = node.arguments;
162
- if (!argument || !ts.isObjectLiteralExpression(argument)) return;
163
- const modules = argument.properties.find((property) => ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === 'modules');
173
+ const modules = worldModulesProperty(ts, spec, node);
164
174
  if (!modules) return;
165
175
  if (ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name)) worlds.add(node.parent.name.text);
166
- if (elementsOf(ts, judge, spec, modules.initializer).some((element) => registersIntegration(ts, judge, element, folder))) result.modules = true;
176
+ if (elementsOf(ts, judge, spec, modules.initializer).some((element) => registersIntegration(ts, judge, element, folder))) proven = true;
167
177
  });
168
- const enumBindings = new Set(
178
+ return proven;
179
+ }
180
+
181
+ /** The local names of the enums the spec imports from the integration folder. */
182
+ function enumBindingsOf(ts, judge, spec, folder) {
183
+ return new Set(
169
184
  [...spec.imports].filter(([, binding]) => {
170
- const target = judge.resolve(rel, binding.source);
185
+ const target = judge.resolve(spec.rel, binding.source);
171
186
  return insideFolder(target, folder) && exportsEnum(ts, judge, target, binding.imported);
172
187
  }).map(([local]) => local),
173
188
  );
189
+ }
190
+
191
+ /** Whether the property access `node` of the world chain `chain` is a call of an outage verb. */
192
+ function isOutageCall(ts, node, chain) {
193
+ const [, area, name, call] = chain;
194
+ if (!(ts.isCallExpression(node.parent) && node.parent.expression === node)) return false;
195
+ return (area === 'infra' && name !== undefined && INFRA_OUTAGES.has(call))
196
+ || area === 'interruptDatabase'
197
+ || (OUTAGE_CALLS[area] !== undefined && name !== undefined && call === OUTAGE_CALLS[area]);
198
+ }
199
+
200
+ /** Whether `node` reaches an outage through a world variable of `worlds`. */
201
+ function drivesOutage(ts, node, worlds) {
202
+ if (!ts.isPropertyAccessExpression(node)) return false;
203
+ const chain = chainOf(ts, node);
204
+ return Boolean(chain) && worlds.has(chain[0]) && isOutageCall(ts, node, chain);
205
+ }
206
+
207
+ /** What one spec proves about the integration in `folder`: { modules, errorCode, outage }. */
208
+ function judgeSpec(ts, judge, rel, folder) {
209
+ const spec = judge.parsed(rel);
210
+ const result = { modules: false, errorCode: false, outage: false };
211
+ if (!spec) return result;
212
+ const worlds = new Set();
213
+ result.modules = registersInWorld(ts, judge, spec, folder, worlds);
214
+ const enumBindings = enumBindingsOf(ts, judge, spec, folder);
174
215
  walk(ts, spec.sourceFile, (node) => {
175
216
  if (ts.isIdentifier(node) && enumBindings.has(node.text) && !ts.isImportSpecifier(node.parent)) result.errorCode = true;
176
- if (!ts.isPropertyAccessExpression(node)) return;
177
- const chain = chainOf(ts, node);
178
- if (!chain || !worlds.has(chain[0])) return;
179
- const [, area, name, call] = chain;
180
- const called = ts.isCallExpression(node.parent) && node.parent.expression === node;
181
- if (area === 'infra' && name !== undefined && INFRA_OUTAGES.has(call) && called) result.outage = true;
182
- else if (area === 'interruptDatabase' && called) result.outage = true;
183
- else if (OUTAGE_CALLS[area] !== undefined && name !== undefined && call === OUTAGE_CALLS[area] && called) result.outage = true;
217
+ if (drivesOutage(ts, node, worlds)) result.outage = true;
184
218
  });
185
219
  return result;
186
220
  }
@@ -199,7 +233,7 @@ export function integrationSpecFindings({ repoRoot, files }) {
199
233
  const findings = [];
200
234
  for (const { side, provider, folder } of integrations) {
201
235
  const specDir = `${side}src/tests/integration/${provider}/`;
202
- const specs = files.filter((file) => file.startsWith(specDir) && file.endsWith(SPEC_SUFFIX) && !file.slice(specDir.length).includes('/')).sort();
236
+ const specs = files.filter((file) => file.startsWith(specDir) && file.endsWith(SPEC_SUFFIX) && !file.slice(specDir.length).includes('/')).sort(byCodeUnit);
203
237
  if (specs.length === 0) {
204
238
  findings.push(found(INTEGRATION_SPEC_MISSING, `${folder}/`, `${folder}/ is an integration with no integration spec: add ${specDir}<name>${SPEC_SUFFIX} that ${MISSING_PARTS.modules}, ${MISSING_PARTS.errorCode}, and ${MISSING_PARTS.outage}.`, { provider, missing: ['spec'] }));
205
239
  continue;
@@ -8,6 +8,7 @@
8
8
  // `features/cli/` is the one cli feature root (its module and group folders), so it is in use as soon as it holds a file.
9
9
  import { loadSlotManifest, ruleParams } from '../slots.mjs';
10
10
  import { found } from './read.mjs';
11
+ import { byCodeUnit } from '../../lib/list.mjs';
11
12
 
12
13
  const KIND_DECLARATION = 'BE_KIND_DECLARATION';
13
14
  const KIND_EMPTY = 'BE_KIND_EMPTY';
@@ -32,23 +33,22 @@ const facts = () => {
32
33
  return memo;
33
34
  };
34
35
 
35
- /** Findings of BE_KIND_DECLARATION and BE_KIND_EMPTY. */
36
- export function kindFindings({ files, repo }) {
37
- const be = repo.sides?.be;
38
- if (!be) return [];
39
- const { triggerKinds, kindPatterns, platform } = facts();
40
- const kindFolders = new Set(triggerKinds);
36
+ /** The path parts below the features root of a file inside a known kind folder, else null. */
37
+ function kindPathParts(file, kindFolders) {
38
+ if (!file.startsWith(FEATURES)) return null;
39
+ const rest = file.slice(FEATURES.length).split('/');
40
+ return rest.length < 2 || !kindFolders.has(rest[0]) ? null : rest;
41
+ }
42
+
43
+ function kindUsageOf(files, kindFolders) {
41
44
  const findings = [];
42
45
  const instances = new Map();
43
46
  const sources = new Map();
44
47
  const roots = new Set();
45
- const tracked = new Set(files);
46
48
  for (const file of files) {
47
- if (!file.startsWith(FEATURES)) continue;
48
- const rest = file.slice(FEATURES.length).split('/');
49
- if (rest.length < 2) continue;
49
+ const rest = kindPathParts(file, kindFolders);
50
+ if (!rest) continue;
50
51
  const [first, second] = rest;
51
- if (!kindFolders.has(first)) continue;
52
52
  if (first === CLI) { roots.add(CLI); continue; }
53
53
  if (rest.length === 2) {
54
54
  findings.push(found(KIND_EMPTY, file, `${file} sits directly in the kind folder ${FEATURES}${first}/, which holds only instance folders (${first}/<name>/); an empty kind folder is never kept: run \`starci app add\` to create the first member.`, { kind: first }));
@@ -59,26 +59,60 @@ export function kindFindings({ files, repo }) {
59
59
  const key = `${first}/${second}`;
60
60
  sources.set(key, (sources.get(key) ?? 0) + (/\.[cm]?tsx?$/.test(file) ? 1 : 0));
61
61
  }
62
+ return { findings, instances, sources, roots };
63
+ }
64
+
65
+ function emptyInstanceFindings(sources) {
66
+ const findings = [];
62
67
  for (const [key, count] of sources) {
63
68
  if (count === 0) findings.push(found(KIND_EMPTY, `${FEATURES}${key}/`, `${FEATURES}${key}/ holds no TypeScript source: a kind member without code is an empty folder; add its files with \`starci app add\` or remove the folder.`, { kind: key.split('/')[0] }));
64
69
  }
65
- const present = new Set([...instances.keys(), ...roots]);
66
- const declared = new Set(be.kinds ?? []);
67
- const patterns = new Set(be.patterns ?? []);
68
- for (const kind of [...present].sort()) {
70
+ return findings;
71
+ }
72
+
73
+ function undeclaredKindFindings(present, declared) {
74
+ const findings = [];
75
+ for (const kind of [...present].sort(byCodeUnit)) {
69
76
  if (!declared.has(kind)) findings.push(found(KIND_DECLARATION, 'hfs.json', `hfs.json sides.be.kinds does not declare the kind \`${kind}\`, which ${FEATURES}${kind}/ uses; declare every kind in use (\`starci app add\` registers it).`, { kind }));
70
77
  }
71
- for (const kind of [...declared].sort()) {
72
- if (!present.has(kind)) {
73
- findings.push(found(KIND_DECLARATION, 'hfs.json', `hfs.json sides.be.kinds declares \`${kind}\` but no feature of that kind exists; a kind is declared only when it is used, so remove it or add its first member with \`starci app add\`.`, { kind }));
74
- continue;
75
- }
76
- for (const pattern of kindPatterns[kind] ?? []) {
77
- if (!patterns.has(pattern)) findings.push(found(KIND_DECLARATION, 'hfs.json', `the kind \`${kind}\` needs the pattern \`${pattern}\`, which hfs.json sides.be.patterns does not declare.`, { kind, pattern }));
78
- for (const capability of platform.get(pattern) ?? []) {
79
- if (!tracked.has(`be/src/modules/platform/${capability}/index.ts`)) findings.push(found(KIND_DECLARATION, `be/src/modules/platform/${capability}/`, `the kind \`${kind}\` needs the platform capability \`${capability}\` (pattern ${pattern}), which is not tracked at be/src/modules/platform/${capability}/; \`starci app add\` generates it with the first member of the kind.`, { kind, pattern, capability }));
80
- }
78
+ return findings;
79
+ }
80
+
81
+ function kindPatternFindings(kind, { kindPatterns, patterns, platform, tracked }) {
82
+ const findings = [];
83
+ for (const pattern of kindPatterns[kind] ?? []) {
84
+ if (!patterns.has(pattern)) findings.push(found(KIND_DECLARATION, 'hfs.json', `the kind \`${kind}\` needs the pattern \`${pattern}\`, which hfs.json sides.be.patterns does not declare.`, { kind, pattern }));
85
+ for (const capability of platform.get(pattern) ?? []) {
86
+ if (!tracked.has(`be/src/modules/platform/${capability}/index.ts`)) findings.push(found(KIND_DECLARATION, `be/src/modules/platform/${capability}/`, `the kind \`${kind}\` needs the platform capability \`${capability}\` (pattern ${pattern}), which is not tracked at be/src/modules/platform/${capability}/; \`starci app add\` generates it with the first member of the kind.`, { kind, pattern, capability }));
81
87
  }
82
88
  }
83
89
  return findings;
84
90
  }
91
+
92
+ function declaredKindFindings({ declared, present, kindPatterns, patterns, platform, tracked }) {
93
+ const findings = [];
94
+ for (const kind of [...declared].sort(byCodeUnit)) {
95
+ if (present.has(kind)) findings.push(...kindPatternFindings(kind, { kindPatterns, patterns, platform, tracked }));
96
+ else findings.push(found(KIND_DECLARATION, 'hfs.json', `hfs.json sides.be.kinds declares \`${kind}\` but no feature of that kind exists; a kind is declared only when it is used, so remove it or add its first member with \`starci app add\`.`, { kind }));
97
+ }
98
+ return findings;
99
+ }
100
+
101
+ /** Findings of BE_KIND_DECLARATION and BE_KIND_EMPTY. */
102
+ export function kindFindings({ files, repo }) {
103
+ const be = repo.sides?.be;
104
+ if (!be) return [];
105
+ const { triggerKinds, kindPatterns, platform } = facts();
106
+ const kindFolders = new Set(triggerKinds);
107
+ const tracked = new Set(files);
108
+ const usage = kindUsageOf(files, kindFolders);
109
+ const findings = usage.findings;
110
+ const instances = usage.instances;
111
+ const roots = usage.roots;
112
+ findings.push(...emptyInstanceFindings(usage.sources));
113
+ const present = new Set([...instances.keys(), ...roots]);
114
+ const declared = new Set(be.kinds ?? []);
115
+ const patterns = new Set(be.patterns ?? []);
116
+ findings.push(...undeclaredKindFindings(present, declared), ...declaredKindFindings({ declared, present, kindPatterns, patterns, platform, tracked }));
117
+ return findings;
118
+ }