@starci/hfs 1.0.1 → 2.0.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 (246) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +110 -12
  3. package/bin/hfs.mjs +119 -15
  4. package/emit/compiler.mjs +35 -0
  5. package/emit/contracts.mjs +97 -0
  6. package/emit/operations-worker.mjs +24 -0
  7. package/emit/operations.mjs +126 -0
  8. package/emit/schema-worker.mjs +117 -0
  9. package/emit/static-graph.mjs +670 -0
  10. package/emit/type-schema.mjs +145 -0
  11. package/package.json +4 -1
  12. package/report/sonar.mjs +180 -0
  13. package/runtime/engine/admission.mjs +284 -0
  14. package/runtime/engine/digest.mjs +10 -0
  15. package/runtime/engine/ledger-db.mjs +1245 -0
  16. package/runtime/engine/machine-db.mjs +1484 -0
  17. package/runtime/engine/migrations/machine/0001-init.sql +887 -0
  18. package/runtime/engine/migrations/runtime/0001-init.sql +1072 -0
  19. package/runtime/engine/migrations/runtime/0003-usage-unavailable.sql +13 -0
  20. package/runtime/engine/migrations/runtime/0004-attempt-why.sql +43 -0
  21. package/runtime/engine/plain-object.mjs +5 -0
  22. package/runtime/knowledge/hfs/canon-pins.yaml +16 -58
  23. package/runtime/knowledge/hfs/slots.yaml +405 -137
  24. package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
  25. package/runtime/knowledge/sonar-gate.yaml +85 -0
  26. package/runtime/modules/kernel/failure-codes.yaml +1480 -16
  27. package/runtime/scripts/checks/architecture/backend.mjs +350 -0
  28. package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
  29. package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
  30. package/runtime/scripts/checks/architecture/clones.mjs +200 -0
  31. package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
  32. package/runtime/scripts/checks/architecture/config.mjs +310 -0
  33. package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
  34. package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
  35. package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
  36. package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
  37. package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
  38. package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
  39. package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
  40. package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
  41. package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
  42. package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
  43. package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
  44. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
  45. package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
  46. package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
  47. package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
  48. package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
  49. package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
  50. package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
  51. package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
  52. package/runtime/scripts/checks/architecture/index.mjs +316 -0
  53. package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
  54. package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
  55. package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
  56. package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
  57. package/runtime/scripts/checks/architecture/owners.mjs +89 -0
  58. package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
  59. package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
  60. package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
  61. package/runtime/scripts/checks/architecture/registration.mjs +319 -0
  62. package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
  63. package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
  64. package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
  65. package/runtime/scripts/checks/architecture/size-growth.mjs +73 -0
  66. package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
  67. package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
  68. package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
  69. package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
  70. package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
  71. package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
  72. package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
  73. package/runtime/scripts/checks/architecture/typescript.mjs +500 -0
  74. package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
  75. package/runtime/scripts/checks/architecture.mjs +41 -0
  76. package/runtime/scripts/checks/common.mjs +37 -0
  77. package/runtime/scripts/checks/typescript-programs.mjs +82 -0
  78. package/runtime/scripts/lib/artifact-hold.mjs +89 -0
  79. package/runtime/scripts/lib/artifact-store.mjs +103 -0
  80. package/runtime/scripts/lib/fs-kind.mjs +10 -0
  81. package/runtime/scripts/lib/git.mjs +53 -0
  82. package/runtime/scripts/lib/hfs-allows.mjs +57 -0
  83. package/runtime/scripts/lib/hfs-check.mjs +254 -28
  84. package/runtime/scripts/lib/hfs-rules/contract.mjs +126 -0
  85. package/runtime/scripts/lib/hfs-rules/deps.mjs +63 -0
  86. package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
  87. package/runtime/scripts/lib/hfs-rules/frontend.mjs +124 -0
  88. package/runtime/scripts/lib/hfs-rules/lint-suppression.mjs +34 -0
  89. package/runtime/scripts/lib/hfs-rules/pipeline.mjs +51 -0
  90. package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +64 -0
  91. package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
  92. package/runtime/scripts/lib/hfs-rules/repo-local-checks.mjs +32 -0
  93. package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
  94. package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +31 -0
  95. package/runtime/scripts/lib/hfs-rules/stacks.mjs +54 -0
  96. package/runtime/scripts/lib/hfs-rules/test-topology.mjs +31 -0
  97. package/runtime/scripts/lib/hfs-slots.mjs +88 -40
  98. package/runtime/scripts/lib/hfs-tree.mjs +80 -0
  99. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  100. package/runtime/scripts/lib/json.mjs +22 -0
  101. package/runtime/scripts/lib/language.mjs +107 -0
  102. package/runtime/scripts/lib/path-key.mjs +2 -0
  103. package/runtime/scripts/lib/redact.mjs +148 -0
  104. package/runtime/scripts/lib/repo-identity.mjs +50 -0
  105. package/runtime/scripts/lib/safe-remove.mjs +179 -0
  106. package/runtime/scripts/lib/secret-patterns.mjs +44 -0
  107. package/runtime/scripts/lib/sleep-sync.mjs +17 -0
  108. package/runtime/scripts/lib/stack-declaration.mjs +52 -0
  109. package/runtime/scripts/lib/stack-services.mjs +217 -0
  110. package/runtime/scripts/lib/test-secrets.mjs +120 -0
  111. package/scaffold/service.mjs +333 -0
  112. package/sync/format.mjs +46 -0
  113. package/sync/hygiene.mjs +56 -24
  114. package/sync/index.mjs +126 -41
  115. package/sync/managed.mjs +170 -0
  116. package/sync/skeleton.mjs +32 -10
  117. package/sync/sonar-key.mjs +13 -0
  118. package/sync/ts-strict.mjs +48 -0
  119. package/templates/{common → be/ci-workflows/github/workflows}/ci.yml +13 -13
  120. package/templates/be/{e2e.yml → ci-workflows/github/workflows/e2e.yml} +1 -0
  121. package/templates/be/hooks/husky/pre-commit +13 -0
  122. package/templates/be/hooks/husky/pre-push +7 -0
  123. package/templates/be/package-scripts/package.json +21 -0
  124. package/templates/be/{sonar-project.properties → quality-config/sonar-project.properties} +2 -3
  125. package/templates/be/skeleton/apps/__app__/src/app.module.ts +14 -4
  126. package/templates/be/skeleton/apps/__app__/src/main.ts +9 -6
  127. package/templates/be/skeleton/scripts/.gitkeep +0 -0
  128. package/templates/be/skeleton/src/features/system-health/application/check-liveness.contracts.ts +2 -0
  129. package/templates/be/skeleton/src/features/system-health/application/check-liveness.handler.ts +22 -0
  130. package/templates/be/skeleton/src/features/system-health/application/check-liveness.query.ts +11 -0
  131. package/templates/be/skeleton/src/features/system-health/index.ts +1 -1
  132. package/templates/be/skeleton/src/features/system-health/system-health.module.ts +3 -3
  133. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +8 -9
  134. package/templates/be/skeleton/src/features/system-health/transport/http/system-health-http.module.ts +3 -6
  135. package/templates/be/skeleton/src/modules/domain/liveness/index.ts +3 -0
  136. package/templates/be/skeleton/src/modules/domain/liveness/liveness.contracts.ts +9 -0
  137. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +7 -0
  138. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module.ts +14 -0
  139. package/templates/be/skeleton/src/modules/domain/liveness/liveness.options.ts +2 -0
  140. package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.spec.ts +40 -0
  141. package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.ts +26 -0
  142. package/templates/be/skeleton/src/modules/platform/clock/clock.decorators.ts +9 -0
  143. package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +7 -0
  144. package/templates/be/skeleton/src/modules/platform/clock/clock.module.ts +19 -0
  145. package/templates/be/skeleton/src/modules/platform/clock/clock.options.ts +2 -0
  146. package/templates/be/skeleton/src/modules/platform/clock/clock.port.ts +5 -0
  147. package/templates/be/skeleton/src/modules/platform/clock/index.ts +4 -0
  148. package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.spec.ts +20 -0
  149. package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.ts +9 -0
  150. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -0
  151. package/templates/be/skeleton/src/modules/platform/composition/index.ts +2 -0
  152. package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +9 -0
  153. package/templates/be/skeleton/src/modules/platform/config/index.ts +2 -3
  154. package/templates/be/skeleton/src/modules/platform/config/server.config.ts +1 -1
  155. package/templates/be/skeleton/src/modules/platform/config/server.options.ts +0 -3
  156. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +5 -0
  157. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.decorators.ts +9 -0
  158. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.handler.ts +27 -0
  159. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.log-events.ts +5 -0
  160. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +7 -0
  161. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module.ts +18 -0
  162. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.options.ts +2 -0
  163. package/templates/be/skeleton/src/modules/platform/cqrs/index.ts +4 -0
  164. package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +8 -12
  165. package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +5 -0
  166. package/templates/be/skeleton/src/modules/platform/errors/index.ts +1 -1
  167. package/templates/be/skeleton/src/modules/platform/logging/index.ts +4 -4
  168. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +99 -0
  169. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +38 -0
  170. package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +9 -0
  171. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +7 -0
  172. package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +7 -0
  173. package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +23 -10
  174. package/templates/be/skeleton/src/modules/platform/logging/logging.options.ts +2 -0
  175. package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +15 -0
  176. package/templates/be/tool-config/eslint.config.mjs +3 -0
  177. package/templates/be/tool-config/jest.config.js +1 -0
  178. package/templates/be/tool-config/prettierignore +8 -0
  179. package/templates/be/tool-config/prettierrc +1 -0
  180. package/templates/be/tool-config/src/tests/tsconfig.json +5 -0
  181. package/templates/be/tool-config/tsconfig.build.json +5 -0
  182. package/templates/be/tool-config/tsconfig.json +11 -0
  183. package/templates/common/gitignore.base +1 -1
  184. package/templates/fe/ci-workflows/github/workflows/ci.yml +54 -0
  185. package/templates/fe/hooks/husky/pre-commit +16 -0
  186. package/templates/fe/hooks/husky/pre-push +6 -0
  187. package/templates/fe/package-scripts/package.json +17 -0
  188. package/templates/fe/parts/api-client.ts +44 -0
  189. package/templates/fe/parts/api-outcome.ts +7 -0
  190. package/templates/fe/quality-config/sonar-project.properties +8 -0
  191. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +1 -1
  192. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +1 -1
  193. package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +1 -1
  194. package/templates/fe/skeleton/scripts/.gitkeep +0 -0
  195. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +1 -0
  196. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +3 -0
  197. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +1 -0
  198. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +4 -0
  199. package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +12 -0
  200. package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +2 -0
  201. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +9 -0
  202. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +5 -0
  203. package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +5 -0
  204. package/templates/fe/skeleton-shared/packages/__family__-api/package.json +12 -0
  205. package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +1 -0
  206. package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +3 -0
  207. package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +1 -0
  208. package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +5 -0
  209. package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +18 -0
  210. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +19 -0
  211. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +2 -0
  212. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +12 -0
  213. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +15 -0
  214. package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +5 -0
  215. package/templates/fe/tool-config/eslint.config.mjs +3 -0
  216. package/templates/fe/tool-config/prettierignore +10 -0
  217. package/templates/fe/tool-config/prettierrc +1 -0
  218. package/templates/fe/tool-config/stylelint.config.mjs +3 -0
  219. package/templates/fe/tool-config/tsconfig.json +4 -0
  220. package/templates/be/pre-commit +0 -8
  221. package/templates/be/skeleton/apps/__app__/src/__app__.composition.spec.ts +0 -31
  222. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.spec.ts +0 -13
  223. package/templates/be/skeleton/src/modules/platform/config/env-source.spec.ts +0 -36
  224. package/templates/be/skeleton/src/modules/platform/config/server.config.spec.ts +0 -19
  225. package/templates/be/skeleton/src/modules/platform/errors/domain-error.spec.ts +0 -15
  226. package/templates/be/skeleton/src/modules/platform/errors/error.filter.spec.ts +0 -38
  227. package/templates/be/skeleton/src/modules/platform/logging/json-logger.spec.ts +0 -33
  228. package/templates/be/skeleton/src/modules/platform/logging/json-logger.ts +0 -35
  229. package/templates/be/skeleton/src/modules/platform/logging/log-id.ts +0 -9
  230. package/templates/be/skeleton/src/modules/platform/logging/logger.port.ts +0 -19
  231. package/templates/common/codecov.yml +0 -13
  232. package/templates/common/pre-push +0 -5
  233. package/templates/fe/e2e.yml +0 -22
  234. package/templates/fe/pre-commit +0 -7
  235. package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.spec.ts +0 -10
  236. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages.spec.ts +0 -27
  237. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.spec.ts +0 -10
  238. package/templates/fe/sonar-project.properties +0 -11
  239. /package/templates/be/skeleton/src/modules/platform/config/{env-source.ts → env-source.config.ts} +0 -0
  240. /package/templates/be/skeleton/src/modules/platform/errors/{domain-error.ts → domain.error.ts} +0 -0
  241. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/next.config.ts +0 -0
  242. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/config.ts +0 -0
  243. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/navigation.ts +0 -0
  244. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/request.ts +0 -0
  245. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/routing.ts +0 -0
  246. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/proxy.ts +0 -0
@@ -0,0 +1,142 @@
1
+ import path from 'node:path';
2
+ import { analyzeSql, HOLE } from './sql-tokens.mjs';
3
+ import { machineKit } from './machine-ast.mjs';
4
+
5
+ /**
6
+ * R86 `sql-owner` (BE_SQL_TABLE_OWNER). Every `sql` tagged template (the tag declared in platform/database) of a
7
+ * `<name>.sql.ts` file in a capability's `persistence/` is read with the tokenizer of sql-tokens.mjs and judged against the
8
+ * entities of the whole program:
9
+ * - it writes (INSERT INTO, UPDATE, DELETE FROM, MERGE INTO, TRUNCATE) only tables the file's own capability declares
10
+ * with `@Entity("<table>")` in its persistence;
11
+ * - it reads (FROM, JOIN, USING) only tables of capabilities its owner may import (the tier matrix, through
12
+ * `resolver.importAllowed` to the table owner's public entry) or its own;
13
+ * - every table it names is declared by some entity;
14
+ * - a SELECT carries LIMIT, or constrains the primary key or a unique key of its first table with `=`, or selects only
15
+ * aggregates without GROUP BY.
16
+ * Table positions that hold a `${...}` substitution (SqlIdent) are dynamic; they are counted in the coverage and judged by
17
+ * nobody. Joined tables are not part of the bound: a to-many join under a key-constrained first table is a review point.
18
+ */
19
+ export const SQL_OWNER_RULE_IDS = ['BE_SQL_TABLE_OWNER'];
20
+
21
+ const RULE = 'BE_SQL_TABLE_OWNER';
22
+ const TYPEORM = 'typeorm';
23
+
24
+ const literalStrings = (ts, node) => {
25
+ if (!node) return null;
26
+ if (ts.isStringLiteralLike(node)) return [node.text];
27
+ if (ts.isArrayLiteralExpression(node) && node.elements.every(item => ts.isStringLiteralLike(item))) return node.elements.map(item => item.text);
28
+ return null;
29
+ };
30
+
31
+ function entitiesOf(kit, graph) {
32
+ const { ts } = kit;
33
+ const tables = new Map();
34
+ let unreadable = 0;
35
+ for (const file of graph.files.values()) {
36
+ if (file.slot !== 'be.persistence' || !path.posix.basename(file.rel).endsWith('.entity.ts') || !file.owner) continue;
37
+ const checker = kit.checkerOf(file.sourceFile);
38
+ const typeorm = (node, ...names) => {
39
+ const call = ts.isCallExpression(node.expression) ? node.expression.expression : node.expression;
40
+ const binding = kit.importBinding(checker, call);
41
+ return binding?.module === TYPEORM && names.includes(binding.name);
42
+ };
43
+ for (const statement of file.sourceFile.statements) {
44
+ if (!ts.isClassDeclaration(statement)) continue;
45
+ const entity = kit.decorators(statement).find(decorator => typeorm(decorator, 'Entity'));
46
+ if (!entity) continue;
47
+ const argument = ts.isCallExpression(entity.expression) ? entity.expression.arguments[0] : null;
48
+ let table = argument && ts.isStringLiteralLike(argument) ? argument.text : null;
49
+ if (table === null && argument && ts.isObjectLiteralExpression(argument)) {
50
+ const property = kit.propertyOf(argument, 'name');
51
+ if (property && ts.isStringLiteralLike(kit.valueOfProperty(property))) table = kit.valueOfProperty(property).text;
52
+ }
53
+ if (table === null) { unreadable += 1; continue; }
54
+ const dbName = new Map();
55
+ const primary = [];
56
+ const sets = [];
57
+ for (const member of statement.members) {
58
+ if (!ts.isPropertyDeclaration(member) || !member.name || !ts.isIdentifier(member.name)) continue;
59
+ const property = member.name.text;
60
+ let column = property;
61
+ for (const decorator of kit.decorators(member)) {
62
+ const args = ts.isCallExpression(decorator.expression) ? decorator.expression.arguments : [];
63
+ for (const item of args) if (ts.isObjectLiteralExpression(item)) {
64
+ const named = kit.propertyOf(item, 'name');
65
+ if (named && ts.isStringLiteralLike(kit.valueOfProperty(named))) column = kit.valueOfProperty(named).text;
66
+ }
67
+ }
68
+ dbName.set(property, column.toLowerCase());
69
+ for (const decorator of kit.decorators(member)) {
70
+ const args = ts.isCallExpression(decorator.expression) ? decorator.expression.arguments : [];
71
+ const uniqueOption = args.some(item => ts.isObjectLiteralExpression(item) && kit.propertyOf(item, 'unique')
72
+ && kit.valueOfProperty(kit.propertyOf(item, 'unique')).kind === ts.SyntaxKind.TrueKeyword);
73
+ if (typeorm(decorator, 'PrimaryColumn', 'PrimaryGeneratedColumn')) primary.push(column.toLowerCase());
74
+ else if (typeorm(decorator, 'Column', 'Index') && uniqueOption) sets.push([column.toLowerCase()]);
75
+ }
76
+ }
77
+ if (primary.length) sets.push(primary);
78
+ for (const decorator of kit.decorators(statement)) {
79
+ const args = ts.isCallExpression(decorator.expression) ? decorator.expression.arguments : [];
80
+ const list = args.find(item => ts.isArrayLiteralExpression(item) && literalStrings(ts, item));
81
+ const columns = list ? literalStrings(ts, list) : null;
82
+ const unique = typeorm(decorator, 'Unique') || (typeorm(decorator, 'Index') && args.some(item => ts.isObjectLiteralExpression(item) && kit.propertyOf(item, 'unique')
83
+ && kit.valueOfProperty(kit.propertyOf(item, 'unique')).kind === ts.SyntaxKind.TrueKeyword));
84
+ if (unique && columns) sets.push(columns.map(column => (dbName.get(column) ?? column).toLowerCase()));
85
+ }
86
+ const key = table.toLowerCase();
87
+ if (!tables.has(key)) tables.set(key, { table, owner: file.owner.root, rel: file.rel, uniqueSets: sets });
88
+ }
89
+ }
90
+ return { tables, unreadable };
91
+ }
92
+
93
+ export function checkSqlOwner(input) {
94
+ const { graph } = input;
95
+ const kit = machineKit(input);
96
+ const { ts, resolver } = kit;
97
+ const { tables, unreadable } = entitiesOf(kit, graph);
98
+ const violations = [];
99
+ let templates = 0;
100
+ let dynamic = 0;
101
+ let foreignTags = 0;
102
+ for (const file of graph.files.values()) {
103
+ if (file.slot !== 'be.persistence' || !path.posix.basename(file.rel).endsWith('.sql.ts') || !file.owner) continue;
104
+ const checker = kit.checkerOf(file.sourceFile);
105
+ kit.walk(file.sourceFile, node => {
106
+ if (!ts.isTaggedTemplateExpression(node)) return true;
107
+ const home = kit.declarationsOf(checker, node.tag).map(kit.ownerOfDeclaration).find(Boolean);
108
+ if (!home || home.tier !== 'platform' || home.name !== 'database') { foreignTags += 1; return true; }
109
+ templates += 1;
110
+ const template = node.template;
111
+ const text = ts.isNoSubstitutionTemplateLiteral(template) ? template.text
112
+ : template.head.text + template.templateSpans.map(span => HOLE + span.literal.text).join('');
113
+ const seen = new Set();
114
+ const report = (message, extra = {}) => {
115
+ const key = `${message}`;
116
+ if (seen.has(key)) return;
117
+ seen.add(key);
118
+ violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
119
+ };
120
+ const result = analyzeSql(text, { uniqueSetsOf: table => tables.get(table.toLowerCase())?.uniqueSets ?? null });
121
+ dynamic += result.dynamic;
122
+ for (const write of result.writes) {
123
+ const entity = tables.get(write.table.toLowerCase());
124
+ if (!entity) report(`SQL writes table ${write.table}, which no @Entity declares; declare the entity in the owning capability's persistence/entities.`, { table: write.table });
125
+ else if (entity.owner !== file.owner.root) report(`SQL in ${file.owner.root} writes table ${write.table}, owned by ${entity.owner}; a capability writes only the tables of its own entities, call the owner's public API instead.`, { table: write.table, tableOwner: entity.owner });
126
+ }
127
+ for (const read of result.reads) {
128
+ const entity = tables.get(read.table.toLowerCase());
129
+ if (!entity) { report(`SQL reads table ${read.table}, which no @Entity declares; declare the entity in the owning capability's persistence/entities.`, { table: read.table }); continue; }
130
+ if (entity.owner === file.owner.root) continue;
131
+ const verdict = resolver.importAllowed(file.rel, `${entity.owner}/index.ts`);
132
+ if (!verdict.allowed) report(`SQL in ${file.owner.root} reads table ${read.table}, owned by ${entity.owner}, which ${file.owner.root} may not import (${verdict.reason}); read it through the owner's public API.`, { table: read.table, tableOwner: entity.owner, reason: verdict.reason });
133
+ }
134
+ for (const select of result.selects) {
135
+ if (select.bounded || (select.table && !tables.has(select.table.toLowerCase()))) continue;
136
+ report(`SELECT ${select.table ? `on ${select.table} ` : ''}has no LIMIT and does not constrain a primary key or unique column with =; bound it with LIMIT (PAGE_SIZE_MAX, LIST_ROWS_MAX or BATCH_ROWS from platform/database), filter by key, or select only aggregates.`, { table: select.table ?? undefined });
137
+ }
138
+ return true;
139
+ });
140
+ }
141
+ return { violations, coverage: { status: 'checked', entities: tables.size, unreadableEntities: unreadable, templates, dynamicIdentifiers: dynamic, foreignTags } };
142
+ }
@@ -0,0 +1,327 @@
1
+ /**
2
+ * A small PostgreSQL text reader for the R86 machine check (sql-owner). No SQL parser is a dependency of the runtime, so
3
+ * this is a tokenizer plus a clause scanner, written for exactly what the check needs: which tables a statement
4
+ * writes, which it reads, and whether a SELECT is bounded. Comments and string literals never reach the scanner.
5
+ * A substitution of the `sql` tag (a `${...}` hole, the SqlIdent form) is an opaque token: a table position that holds
6
+ * one is counted as dynamic and judged by nobody.
7
+ */
8
+ export const HOLE = '';
9
+
10
+ const isSpace = char => /\s/u.test(char);
11
+ const isWordStart = char => /[\p{L}_]/u.test(char);
12
+ const isWordPart = char => /[\p{L}\p{N}_$]/u.test(char);
13
+
14
+ /** Tokens: {t: 'word'|'ident'|'string'|'number'|'param'|'hole'|'punct', v, up?} (`up` is the uppercase word). */
15
+ export function tokenizeSql(text) {
16
+ const tokens = [];
17
+ let i = 0;
18
+ const push = (t, v) => tokens.push(t === 'word' ? { t, v, up: v.toUpperCase() } : { t, v });
19
+ while (i < text.length) {
20
+ const char = text[i];
21
+ if (isSpace(char)) { i += 1; continue; }
22
+ if (char === '-' && text[i + 1] === '-') { while (i < text.length && text[i] !== '\n') i += 1; continue; }
23
+ if (char === '/' && text[i + 1] === '*') {
24
+ const end = text.indexOf('*/', i + 2);
25
+ i = end === -1 ? text.length : end + 2;
26
+ continue;
27
+ }
28
+ if (char === HOLE) { push('hole', HOLE); i += 1; continue; }
29
+ if (char === "'") {
30
+ i += 1;
31
+ while (i < text.length) {
32
+ if (text[i] === "'" && text[i + 1] === "'") { i += 2; continue; }
33
+ if (text[i] === "'") break;
34
+ i += 1;
35
+ }
36
+ i += 1;
37
+ push('string', '');
38
+ continue;
39
+ }
40
+ if (char === '"') {
41
+ let value = '';
42
+ i += 1;
43
+ while (i < text.length) {
44
+ if (text[i] === '"' && text[i + 1] === '"') { value += '"'; i += 2; continue; }
45
+ if (text[i] === '"') break;
46
+ value += text[i];
47
+ i += 1;
48
+ }
49
+ i += 1;
50
+ push('ident', value);
51
+ continue;
52
+ }
53
+ if (char === '$') {
54
+ const param = /^\$(\d+)/u.exec(text.slice(i, i + 12));
55
+ if (param) { push('param', param[0]); i += param[0].length; continue; }
56
+ const tag = /^\$([\p{L}_][\p{L}\p{N}_]*)?\$/u.exec(text.slice(i));
57
+ if (tag) {
58
+ const end = text.indexOf(tag[0], i + tag[0].length);
59
+ i = end === -1 ? text.length : end + tag[0].length;
60
+ push('string', '');
61
+ continue;
62
+ }
63
+ }
64
+ if (/\d/u.test(char)) {
65
+ let j = i;
66
+ while (j < text.length && /[\d.]/u.test(text[j])) j += 1;
67
+ push('number', text.slice(i, j));
68
+ i = j;
69
+ continue;
70
+ }
71
+ if (isWordStart(char)) {
72
+ let j = i;
73
+ while (j < text.length && isWordPart(text[j])) j += 1;
74
+ const word = text.slice(i, j);
75
+ if (text[j] === "'" && /^[EeBbXxNn]$/u.test(word)) {
76
+ const escaped = /^[Ee]$/u.test(word);
77
+ j += 1;
78
+ while (j < text.length) {
79
+ if (escaped && text[j] === '\\') { j += 2; continue; }
80
+ if (text[j] === "'" && text[j + 1] === "'") { j += 2; continue; }
81
+ if (text[j] === "'") break;
82
+ j += 1;
83
+ }
84
+ i = j + 1;
85
+ push('string', '');
86
+ continue;
87
+ }
88
+ push('word', word);
89
+ i = j;
90
+ continue;
91
+ }
92
+ const two = text.slice(i, i + 2);
93
+ if (['::', '<=', '>=', '<>', '!=', '||', '->'].includes(two)) { push('punct', two); i += 2; continue; }
94
+ push('punct', char);
95
+ i += 1;
96
+ }
97
+ return tokens;
98
+ }
99
+
100
+ const CLAUSE_WORDS = new Set(['WHERE', 'JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'CROSS', 'NATURAL', 'ON', 'USING', 'GROUP', 'ORDER', 'LIMIT', 'OFFSET',
101
+ 'HAVING', 'UNION', 'INTERSECT', 'EXCEPT', 'WINDOW', 'FOR', 'RETURNING', 'SET', 'VALUES', 'SELECT', 'FETCH', 'OUTER', 'TABLESAMPLE', 'WITH', 'AS']);
102
+ // PostgreSQL aggregate function names, kept as one string so they read as words, not as failure codes.
103
+ const AGGREGATES = new Set('COUNT SUM AVG MIN MAX BOOL_AND BOOL_OR EVERY ARRAY_AGG STRING_AGG JSON_AGG JSONB_AGG JSON_OBJECT_AGG JSONB_OBJECT_AGG BIT_AND BIT_OR STDDEV VARIANCE'.split(' '));
104
+ const FROM_INSIDE = new Set(['EXTRACT', 'SUBSTRING', 'TRIM', 'OVERLAY', 'POSITION']);
105
+ const NOT_A_WRITE_BEFORE_UPDATE = new Set(['FOR', 'DO', 'ON', 'KEY', 'SHARE', 'OF']);
106
+ const WHERE_END = new Set(['GROUP', 'ORDER', 'HAVING', 'LIMIT', 'OFFSET', 'UNION', 'INTERSECT', 'EXCEPT', 'FOR', 'WINDOW', 'RETURNING', 'FETCH']);
107
+ const SYSTEM_SCHEMAS = new Set(['pg_catalog', 'information_schema']);
108
+
109
+ const isName = token => token && (token.t === 'word' || token.t === 'ident');
110
+ const nameOf = token => (token.t === 'word' ? token.v.toLowerCase() : token.v);
111
+ const isWord = (token, up) => token?.t === 'word' && token.up === up;
112
+
113
+ /**
114
+ * The table reference at `start`: {parts, table, alias, next}, {dynamic: true}, or null (a subquery, a function, nothing).
115
+ * `next` is the index after the reference and its alias.
116
+ */
117
+ function tableRef(tokens, start, { columns = false } = {}) {
118
+ let i = start;
119
+ while (isWord(tokens[i], 'ONLY') || isWord(tokens[i], 'LATERAL')) i += 1;
120
+ const first = tokens[i];
121
+ if (first?.t === 'hole') return { dynamic: true, next: i + 1 };
122
+ if (!isName(first) || (first.t === 'word' && CLAUSE_WORDS.has(first.up) && first.up !== 'AS')) return null;
123
+ const parts = [nameOf(first)];
124
+ i += 1;
125
+ while (tokens[i]?.v === '.' && isName(tokens[i + 1])) { parts.push(nameOf(tokens[i + 1])); i += 2; }
126
+ if (tokens[i]?.v === '(' && !columns) return null;
127
+ let alias = null;
128
+ if (isWord(tokens[i], 'AS') && isName(tokens[i + 1])) { alias = nameOf(tokens[i + 1]); i += 2; }
129
+ else if (isName(tokens[i]) && !(tokens[i].t === 'word' && CLAUSE_WORDS.has(tokens[i].up))) { alias = nameOf(tokens[i]); i += 1; }
130
+ if (alias && tokens[i]?.v === '(') { let depth = 0; do { if (tokens[i].v === '(') depth += 1; else if (tokens[i].v === ')') depth -= 1; i += 1; } while (i < tokens.length && depth > 0); }
131
+ return { parts, table: parts.at(-1), schema: parts.length > 1 ? parts.at(-2) : null, alias, next: i };
132
+ }
133
+
134
+ function statementsOf(tokens) {
135
+ const statements = [];
136
+ let current = [];
137
+ let depth = 0;
138
+ for (const token of tokens) {
139
+ if (token.t === 'punct' && token.v === '(') depth += 1;
140
+ if (token.t === 'punct' && token.v === ')') depth -= 1;
141
+ if (token.t === 'punct' && token.v === ';' && depth <= 0) { if (current.length) statements.push(current); current = []; continue; }
142
+ current.push(token);
143
+ }
144
+ if (current.length) statements.push(current);
145
+ return statements;
146
+ }
147
+
148
+ /** The CTE names a statement declares: `name [(cols)] AS [NOT] [MATERIALIZED] (`. */
149
+ function cteNames(tokens) {
150
+ const names = new Set();
151
+ for (let i = 1; i < tokens.length; i += 1) {
152
+ if (!isWord(tokens[i], 'AS')) continue;
153
+ let j = i + 1;
154
+ if (isWord(tokens[j], 'NOT')) j += 1;
155
+ if (isWord(tokens[j], 'MATERIALIZED')) j += 1;
156
+ if (tokens[j]?.v !== '(') continue;
157
+ let k = i - 1;
158
+ if (tokens[k]?.v === ')') { let depth = 0; do { if (tokens[k].v === ')') depth += 1; else if (tokens[k].v === '(') depth -= 1; k -= 1; } while (k >= 0 && depth > 0); }
159
+ const before = tokens[k - 1];
160
+ if (isName(tokens[k]) && (isWord(before, 'WITH') || isWord(before, 'RECURSIVE') || before?.v === ',')) names.add(nameOf(tokens[k]));
161
+ }
162
+ return names;
163
+ }
164
+
165
+ /** Depth of each token (the depth before it when it is `(`, after it when it is `)`). */
166
+ function depths(tokens) {
167
+ let depth = 0;
168
+ return tokens.map(token => {
169
+ if (token.v === ')' && token.t === 'punct') depth -= 1;
170
+ const here = depth;
171
+ if (token.v === '(' && token.t === 'punct') depth += 1;
172
+ return here;
173
+ });
174
+ }
175
+
176
+ function matching(tokens, open) {
177
+ let depth = 0;
178
+ for (let i = open; i < tokens.length; i += 1) {
179
+ if (tokens[i].t !== 'punct') continue;
180
+ if (tokens[i].v === '(') depth += 1;
181
+ else if (tokens[i].v === ')') { depth -= 1; if (depth === 0) return i; }
182
+ }
183
+ return tokens.length - 1;
184
+ }
185
+
186
+ function isAggregateItem(item) {
187
+ for (let i = 0; i < item.length; i += 1) {
188
+ if (item[i].t === 'word' && AGGREGATES.has(item[i].up) && item[i + 1]?.v === '(') {
189
+ const close = matching(item, i + 1);
190
+ if (!isWord(item[close + 1], 'OVER')) return true;
191
+ }
192
+ }
193
+ return false;
194
+ }
195
+
196
+ /** The unique-key columns `columns` (lowercase Set of `=`-constrained column names) satisfy for one of `uniqueSets`. */
197
+ const covers = (uniqueSets, columns) => uniqueSets.some(set => set.length > 0 && set.every(column => columns.has(column)));
198
+
199
+ function equalityColumns(conjunct, qualifiers) {
200
+ const found = new Set();
201
+ const name = (from, to) => {
202
+ const part = conjunct.slice(from, to);
203
+ if (part.length === 1 && isName(part[0])) return nameOf(part[0]);
204
+ if (part.length === 3 && isName(part[0]) && part[1].v === '.' && isName(part[2]) && qualifiers.has(nameOf(part[0]))) return nameOf(part[2]);
205
+ return null;
206
+ };
207
+ const equals = conjunct.findIndex(token => token.t === 'punct' && token.v === '=');
208
+ if (equals < 0) return found;
209
+ const left = name(0, equals);
210
+ const rightStart = conjunct[equals + 1];
211
+ if (left && !(rightStart?.t === 'word' && ['ANY', 'ALL', 'SOME'].includes(rightStart.up))) found.add(left);
212
+ const right = name(equals + 1, conjunct.length);
213
+ if (right) found.add(right);
214
+ return found;
215
+ }
216
+
217
+ /**
218
+ * Judge one statement that reads rows (no INSERT/UPDATE/DELETE/MERGE at its own depth): `{bounded, basis}`. Bounded is
219
+ * a LIMIT/FETCH at the statement depth, no FROM at all (one row), only aggregates without GROUP BY, or an equality on
220
+ * every column of the primary key or of one unique key of the first table of FROM.
221
+ */
222
+ function selectBound(tokens, level, uniqueSetsOf, cte) {
223
+ const at0 = index => level[index] === 0;
224
+ const find = (up, from = 0) => { for (let i = from; i < tokens.length; i += 1) if (at0(i) && isWord(tokens[i], up)) return i; return -1; };
225
+ if (find('LIMIT') >= 0 || find('FETCH') >= 0) return { bounded: true, basis: 'limit' };
226
+ const select = find('SELECT');
227
+ const from = find('FROM', select);
228
+ if (from < 0) return { bounded: true, basis: 'no from' };
229
+ const groupBy = tokens.findIndex((token, i) => at0(i) && isWord(token, 'GROUP') && isWord(tokens[i + 1], 'BY'));
230
+ if (groupBy < 0) {
231
+ const items = [];
232
+ let item = [];
233
+ for (let i = select + 1; i < from; i += 1) {
234
+ if (at0(i) && tokens[i].v === ',' && tokens[i].t === 'punct') { items.push(item); item = []; continue; }
235
+ if (!(items.length === 0 && item.length === 0 && (isWord(tokens[i], 'DISTINCT') || isWord(tokens[i], 'ALL')))) item.push(tokens[i]);
236
+ }
237
+ items.push(item);
238
+ if (items.length && items.every(isAggregateItem)) return { bounded: true, basis: 'aggregate' };
239
+ }
240
+ const primary = tableRef(tokens, from + 1);
241
+ if (!primary || primary.dynamic || cte.has(primary.table)) return { bounded: false, basis: 'unbounded', table: primary?.table ?? null };
242
+ const uniqueSets = uniqueSetsOf(primary.table);
243
+ const where = find('WHERE', from);
244
+ if (uniqueSets?.length && where >= 0) {
245
+ const region = [];
246
+ for (let i = where + 1; i < tokens.length; i += 1) {
247
+ if (at0(i) && tokens[i].t === 'word' && WHERE_END.has(tokens[i].up)) break;
248
+ region.push({ token: tokens[i], level: level[i] });
249
+ }
250
+ if (!region.some(entry => entry.level === 0 && isWord(entry.token, 'OR'))) {
251
+ const conjuncts = [[]];
252
+ let between = false;
253
+ for (const entry of region) {
254
+ if (entry.level === 0 && isWord(entry.token, 'BETWEEN')) between = true;
255
+ if (entry.level === 0 && isWord(entry.token, 'AND')) {
256
+ if (between) { between = false; } else { conjuncts.push([]); continue; }
257
+ }
258
+ conjuncts.at(-1).push(entry.token);
259
+ }
260
+ const qualifiers = new Set([primary.table, primary.alias].filter(Boolean));
261
+ const columns = new Set();
262
+ for (const conjunct of conjuncts) for (const column of equalityColumns(conjunct, qualifiers)) columns.add(column);
263
+ if (covers(uniqueSets, columns)) return { bounded: true, basis: 'unique' };
264
+ }
265
+ }
266
+ return { bounded: false, basis: 'unbounded', table: primary.table };
267
+ }
268
+
269
+ /**
270
+ * Read one SQL text. `uniqueSetsOf(table)` returns the unique-key column sets (each a lowercase column list) of the entity
271
+ * of `table`, or null when no entity declares it.
272
+ * Returns {writes: [{table, schema}], reads: [{table, schema}], selects: [{bounded, basis, table}], dynamic}.
273
+ */
274
+ export function analyzeSql(text, { uniqueSetsOf = () => null } = {}) {
275
+ const writes = [];
276
+ const reads = [];
277
+ const selects = [];
278
+ let dynamic = 0;
279
+ for (const tokens of statementsOf(tokenizeSql(text))) {
280
+ const cte = cteNames(tokens);
281
+ const level = depths(tokens);
282
+ const parens = [];
283
+ const record = (list, ref) => {
284
+ if (!ref) return;
285
+ if (ref.dynamic) { dynamic += 1; return; }
286
+ if (ref.schema && SYSTEM_SCHEMAS.has(ref.schema)) return;
287
+ if (ref.table.startsWith('pg_')) return;
288
+ if (list === reads && !ref.schema && cte.has(ref.table)) return;
289
+ list.push({ table: ref.table, schema: ref.schema });
290
+ };
291
+ const mergeOrDelete = tokens.some(token => isWord(token, 'MERGE')) || tokens.some(token => isWord(token, 'DELETE'));
292
+ let hasWriteVerb = false;
293
+ for (let i = 0; i < tokens.length; i += 1) {
294
+ const token = tokens[i];
295
+ if (token.t === 'punct' && token.v === '(') { parens.push(i > 0 && tokens[i - 1].t === 'word' && FROM_INSIDE.has(tokens[i - 1].up)); continue; }
296
+ if (token.t === 'punct' && token.v === ')') { parens.pop(); continue; }
297
+ if (token.t !== 'word') continue;
298
+ const previous = tokens[i - 1];
299
+ if (token.up === 'INSERT' && isWord(tokens[i + 1], 'INTO')) { if (level[i] === 0) hasWriteVerb = true; record(writes, tableRef(tokens, i + 2, { columns: true })); }
300
+ else if (token.up === 'UPDATE' && !(previous?.t === 'word' && NOT_A_WRITE_BEFORE_UPDATE.has(previous.up))) { if (level[i] === 0) hasWriteVerb = true; record(writes, tableRef(tokens, i + 1)); }
301
+ else if (token.up === 'DELETE' && isWord(tokens[i + 1], 'FROM')) { if (level[i] === 0) hasWriteVerb = true; record(writes, tableRef(tokens, i + 2)); i += 1; }
302
+ else if (token.up === 'MERGE' && isWord(tokens[i + 1], 'INTO')) { if (level[i] === 0) hasWriteVerb = true; record(writes, tableRef(tokens, i + 2)); }
303
+ else if (token.up === 'TRUNCATE') {
304
+ let j = i + 1;
305
+ if (isWord(tokens[j], 'TABLE')) j += 1;
306
+ for (;;) {
307
+ const ref = tableRef(tokens, j);
308
+ record(writes, ref);
309
+ if (ref && tokens[ref.next]?.v === ',') { j = ref.next + 1; continue; }
310
+ break;
311
+ }
312
+ } else if (token.up === 'FROM' && !isWord(previous, 'DISTINCT') && !isWord(previous, 'DELETE') && parens.at(-1) !== true) {
313
+ let j = i + 1;
314
+ for (;;) {
315
+ const ref = tableRef(tokens, j);
316
+ record(reads, ref);
317
+ if (ref && tokens[ref.next]?.v === ',' && tokens[ref.next].t === 'punct') { j = ref.next + 1; continue; }
318
+ break;
319
+ }
320
+ } else if (token.up === 'JOIN') record(reads, tableRef(tokens, i + 1));
321
+ else if (token.up === 'USING' && mergeOrDelete && tokens[i + 1]?.v !== '(') record(reads, tableRef(tokens, i + 1));
322
+ }
323
+ const hasSelect = tokens.some((candidate, i) => level[i] === 0 && isWord(candidate, 'SELECT'));
324
+ if (hasSelect && !hasWriteVerb) selects.push(selectBound(tokens, level, uniqueSetsOf, cte));
325
+ }
326
+ return { writes, reads, selects, dynamic };
327
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * HFS check 5, one name one declaration (knowledge/hfs/rules.yaml R21 and R30, both profiles, errors in every gate):
3
+ * HFS_DUPLICATE_SYMBOL a name an owner's public entry exports, resolved to the file that declares it, that another
4
+ * production file of the repository also declares and exports (function, class, const, enum,
5
+ * type, interface). Two `InjectPrimaryEntityManager` declarations were this. A re-export of one
6
+ * declaration is not a second declaration. There is no allowlist of names.
7
+ * HFS_ALIAS_REEXPORT `export { X as Y }`, `export { default as Y }` and `export * as ns` in production source, and an
8
+ * exported `const Y = X` / `const Y = X.y` (a bare identifier or member that resolves to a function,
9
+ * class, const or enum of the repository), `type Y = X` and `interface Y extends X {}` (no body, no
10
+ * type arguments, no type parameters): a second name for one declaration. Rename the declaration,
11
+ * or import it by its name.
12
+ * Specs and tests are not in the graph. The public entry of an owner is found the way dead-exports.mjs finds it.
13
+ */
14
+ import { canonical } from './config.mjs';
15
+ import { entryOf } from './dead-exports.mjs';
16
+
17
+ export const SYMBOL_RULE_IDS = ['HFS_DUPLICATE_SYMBOL', 'HFS_ALIAS_REEXPORT'];
18
+
19
+ const lineOf = (sourceFile, node) => sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
20
+
21
+ /** The names a source file declares at top level and exports: Map<name, line>. Re-exports from another module declare nothing. */
22
+ function declaredExports(ts, sourceFile) {
23
+ const kind = ts.SyntaxKind;
24
+ const declared = new Map();
25
+ const exported = new Map();
26
+ const localLists = [];
27
+ const bindingNames = (name, into, node) => {
28
+ if (name.kind === kind.Identifier) into.set(name.text, lineOf(sourceFile, node));
29
+ else for (const element of name.elements) if (element.kind === kind.BindingElement) bindingNames(element.name, into, node);
30
+ };
31
+ for (const statement of sourceFile.statements) {
32
+ if (statement.kind === kind.ExportDeclaration) {
33
+ if (!statement.moduleSpecifier && statement.exportClause?.kind === kind.NamedExports) localLists.push(...statement.exportClause.elements);
34
+ continue;
35
+ }
36
+ const modifiers = statement.modifiers ?? [];
37
+ const publish = modifiers.some(modifier => modifier.kind === kind.ExportKeyword) && !modifiers.some(modifier => modifier.kind === kind.DefaultKeyword);
38
+ const into = publish ? exported : declared;
39
+ if (statement.kind === kind.VariableStatement) {
40
+ for (const declaration of statement.declarationList.declarations) bindingNames(declaration.name, into, declaration);
41
+ } else if (statement.name?.kind === kind.Identifier
42
+ && [kind.FunctionDeclaration, kind.ClassDeclaration, kind.EnumDeclaration, kind.TypeAliasDeclaration, kind.InterfaceDeclaration].includes(statement.kind)) {
43
+ into.set(statement.name.text, lineOf(sourceFile, statement));
44
+ }
45
+ }
46
+ // `export { local }` of a name declared in this file makes that declaration exported.
47
+ for (const element of localLists) {
48
+ const local = (element.propertyName ?? element.name).text;
49
+ if (declared.has(local)) exported.set(element.name.text, declared.get(local));
50
+ }
51
+ return exported;
52
+ }
53
+
54
+ /** The declaration an export of an entry resolves to: its repository-relative file and its own name, or null. */
55
+ function declaringFile(ts, checker, graph, symbol) {
56
+ const resolved = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
57
+ for (const declaration of resolved?.declarations ?? []) {
58
+ const rel = graph.abs(canonical(declaration.getSourceFile().fileName));
59
+ if (rel) return { rel, name: resolved.getName() };
60
+ }
61
+ return null;
62
+ }
63
+
64
+ function duplicateSymbols({ context, graph, config }) {
65
+ const { ts } = context;
66
+ const declarations = new Map(); // name -> Map<rel, line>
67
+ for (const [rel, node] of graph.files) {
68
+ for (const [name, line] of declaredExports(ts, node.sourceFile)) {
69
+ if (name === 'default') continue;
70
+ if (!declarations.has(name)) declarations.set(name, new Map());
71
+ declarations.get(name).set(rel, line);
72
+ }
73
+ }
74
+ const violations = [];
75
+ const seen = new Set();
76
+ let surface = 0;
77
+ for (const [key, owner] of graph.ownerRoots) {
78
+ if (owner.tier === 'app') continue;
79
+ const entry = entryOf(graph, config, key, owner);
80
+ if (!entry) continue;
81
+ const entryFile = graph.files.get(entry);
82
+ const checker = context.checkerFor(entryFile.abs);
83
+ const moduleSymbol = checker?.getSymbolAtLocation(entryFile.sourceFile);
84
+ if (!moduleSymbol) continue;
85
+ for (const symbol of checker.getExportsOfModule(moduleSymbol)) {
86
+ surface += 1;
87
+ const declaration = declaringFile(ts, checker, graph, symbol);
88
+ if (!declaration || declaration.name === 'default') continue;
89
+ const { rel: home, name } = declaration;
90
+ const others = [...(declarations.get(name) ?? [])].filter(([rel]) => rel !== home);
91
+ if (!others.length || seen.has(`${home}\0${name}`)) continue;
92
+ seen.add(`${home}\0${name}`);
93
+ const where = others.map(([rel, at]) => `${rel}:${at}`);
94
+ violations.push({
95
+ ruleId: 'HFS_DUPLICATE_SYMBOL',
96
+ path: home, line: declarations.get(name).get(home) ?? 1, column: 1, name, owner: owner.root, entry,
97
+ otherDeclarations: where,
98
+ message: `${name} is exported through the public entry ${entry} (declared in ${home}) and also declared and exported in ${where.join(', ')}; one name, one declaration: rename or delete the copy.`,
99
+ });
100
+ }
101
+ }
102
+ return { violations, surface };
103
+ }
104
+
105
+ const isExported = (ts, statement) => (statement.modifiers ?? []).some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)
106
+ && !(statement.modifiers ?? []).some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword);
107
+
108
+ /** The declaration `node` (an identifier, a member or a qualified name) resolves to when a repository file declares it as one of `kinds`, else null. */
109
+ function repositoryDeclaration(ts, checker, graph, node, kinds) {
110
+ const target = ts.isPropertyAccessExpression(node) ? node.name : ts.isQualifiedName(node) ? node.right : node;
111
+ const symbol = checker?.getSymbolAtLocation(target);
112
+ if (!symbol) return null;
113
+ const resolved = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
114
+ const home = (resolved?.declarations ?? []).find(declaration => kinds.includes(declaration.kind) && graph.abs(canonical(declaration.getSourceFile().fileName)));
115
+ return home ? resolved.getName() : null;
116
+ }
117
+
118
+ /**
119
+ * The exported declarations of one top-level statement that only rename another declaration of the repository:
120
+ * `export const Y = X` and `export const Y = X.y` (X or y a function, class, const or enum), `export type Y = X` and
121
+ * `export interface Y extends X {}` with no body, no type arguments and no type parameters.
122
+ */
123
+ function declarationAliases(ts, checker, graph, statement) {
124
+ const kind = ts.SyntaxKind;
125
+ if (!checker || !isExported(ts, statement)) return [];
126
+ const bare = expression => ts.isIdentifier(expression) || (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.name) && (ts.isIdentifier(expression.expression) || ts.isPropertyAccessExpression(expression.expression)));
127
+ const found = [];
128
+ if (statement.kind === kind.VariableStatement) {
129
+ for (const declaration of statement.declarationList.declarations) {
130
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer || !bare(declaration.initializer)) continue;
131
+ const of = repositoryDeclaration(ts, checker, graph, declaration.initializer, [kind.FunctionDeclaration, kind.ClassDeclaration, kind.VariableDeclaration, kind.EnumDeclaration]);
132
+ if (of && of !== declaration.name.text) found.push({ node: declaration, name: declaration.name.text, of, form: `export const ${declaration.name.text} = ${declaration.initializer.getText()}` });
133
+ }
134
+ } else if (statement.kind === kind.TypeAliasDeclaration && !statement.typeParameters?.length && ts.isTypeReferenceNode(statement.type) && !statement.type.typeArguments?.length) {
135
+ const of = repositoryDeclaration(ts, checker, graph, statement.type.typeName, [kind.InterfaceDeclaration, kind.TypeAliasDeclaration, kind.ClassDeclaration, kind.EnumDeclaration]);
136
+ if (of && of !== statement.name.text) found.push({ node: statement, name: statement.name.text, of, form: `export type ${statement.name.text} = ${statement.type.getText()}` });
137
+ } else if (statement.kind === kind.InterfaceDeclaration && !statement.members.length && !statement.typeParameters?.length) {
138
+ const heritage = (statement.heritageClauses ?? []).flatMap(clause => clause.types);
139
+ const [only] = heritage;
140
+ if (heritage.length === 1 && !only.typeArguments?.length && bare(only.expression)) {
141
+ const of = repositoryDeclaration(ts, checker, graph, only.expression, [kind.InterfaceDeclaration, kind.TypeAliasDeclaration, kind.ClassDeclaration]);
142
+ if (of && of !== statement.name.text) found.push({ node: statement, name: statement.name.text, of, form: `export interface ${statement.name.text} extends ${only.expression.getText()} {}` });
143
+ }
144
+ }
145
+ return found;
146
+ }
147
+
148
+ function aliasReexports({ context, graph }) {
149
+ const kind = context.ts.SyntaxKind;
150
+ const violations = [];
151
+ for (const [rel, node] of graph.files) {
152
+ const point = target => node.sourceFile.getLineAndCharacterOfPosition(target.getStart(node.sourceFile));
153
+ const checker = context.checkerFor(node.abs);
154
+ for (const statement of node.sourceFile.statements) {
155
+ for (const alias of declarationAliases(context.ts, checker, graph, statement)) {
156
+ const at = point(alias.node);
157
+ violations.push({
158
+ ruleId: 'HFS_ALIAS_REEXPORT', path: rel, line: at.line + 1, column: at.character + 1, name: alias.name, aliasOf: alias.of,
159
+ message: `${alias.form} in ${rel} gives the declaration ${alias.of} a second name: use ${alias.of} where ${alias.name} is used and delete ${alias.name}.`,
160
+ });
161
+ }
162
+ if (statement.kind !== kind.ExportDeclaration || !statement.exportClause) continue;
163
+ const clause = statement.exportClause;
164
+ if (clause.kind === kind.NamespaceExport) {
165
+ const at = point(statement);
166
+ violations.push({
167
+ ruleId: 'HFS_ALIAS_REEXPORT', path: rel, line: at.line + 1, column: at.character + 1, name: clause.name.text,
168
+ message: `export * as ${clause.name.text} in ${rel} publishes a whole module under a second name: export the declarations it holds by their own names.`,
169
+ });
170
+ continue;
171
+ }
172
+ for (const element of clause.elements) {
173
+ if (!element.propertyName || element.propertyName.text === element.name.text) continue;
174
+ const at = point(element);
175
+ violations.push({
176
+ ruleId: 'HFS_ALIAS_REEXPORT', path: rel, line: at.line + 1, column: at.character + 1,
177
+ name: element.name.text, aliasOf: element.propertyName.text,
178
+ message: `export { ${element.propertyName.text} as ${element.name.text} } in ${rel} gives one declaration a second name: rename the declaration to ${element.name.text}, or import and export it as ${element.propertyName.text}.`,
179
+ });
180
+ }
181
+ }
182
+ }
183
+ return { violations };
184
+ }
185
+
186
+ export function checkSymbols(input) {
187
+ const duplicates = duplicateSymbols(input);
188
+ const aliases = aliasReexports(input);
189
+ return {
190
+ violations: [...duplicates.violations, ...aliases.violations],
191
+ coverage: { status: 'checked', publicNames: duplicates.surface, duplicates: duplicates.violations.length, aliases: aliases.violations.length },
192
+ };
193
+ }