@starci/hfs 4.0.10 → 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 (271) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/emit/operations.mjs +1 -1
  3. package/emit/type-schema.mjs +2 -1
  4. package/lint/run.mjs +3 -2
  5. package/package.json +1 -1
  6. package/report/order.mjs +2 -0
  7. package/runtime/config.example.yaml +187 -0
  8. package/runtime/engine/by-code-unit.mjs +12 -0
  9. package/runtime/engine/config.mjs +221 -239
  10. package/runtime/engine/invalid-config.mjs +5 -5
  11. package/runtime/engine/model-config.mjs +90 -0
  12. package/runtime/engine/orca-config.mjs +3 -1
  13. package/runtime/engine/removed-vocabulary.mjs +28 -0
  14. package/runtime/engine/resources-config.mjs +52 -0
  15. package/runtime/engine/runtime-root.mjs +17 -0
  16. package/runtime/engine/secrets.mjs +35 -31
  17. package/runtime/engine/sonar-config.mjs +21 -0
  18. package/runtime/engine/temp-root.mjs +32 -0
  19. package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
  20. package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
  21. package/runtime/knowledge/hfs/rules.yaml +98 -65
  22. package/runtime/knowledge/hfs/slots.yaml +11 -14
  23. package/runtime/knowledge/patterns/be/api.yaml +7 -24
  24. package/runtime/knowledge/patterns/be/cli.yaml +3 -12
  25. package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
  26. package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
  27. package/runtime/modules/kernel/failure-codes.yaml +1 -41
  28. package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
  29. package/runtime/modules/models/registry.yaml +25 -359
  30. package/runtime/modules/models/runtimes.yaml +92 -256
  31. package/runtime/modules/models/tiers.yaml +65 -0
  32. package/runtime/scripts/api/fs/claim-file.mjs +30 -0
  33. package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
  34. package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
  35. package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
  36. package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
  37. package/runtime/scripts/api/git/lib.mjs +34 -1
  38. package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
  39. package/runtime/scripts/api/process/run-program.mjs +2 -1
  40. package/runtime/scripts/api/sops/lib.mjs +198 -120
  41. package/runtime/scripts/hfs/allows.mjs +3 -3
  42. package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
  43. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
  44. package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
  45. package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
  46. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
  47. package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
  48. package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
  49. package/runtime/scripts/hfs/architecture/config.mjs +95 -80
  50. package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
  51. package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
  52. package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
  53. package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
  54. package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
  55. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
  56. package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
  57. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
  58. package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
  59. package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
  60. package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
  61. package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
  62. package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
  63. package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
  64. package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
  65. package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
  66. package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
  67. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
  68. package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
  69. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  70. package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
  71. package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
  72. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
  73. package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
  74. package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
  75. package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
  76. package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
  77. package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
  78. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
  79. package/runtime/scripts/hfs/architecture/index.mjs +158 -102
  80. package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
  81. package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
  82. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
  83. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
  84. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
  85. package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
  86. package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
  87. package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
  88. package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
  89. package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
  90. package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
  91. package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
  92. package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
  93. package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
  94. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
  95. package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
  96. package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
  97. package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
  98. package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
  99. package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
  100. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
  101. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
  102. package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
  103. package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
  104. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
  105. package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
  106. package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
  107. package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
  108. package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
  109. package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
  110. package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
  111. package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
  112. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
  113. package/runtime/scripts/hfs/architecture.mjs +3 -4
  114. package/runtime/scripts/hfs/check.mjs +61 -70
  115. package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
  116. package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
  117. package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
  118. package/runtime/scripts/hfs/edition-slots.mjs +5 -3
  119. package/runtime/scripts/hfs/linear-text.mjs +30 -0
  120. package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
  121. package/runtime/scripts/hfs/path-findings.mjs +58 -38
  122. package/runtime/scripts/hfs/pin-findings.mjs +31 -0
  123. package/runtime/scripts/hfs/repo-identity.mjs +5 -2
  124. package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
  125. package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
  126. package/runtime/scripts/hfs/rules/cli.mjs +5 -1
  127. package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
  128. package/runtime/scripts/hfs/rules/contract.mjs +35 -27
  129. package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
  130. package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
  131. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
  132. package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
  133. package/runtime/scripts/hfs/rules/database.mjs +36 -23
  134. package/runtime/scripts/hfs/rules/deps.mjs +64 -32
  135. package/runtime/scripts/hfs/rules/docker.mjs +113 -65
  136. package/runtime/scripts/hfs/rules/edition.mjs +118 -111
  137. package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
  138. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
  139. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
  140. package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
  141. package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
  142. package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
  143. package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
  144. package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
  145. package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
  146. package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
  147. package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
  148. package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
  149. package/runtime/scripts/hfs/rules/saga.mjs +128 -61
  150. package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
  151. package/runtime/scripts/hfs/rules/services.mjs +34 -16
  152. package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
  153. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
  154. package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
  155. package/runtime/scripts/hfs/secret.mjs +59 -48
  156. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  157. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  158. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  159. package/runtime/scripts/hfs/slot-imports.mjs +76 -0
  160. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  161. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  162. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  163. package/runtime/scripts/hfs/slot-required.mjs +76 -0
  164. package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
  165. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  166. package/runtime/scripts/hfs/slots.mjs +72 -693
  167. package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
  168. package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
  169. package/runtime/scripts/hfs/tree.mjs +19 -14
  170. package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
  171. package/runtime/scripts/hfs/view.mjs +1 -1
  172. package/runtime/scripts/lib/dockerfile.mjs +56 -33
  173. package/runtime/scripts/lib/event-contract.mjs +17 -13
  174. package/runtime/scripts/lib/git.mjs +2 -2
  175. package/runtime/scripts/lib/graphql-contract.mjs +158 -322
  176. package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
  177. package/runtime/scripts/lib/i18n.mjs +3 -2
  178. package/runtime/scripts/lib/in-order.mjs +71 -0
  179. package/runtime/scripts/lib/language.mjs +3 -3
  180. package/runtime/scripts/lib/list.mjs +8 -1
  181. package/runtime/scripts/lib/path-key.mjs +38 -5
  182. package/runtime/scripts/lib/pid-alive.mjs +7 -0
  183. package/runtime/scripts/lib/regex.mjs +1 -1
  184. package/runtime/scripts/lib/same-text.mjs +1 -1
  185. package/runtime/scripts/lib/secret-patterns.mjs +6 -2
  186. package/runtime/scripts/lib/sleep-sync.mjs +2 -2
  187. package/runtime/scripts/lib/sops-envelope.mjs +95 -48
  188. package/runtime/scripts/lib/stack-services.mjs +1 -2
  189. package/runtime/scripts/lib/ts-ast.mjs +9 -0
  190. package/runtime/scripts/lib/walk.mjs +6 -1
  191. package/scaffold/add-cli-lite.mjs +2 -1
  192. package/scaffold/add-table.mjs +2 -2
  193. package/scaffold/add.mjs +3 -2
  194. package/scaffold/app.mjs +4 -2
  195. package/scaffold/edition-gate.mjs +25 -25
  196. package/scaffold/lite-exports.mjs +2 -1
  197. package/scaffold/service.mjs +5 -4
  198. package/sync/hygiene.mjs +15 -9
  199. package/sync/index.mjs +7 -3
  200. package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
  201. package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
  202. package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
  203. package/templates/app/ci-workflows/sonar-steps.yml +2 -2
  204. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
  205. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
  206. package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
  207. package/templates/app/starciwork.gitignore +1 -1
  208. package/templates/be/image/api/Dockerfile +1 -1
  209. package/templates/be/image/cli/Dockerfile +1 -1
  210. package/templates/be/image/worker/Dockerfile +1 -1
  211. package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
  212. package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
  213. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
  214. package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
  215. package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
  216. package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
  217. package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
  218. package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
  219. package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
  220. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
  221. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
  222. package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
  223. package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
  224. package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
  225. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
  226. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
  227. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
  228. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
  229. package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
  230. package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
  231. package/templates/fe/image/next/Dockerfile +2 -1
  232. package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
  233. package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
  234. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
  235. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
  236. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
  237. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
  238. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
  239. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
  240. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
  241. package/upgrade/index.mjs +9 -2
  242. package/runtime/engine/admission.mjs +0 -308
  243. package/runtime/engine/canonical-json.mjs +0 -10
  244. package/runtime/engine/db/blob.mjs +0 -315
  245. package/runtime/engine/db/ledger-paths.mjs +0 -83
  246. package/runtime/engine/db/ledger.mjs +0 -1118
  247. package/runtime/engine/db/machine-connection.mjs +0 -135
  248. package/runtime/engine/db/machine-schema.mjs +0 -84
  249. package/runtime/engine/db/machine.mjs +0 -1367
  250. package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
  251. package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
  252. package/runtime/engine/db/provider-reservations.mjs +0 -101
  253. package/runtime/engine/digest.mjs +0 -16
  254. package/runtime/engine/refuse.mjs +0 -11
  255. package/runtime/modules/ops/_labels.yaml +0 -53
  256. package/runtime/scripts/api/node/lib.mjs +0 -14
  257. package/runtime/scripts/api/node/spawn-node.mjs +0 -6
  258. package/runtime/scripts/api/process/lib.mjs +0 -111
  259. package/runtime/scripts/api/process/owned-process.mjs +0 -78
  260. package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
  261. package/runtime/scripts/connectors/lib.mjs +0 -488
  262. package/runtime/scripts/lib/clip.mjs +0 -19
  263. package/runtime/scripts/lib/display-names.mjs +0 -259
  264. package/runtime/scripts/lib/example-refs.mjs +0 -158
  265. package/runtime/scripts/lib/json-schema.mjs +0 -52
  266. package/runtime/scripts/lib/process-identity.mjs +0 -6
  267. package/runtime/scripts/lib/read-yaml.mjs +0 -18
  268. package/runtime/scripts/lib/redact.mjs +0 -161
  269. package/runtime/scripts/lib/sleep.mjs +0 -7
  270. package/runtime/scripts/lib/source-phrases.mjs +0 -34
  271. package/runtime/scripts/lib/sqlite.mjs +0 -21
@@ -20,70 +20,105 @@ import { machineKit } from './machine-ast.mjs';
20
20
  export const BACKGROUND_UNOWNED_RULE_IDS = ['BE_BACKGROUND_UNOWNED'];
21
21
 
22
22
  const RULE = 'BE_BACKGROUND_UNOWNED';
23
- const BACKGROUND_ROLES = ['processor', 'consumer'];
23
+ const BACKGROUND_ROLES = new Set(['processor', 'consumer']);
24
24
  /** The words the law names for background methods (knowledge/hfs/README.md 5.7). */
25
25
  const BACKGROUND_WORDS = ['sweep', 'deliver', 'reconcile', 'retry'];
26
26
 
27
27
  const roleOf = rel => {
28
28
  const parts = path.posix.basename(rel).split('.');
29
- return parts.length >= 3 && parts.at(-1) === 'ts' && BACKGROUND_ROLES.includes(parts.at(-2)) ? parts.at(-2) : null;
29
+ return parts.length >= 3 && parts.at(-1) === 'ts' && BACKGROUND_ROLES.has(parts.at(-2)) ? parts.at(-2) : null;
30
30
  };
31
31
  const isBackgroundName = name => BACKGROUND_WORDS.some(word => name === word || (name.startsWith(word) && /[A-Z]/u.test(name[word.length] ?? '')));
32
32
 
33
- export function checkBackgroundUnowned(input) {
34
- const { graph } = input;
35
- const kit = machineKit(input);
36
- const { ts, resolver } = kit;
37
- const violations = [];
38
- const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
39
-
33
+ const adjacencyOf = edges => {
40
34
  const adjacency = new Map();
41
- for (const edge of graph.edges) if (edge.runtime) {
35
+ for (const edge of edges) {
36
+ if (!edge.runtime) continue;
42
37
  if (!adjacency.has(edge.from)) adjacency.set(edge.from, []);
43
38
  adjacency.get(edge.from).push(edge.to);
44
39
  }
45
- const closure = starts => {
46
- const seen = new Set(starts);
47
- const queue = [...starts];
48
- while (queue.length) for (const next of adjacency.get(queue.shift()) ?? []) if (!seen.has(next)) { seen.add(next); queue.push(next); }
49
- return seen;
50
- };
40
+ return adjacency;
41
+ };
51
42
 
43
+ const closureOf = (adjacency, starts) => {
44
+ const seen = new Set(starts);
45
+ const queue = [...starts];
46
+ while (queue.length) {
47
+ for (const next of adjacency.get(queue.shift()) ?? []) {
48
+ if (seen.has(next)) continue;
49
+ seen.add(next);
50
+ queue.push(next);
51
+ }
52
+ }
53
+ return seen;
54
+ };
55
+
56
+ const appRootsOf = (apps, graph) => {
52
57
  const rootOf = app => `apps/${app.name}/src/app.module.ts`;
53
- const workerRoots = resolver.repo.apps.filter(app => app.kind === 'worker').map(rootOf).filter(rel => graph.files.has(rel));
54
- const composed = closure([...workerRoots, ...resolver.repo.apps.filter(app => app.kind === 'api').map(rootOf).filter(rel => graph.files.has(rel))]);
55
- const background = [...graph.files.values()].filter(file => roleOf(file.rel) && file.tier === 'feature');
56
- const running = background.filter(file => composed.has(file.rel));
58
+ const workers = apps.filter(app => app.kind === 'worker').map(rootOf).filter(rel => graph.files.has(rel));
59
+ const services = apps.filter(app => app.kind === 'api').map(rootOf).filter(rel => graph.files.has(rel));
60
+ return { workers, composed: [...workers, ...services] };
61
+ };
62
+
63
+ const backgroundFilesOf = graph => [...graph.files.values()].filter(file => roleOf(file.rel) && file.tier === 'feature');
64
+
65
+ const reportUncomposedFiles = (background, composed, workerRoots, report) => {
57
66
  for (const file of background) {
58
67
  if (composed.has(file.rel)) continue;
59
68
  const role = roleOf(file.rel);
60
69
  report(file, file.sourceFile, `${file.rel} is a ${role} that no service app composes${workerRoots.length ? '' : ' (hfs.json declares no worker app)'}; a ${role} runs only when the ${role === 'processor' ? 'job' : 'message'} module of its feature is imported by the root module of an app of kind worker or api.`, { role });
61
70
  }
71
+ };
62
72
 
63
- // Everything a composed processor or consumer can reach: its imports, and the files of its own feature (command handlers).
73
+ const rootsOfRunningFeatures = (running, files) => {
64
74
  const roots = new Set();
65
75
  for (const file of running) {
66
76
  roots.add(file.rel);
67
77
  const owner = file.owner?.root;
68
- if (owner) for (const other of graph.files.values()) if (other.owner?.root === owner) roots.add(other.rel);
78
+ if (owner) for (const other of files.values()) if (other.owner?.root === owner) roots.add(other.rel);
69
79
  }
70
- const reachable = closure([...roots]);
80
+ return roots;
81
+ };
82
+
83
+ const backgroundMethodsOf = (node, kit, ts) => node.members.flatMap(member => {
84
+ if (!ts.isMethodDeclaration(member) || !member.name) return [];
85
+ const name = kit.propertyNameText(member.name);
86
+ return name && isBackgroundName(name) ? [{ name, node: member.name }] : [];
87
+ });
71
88
 
89
+ const checkBackgroundMethods = ({ graph, kit, ts, reachable, report }) => {
72
90
  let methods = 0;
91
+ const reportMethod = (file, method) => {
92
+ methods += 1;
93
+ if (!reachable.has(file.rel)) report(file, method.node, `${method.name} is background work (a ${BACKGROUND_WORDS.join(', ')} method) that no processor or consumer composed by a worker or api app can reach. Add a processor in features/jobs/<job> or a consumer in transport/message of a feature that runs it, and compose its module in a worker or api app.`, { method: method.name });
94
+ };
73
95
  for (const file of graph.files.values()) {
74
- const mechanism = file.tier === 'platform';
96
+ if (file.tier === 'platform') continue;
75
97
  kit.walk(file.sourceFile, node => {
76
- if (!mechanism && ts.isClassDeclaration(node)) {
77
- for (const member of node.members) {
78
- if (!ts.isMethodDeclaration(member) || !member.name) continue;
79
- const name = kit.propertyNameText(member.name);
80
- if (!name || !isBackgroundName(name)) continue;
81
- methods += 1;
82
- if (!reachable.has(file.rel)) report(file, member.name, `${name} is background work (a ${BACKGROUND_WORDS.join(', ')} method) that no processor or consumer composed by a worker or api app can reach. Add a processor in features/jobs/<job> or a consumer in transport/message of a feature that runs it, and compose its module in a worker or api app.`, { method: name });
83
- }
84
- }
85
- return true;
98
+ if (ts.isClassDeclaration(node)) for (const method of backgroundMethodsOf(node, kit, ts)) reportMethod(file, method);
86
99
  });
87
100
  }
101
+ return methods;
102
+ };
103
+
104
+ export function checkBackgroundUnowned(input) {
105
+ const { graph } = input;
106
+ const kit = machineKit(input);
107
+ const { ts, resolver } = kit;
108
+ const violations = [];
109
+ const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
110
+
111
+ const adjacency = adjacencyOf(graph.edges);
112
+ const closure = starts => closureOf(adjacency, starts);
113
+ const { workers: workerRoots, composed: appRoots } = appRootsOf(resolver.repo.apps, graph);
114
+ const composed = closure(appRoots);
115
+ const background = backgroundFilesOf(graph);
116
+ const running = background.filter(file => composed.has(file.rel));
117
+ reportUncomposedFiles(background, composed, workerRoots, report);
118
+
119
+ // Everything a composed processor or consumer can reach: its imports, and the files of its own feature (command handlers).
120
+ const roots = rootsOfRunningFeatures(running, graph.files);
121
+ const reachable = closure([...roots]);
122
+ const methods = checkBackgroundMethods({ graph, kit, ts, reachable, report });
88
123
  return { violations, coverage: { status: 'checked', workers: workerRoots.length, jobsAndConsumers: background.length, composed: running.length, backgroundMethods: methods } };
89
124
  }
@@ -22,41 +22,61 @@ const SERVER_MODULES = new Set(['server-only', 'next/headers', 'next/server', 'n
22
22
  const NODE_BUILTINS = new Set(builtinModules.map(name => name.replace(/^node:/u, '').split('/')[0]));
23
23
 
24
24
  const isServerSpecifier = specifier => SERVER_MODULES.has(specifier) || specifier.startsWith('node:') || NODE_BUILTINS.has(specifier.split('/')[0]);
25
+ const isClientEntry = (ts, file) => {
26
+ const first = file.sourceFile.statements[0];
27
+ return Boolean(first) && ts.isExpressionStatement(first) && ts.isStringLiteral(first.expression) && first.expression.text === 'use client';
28
+ };
25
29
 
26
- export function checkClientReachesServer({ graph, context }) {
27
- const ts = context.ts;
28
- const isClientEntry = file => {
29
- const first = file.sourceFile.statements[0];
30
- return Boolean(first) && ts.isExpressionStatement(first) && ts.isStringLiteral(first.expression) && first.expression.text === 'use client';
31
- };
32
- /** True when the import or re-export declaration reaches the bundle: not `import type`, not all-`type` specifiers. */
33
- const reachesBundle = declaration => {
34
- if (ts.isImportDeclaration(declaration)) {
35
- const clause = declaration.importClause;
36
- if (!clause) return true;
37
- if (clause.isTypeOnly) return false;
38
- if (!clause.name && clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
39
- const elements = clause.namedBindings.elements;
40
- return elements.length === 0 || !elements.every(element => element.isTypeOnly);
41
- }
42
- return true;
30
+ const reachesBundle = (ts, declaration) => {
31
+ if (ts.isImportDeclaration(declaration)) {
32
+ const clause = declaration.importClause;
33
+ if (!clause) return true;
34
+ if (clause.isTypeOnly) return false;
35
+ if (!clause.name && clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
36
+ const elements = clause.namedBindings.elements;
37
+ return elements.length === 0 || !elements.every(element => element.isTypeOnly);
43
38
  }
44
- return !declaration.isTypeOnly;
45
- };
46
- /** The server-only specifiers a file imports, with the node to point at. */
47
- const serverImports = file => {
48
- const found = [];
49
- const visit = node => {
50
- const specifier = (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && reachesBundle(node) ? node.moduleSpecifier
51
- : ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require')) ? node.arguments[0]
52
- : null;
53
- if (specifier && ts.isStringLiteralLike(specifier) && isServerSpecifier(specifier.text)) found.push({ specifier: specifier.text, node });
54
- ts.forEachChild(node, visit);
55
- };
56
- visit(file.sourceFile);
57
- return found;
39
+ return true;
40
+ }
41
+ return !declaration.isTypeOnly;
42
+ };
43
+
44
+ const serverImports = (ts, file) => {
45
+ const found = [];
46
+ const visit = node => {
47
+ let specifier = null;
48
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && reachesBundle(ts, node)) specifier = node.moduleSpecifier;
49
+ else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) specifier = node.arguments[0];
50
+ if (specifier && ts.isStringLiteralLike(specifier) && isServerSpecifier(specifier.text)) found.push({ specifier: specifier.text, node });
51
+ ts.forEachChild(node, visit);
58
52
  };
53
+ visit(file.sourceFile);
54
+ return found;
55
+ };
56
+
57
+ const scanClientEntry = (entry, { ts, graph, runtime, violations }) => {
58
+ const seen = new Map([[entry.rel, { chain: [entry.rel], first: null }]]);
59
+ const queue = [entry.rel];
60
+ for (const rel of queue) {
61
+ if (isServerActionModule(ts, graph.files.get(rel).sourceFile)) continue;
62
+ const state = seen.get(rel);
63
+ for (const hit of runtime.serverOf(rel)) {
64
+ const at = state.first ?? sourceLocation(graph.files.get(rel).sourceFile, hit.node);
65
+ violations.push({
66
+ ruleId: RULE, path: entry.rel, line: at.line, column: at.column, clientEntry: entry.rel, importer: rel, specifier: hit.specifier, dependencyChain: state.chain,
67
+ message: `${entry.rel} is a client module ("use client") and reaches ${hit.specifier} through ${[...state.chain, hit.specifier].join(' -> ')}; a server-only surface never enters the client bundle. Read on the server and pass the value down, or move the import out of the client graph.`,
68
+ });
69
+ }
70
+ for (const edge of runtime.edges.get(rel) ?? []) {
71
+ if (seen.has(edge.to)) continue;
72
+ seen.set(edge.to, { chain: [...state.chain, edge.to], first: state.first ?? { line: edge.line, column: edge.column } });
73
+ queue.push(edge.to);
74
+ }
75
+ }
76
+ };
59
77
 
78
+ export function checkClientReachesServer({ graph, context }) {
79
+ const ts = context.ts;
60
80
  const runtime = new Map();
61
81
  for (const edge of graph.edges) {
62
82
  if (!edge.runtime) continue;
@@ -65,32 +85,13 @@ export function checkClientReachesServer({ graph, context }) {
65
85
  }
66
86
  const serverByFile = new Map();
67
87
  const serverOf = rel => {
68
- if (!serverByFile.has(rel)) serverByFile.set(rel, serverImports(graph.files.get(rel)));
88
+ if (!serverByFile.has(rel)) serverByFile.set(rel, serverImports(ts, graph.files.get(rel)));
69
89
  return serverByFile.get(rel);
70
90
  };
71
91
 
72
92
  const violations = [];
73
- const entries = [...graph.files.values()].filter(isClientEntry).sort((a, b) => a.rel.localeCompare(b.rel));
74
- for (const entry of entries) {
75
- const seen = new Map([[entry.rel, { chain: [entry.rel], first: null }]]);
76
- const queue = [entry.rel];
77
- for (let head = 0; head < queue.length; head += 1) {
78
- const rel = queue[head];
79
- if (isServerActionModule(ts, graph.files.get(rel).sourceFile)) continue;
80
- const state = seen.get(rel);
81
- for (const hit of serverOf(rel)) {
82
- const at = state.first ?? sourceLocation(graph.files.get(rel).sourceFile, hit.node);
83
- violations.push({
84
- ruleId: RULE, path: entry.rel, line: at.line, column: at.column, clientEntry: entry.rel, importer: rel, specifier: hit.specifier, dependencyChain: state.chain,
85
- message: `${entry.rel} is a client module ("use client") and reaches ${hit.specifier} through ${[...state.chain, hit.specifier].join(' -> ')}; a server-only surface never enters the client bundle. Read on the server and pass the value down, or move the import out of the client graph.`,
86
- });
87
- }
88
- for (const edge of runtime.get(rel) ?? []) {
89
- if (seen.has(edge.to)) continue;
90
- seen.set(edge.to, { chain: [...state.chain, edge.to], first: state.first ?? { line: edge.line, column: edge.column } });
91
- queue.push(edge.to);
92
- }
93
- }
94
- }
93
+ const entries = [...graph.files.values()].filter(file => isClientEntry(ts, file)).sort((a, b) => a.rel.localeCompare(b.rel));
94
+ const scanRuntime = { ts, graph, edges: runtime, serverOf, violations };
95
+ for (const entry of entries) scanClientEntry(entry, { ts, graph, runtime: scanRuntime, violations });
95
96
  return { violations, coverage: { status: 'checked', clientEntries: entries.length } };
96
97
  }
@@ -22,6 +22,8 @@
22
22
  * canonical shape of section 5 of BE-CONVENTION. A door or main.ts that grows logic is refused by those enforcers, not
23
23
  * excused here; every other file of the same slots (mappers, dto, app.module.ts) stays compared.
24
24
  */
25
+ import { byCodeUnit } from '../../lib/list.mjs';
26
+
25
27
  export const CLONE_RULE_IDS = ['HFS_DUPLICATE_CODE'];
26
28
 
27
29
  const MAX_VIOLATIONS = 200;
@@ -30,39 +32,54 @@ const APP_ENTRY = 'main.ts';
30
32
  const ID = -1;
31
33
  const LIT = -2;
32
34
 
35
+ const literalKindSet = (SyntaxKind) => new Set([SyntaxKind.StringLiteral, SyntaxKind.NumericLiteral, SyntaxKind.BigIntLiteral, SyntaxKind.RegularExpressionLiteral,
36
+ SyntaxKind.NoSubstitutionTemplateLiteral, SyntaxKind.TemplateHead, SyntaxKind.TemplateMiddle, SyntaxKind.TemplateTail, SyntaxKind.JsxText]);
37
+
38
+ function lineOfPosition(lineStarts, position) {
39
+ let low = 0;
40
+ let high = lineStarts.length - 1;
41
+ while (low < high) {
42
+ const mid = (low + high + 1) >> 1;
43
+ if (lineStarts[mid] <= position) low = mid; else high = mid - 1;
44
+ }
45
+ return low + 1;
46
+ }
47
+
48
+ /** True for the nodes that carry no token: documentation comments and whitespace-only JSX text. */
49
+ function isTriviaNode(SyntaxKind, node) {
50
+ const kind = node.kind;
51
+ if (kind >= SyntaxKind.FirstJSDocNode && kind <= SyntaxKind.LastJSDocNode) return true;
52
+ return kind === SyntaxKind.JsxText && Boolean(node.containsOnlyTriviaWhiteSpaces);
53
+ }
54
+
55
+ function normalizedKindOf(SyntaxKind, literalKinds, kind) {
56
+ if (kind === SyntaxKind.Identifier || kind === SyntaxKind.PrivateIdentifier) return ID;
57
+ return literalKinds.has(kind) ? LIT : kind;
58
+ }
59
+
60
+ function visitToken(walk, node, inType) {
61
+ const { ts, sourceFile, kinds, lines, types, lineStarts, literalKinds } = walk;
62
+ const { SyntaxKind } = ts;
63
+ if (isTriviaNode(SyntaxKind, node)) return;
64
+ const kind = node.kind;
65
+ const typeScope = inType || kind === SyntaxKind.InterfaceDeclaration || kind === SyntaxKind.TypeAliasDeclaration;
66
+ kinds.push(normalizedKindOf(SyntaxKind, literalKinds, kind));
67
+ lines.push(lineOfPosition(lineStarts, node.getStart(sourceFile)));
68
+ types.push(typeScope ? 1 : 0);
69
+ ts.forEachChild(node, (child) => { visitToken(walk, child, typeScope); });
70
+ }
71
+
72
+ /** Leading import and export-from statements repeat legitimately and are not tokenised. */
73
+ const isDroppedStatement = (SyntaxKind, statement) => statement.kind === SyntaxKind.ImportDeclaration || statement.kind === SyntaxKind.ImportEqualsDeclaration
74
+ || (statement.kind === SyntaxKind.ExportDeclaration && statement.moduleSpecifier);
75
+
33
76
  export function tokenize(ts, sourceFile) {
34
77
  const { SyntaxKind } = ts;
35
- const literalKinds = new Set([SyntaxKind.StringLiteral, SyntaxKind.NumericLiteral, SyntaxKind.BigIntLiteral, SyntaxKind.RegularExpressionLiteral,
36
- SyntaxKind.NoSubstitutionTemplateLiteral, SyntaxKind.TemplateHead, SyntaxKind.TemplateMiddle, SyntaxKind.TemplateTail, SyntaxKind.JsxText]);
37
- const kinds = [];
38
- const lines = [];
39
- const types = [];
40
- const lineStarts = sourceFile.getLineStarts();
41
- const lineOf = (position) => {
42
- let low = 0;
43
- let high = lineStarts.length - 1;
44
- while (low < high) {
45
- const mid = (low + high + 1) >> 1;
46
- if (lineStarts[mid] <= position) low = mid; else high = mid - 1;
47
- }
48
- return low + 1;
49
- };
50
- const visit = (node, inType) => {
51
- const kind = node.kind;
52
- if (kind >= SyntaxKind.FirstJSDocNode && kind <= SyntaxKind.LastJSDocNode) return;
53
- if (kind === SyntaxKind.JsxText && node.containsOnlyTriviaWhiteSpaces) return;
54
- const typeScope = inType || kind === SyntaxKind.InterfaceDeclaration || kind === SyntaxKind.TypeAliasDeclaration;
55
- kinds.push(kind === SyntaxKind.Identifier || kind === SyntaxKind.PrivateIdentifier ? ID : literalKinds.has(kind) ? LIT : kind);
56
- lines.push(lineOf(node.getStart(sourceFile)));
57
- types.push(typeScope ? 1 : 0);
58
- ts.forEachChild(node, (child) => { visit(child, typeScope); });
59
- };
78
+ const walk = { ts, sourceFile, kinds: [], lines: [], types: [], lineStarts: sourceFile.getLineStarts(), literalKinds: literalKindSet(SyntaxKind) };
60
79
  for (const statement of sourceFile.statements) {
61
- if (statement.kind === SyntaxKind.ImportDeclaration || statement.kind === SyntaxKind.ImportEqualsDeclaration
62
- || (statement.kind === SyntaxKind.ExportDeclaration && statement.moduleSpecifier)) continue;
63
- visit(statement, false);
80
+ if (!isDroppedStatement(SyntaxKind, statement)) visitToken(walk, statement, false);
64
81
  }
65
- return { kinds: Int32Array.from(kinds), lines: Int32Array.from(lines), types: Uint8Array.from(types) };
82
+ return { kinds: Int32Array.from(walk.kinds), lines: Int32Array.from(walk.lines), types: Uint8Array.from(walk.types) };
66
83
  }
67
84
 
68
85
  /** The app a file belongs to, by the slot binding of the manifest; null for a file of no app. */
@@ -86,17 +103,7 @@ function homeText(profile, sameOwner, crossApp) {
86
103
  return "move the shared helper to src/modules/platform/primitives/ or into the owning capability's src/modules/domain/<capability>/ and import it from both";
87
104
  }
88
105
 
89
- export function checkClones({ config, context, graph } = {}) {
90
- const { lines: N, tokens: T } = graph.resolver.ruleParams().duplicateBlock;
91
- const ts = context.ts ?? context.loaded.ts;
92
- const entries = [];
93
- for (const rel of [...graph.files.keys()].sort()) {
94
- if (/\.d\.[cm]?tsx?$/.test(rel) || uniformByDesign(graph.resolver, rel)) continue;
95
- const unit = graph.unit(rel);
96
- if (!unit) continue;
97
- entries.push({ rel, unit, ...tokenize(ts, graph.files.get(rel).sourceFile) });
98
- }
99
- // Rolling hash: two independent 32-bit polynomial prefix hashes, verified by token comparison on every match.
106
+ const cloneBuckets = (entries, N, T) => {
100
107
  const B1 = 1000003;
101
108
  const B2 = 92821;
102
109
  let maxTokens = 0;
@@ -106,63 +113,109 @@ export function checkClones({ config, context, graph } = {}) {
106
113
  pow1[0] = 1; pow2[0] = 1;
107
114
  for (let i = 1; i <= maxTokens; i += 1) { pow1[i] = Math.imul(pow1[i - 1], B1); pow2[i] = Math.imul(pow2[i - 1], B2); }
108
115
  const buckets = new Map();
109
- entries.forEach((file, fileIndex) => {
110
- const { kinds, lines } = file;
111
- const count = kinds.length;
112
- const pre1 = new Int32Array(count + 1);
113
- const pre2 = new Int32Array(count + 1);
114
- for (let i = 0; i < count; i += 1) {
115
- pre1[i + 1] = (Math.imul(pre1[i], B1) + kinds[i] + 3) | 0;
116
- pre2[i + 1] = (Math.imul(pre2[i], B2) + kinds[i] + 7) | 0;
117
- }
118
- let end = 0;
119
- for (let start = 0; start < count; start += 1) {
120
- if (start > 0 && lines[start] === lines[start - 1]) continue;
121
- const limit = lines[start] + N;
122
- if (end < start) end = start;
123
- while (end < count && lines[end] < limit) end += 1;
124
- if (end === start || lines[end - 1] - lines[start] + 1 < N) continue;
125
- const length = end - start;
126
- if (length < T) continue;
127
- const key = `${(pre1[end] - Math.imul(pre1[start], pow1[length])) | 0}:${(pre2[end] - Math.imul(pre2[start], pow2[length])) | 0}:${length}`;
128
- const list = buckets.get(key);
129
- const item = [fileIndex, start, end];
130
- if (list) list.push(item); else buckets.set(key, [item]);
131
- }
132
- });
133
- const same = (a, b) => {
134
- const ka = entries[a[0]].kinds;
135
- const kb = entries[b[0]].kinds;
136
- for (let i = 0; i < a[2] - a[1]; i += 1) if (ka[a[1] + i] !== kb[b[1] + i]) return false;
137
- return true;
138
- };
139
- // Hits per (file a, file b, offset): merged into maximal blocks below.
116
+ const hashing = { pow1, pow2, B1, B2 };
117
+ entries.forEach((file, fileIndex) => addFileWindows(buckets, file, fileIndex, N, T, hashing));
118
+ return buckets;
119
+ };
120
+
121
+ function signedInt32(value) {
122
+ return Math.imul(value, 1);
123
+ }
124
+
125
+ const prefixHashes = (kinds, B1, B2) => {
126
+ const count = kinds.length;
127
+ const pre1 = new Int32Array(count + 1);
128
+ const pre2 = new Int32Array(count + 1);
129
+ for (let i = 0; i < count; i += 1) {
130
+ pre1[i + 1] = Math.imul(pre1[i], B1) + kinds[i] + 3;
131
+ pre2[i + 1] = Math.imul(pre2[i], B2) + kinds[i] + 7;
132
+ }
133
+ return { pre1, pre2 };
134
+ };
135
+
136
+ const windowKey = (pre, pow, start, end) => signedInt32(pre[end] - Math.imul(pre[start], pow[end - start]));
137
+
138
+ const addToBucket = (buckets, key, item) => {
139
+ const list = buckets.get(key);
140
+ if (list) list.push(item); else buckets.set(key, [item]);
141
+ };
142
+
143
+ const addFileWindows = (buckets, file, fileIndex, N, T, hashing) => {
144
+ const { pow1, pow2, B1, B2 } = hashing;
145
+ const { kinds, lines } = file;
146
+ const count = kinds.length;
147
+ const { pre1, pre2 } = prefixHashes(kinds, B1, B2);
148
+ let end = 0;
149
+ for (let start = 0; start < count; start += 1) {
150
+ if (start > 0 && lines[start] === lines[start - 1]) continue;
151
+ const limit = lines[start] + N;
152
+ if (end < start) end = start;
153
+ while (end < count && lines[end] < limit) end += 1;
154
+ if (end === start || lines[end - 1] - lines[start] + 1 < N) continue;
155
+ const length = end - start;
156
+ if (length < T) continue;
157
+ const key = `${windowKey(pre1, pow1, start, end)}:${windowKey(pre2, pow2, start, end)}:${length}`;
158
+ addToBucket(buckets, key, [fileIndex, start, end]);
159
+ }
160
+ };
161
+
162
+ const sameTokens = (entries, a, b) => {
163
+ const ka = entries[a[0]].kinds;
164
+ const kb = entries[b[0]].kinds;
165
+ for (let i = 0; i < a[2] - a[1]; i += 1) if (ka[a[1] + i] !== kb[b[1] + i]) return false;
166
+ return true;
167
+ };
168
+
169
+ /** The first other occurrence is the partner; overlapping windows of one file are one occurrence, not a copy. */
170
+ const partnerOf = (list, item) => list.find((other) => other !== item && (other[0] !== item[0] || other[1] >= item[2] || item[1] >= other[2]));
171
+
172
+ const orderedPair = (entries, item, partner) => {
173
+ const sameFile = entries[item[0]].rel === entries[partner[0]].rel;
174
+ const itemFirst = sameFile ? item[1] < partner[1] : entries[item[0]].rel < entries[partner[0]].rel;
175
+ return itemFirst ? [item, partner] : [partner, item];
176
+ };
177
+
178
+ const addHit = (hits, a, b) => {
179
+ const key = `${a[0]}|${b[0]}|${b[1] - a[1]}`;
180
+ const group = hits.get(key);
181
+ if (group) group.push([a[1], a[2]]); else hits.set(key, [[a[1], a[2]]]);
182
+ };
183
+
184
+ const cloneHits = (entries, buckets) => {
140
185
  const hits = new Map();
141
186
  for (const list of buckets.values()) {
142
187
  if (list.length < 2) continue;
143
188
  for (const item of list) {
144
- // The first other occurrence is the partner; overlapping windows of one file are one occurrence, not a copy.
145
- const partner = list.find((other) => other !== item && (other[0] !== item[0] || other[1] >= item[2] || item[1] >= other[2]));
146
- if (!partner || !same(item, partner)) continue;
147
- const order = entries[item[0]].rel === entries[partner[0]].rel ? item[1] < partner[1] : entries[item[0]].rel < entries[partner[0]].rel;
148
- const [a, b] = order ? [item, partner] : [partner, item];
149
- const key = `${a[0]}|${b[0]}|${b[1] - a[1]}`;
150
- const group = hits.get(key);
151
- if (group) group.push([a[1], a[2]]); else hits.set(key, [[a[1], a[2]]]);
189
+ const partner = partnerOf(list, item);
190
+ if (!partner || !sameTokens(entries, item, partner)) continue;
191
+ const [a, b] = orderedPair(entries, item, partner);
192
+ addHit(hits, a, b);
152
193
  }
153
194
  }
195
+ return hits;
196
+ };
197
+
198
+ const blockShape = (file, start, end) => {
199
+ const nonType = new Set();
200
+ const shapes = new Map();
201
+ for (let i = start; i < end; i += 1) {
202
+ if (!file.types[i]) nonType.add(file.lines[i]);
203
+ shapes.set(file.lines[i], signedInt32((shapes.get(file.lines[i]) ?? 7) * 31 + file.kinds[i] + 5));
204
+ }
205
+ return { nonType: nonType.size, distinct: new Set(shapes.values()).size };
206
+ };
207
+
208
+ const appendCloneBlock = (blocks, fileA, fileB, start, end, diag, N) => {
209
+ const shape = blockShape(fileA, start, end);
210
+ if (shape.nonType < Math.ceil(N / 2) || shape.distinct < Math.ceil(N / 3)) return;
211
+ const lineA = fileA.lines[start];
212
+ const endA = fileA.lines[end - 1];
213
+ blocks.push({ a: fileA, b: fileB, line: lineA, endLine: endA, lines: endA - lineA + 1,
214
+ twin: { path: fileB.rel, line: fileB.lines[start + diag], endLine: fileB.lines[end - 1 + diag] } });
215
+ };
216
+
217
+ const cloneBlocks = (entries, hits, N) => {
154
218
  const blocks = [];
155
- // Shape of a block: the lines outside type declarations and the number of distinct line shapes (a field list of
156
- // one decorated DTO shape repeated N times is boilerplate, not a copied helper).
157
- const blockShape = (file, start, end) => {
158
- const nonType = new Set();
159
- const shapes = new Map();
160
- for (let i = start; i < end; i += 1) {
161
- if (!file.types[i]) nonType.add(file.lines[i]);
162
- shapes.set(file.lines[i], ((shapes.get(file.lines[i]) ?? 7) * 31 + file.kinds[i] + 5) | 0);
163
- }
164
- return { nonType: nonType.size, distinct: new Set(shapes.values()).size };
165
- };
166
219
  for (const [key, group] of hits) {
167
220
  const [ai, bi, diagText] = key.split('|');
168
221
  const fileA = entries[Number(ai)];
@@ -170,31 +223,51 @@ export function checkClones({ config, context, graph } = {}) {
170
223
  const diag = Number(diagText);
171
224
  group.sort((x, y) => x[0] - y[0]);
172
225
  let [start, end] = group[0];
173
- const flush = () => {
174
- const shape = blockShape(fileA, start, end);
175
- if (shape.nonType < Math.ceil(N / 2) || shape.distinct < Math.ceil(N / 3)) return;
176
- const lineA = fileA.lines[start];
177
- const endA = fileA.lines[end - 1];
178
- blocks.push({ a: fileA, b: fileB, line: lineA, endLine: endA, lines: endA - lineA + 1,
179
- twin: { path: fileB.rel, line: fileB.lines[start + diag], endLine: fileB.lines[end - 1 + diag] } });
180
- };
181
226
  for (const [nextStart, nextEnd] of group.slice(1)) {
182
227
  if (nextStart <= end) { end = Math.max(end, nextEnd); continue; }
183
- flush();
228
+ appendCloneBlock(blocks, fileA, fileB, start, end, diag, N);
184
229
  start = nextStart; end = nextEnd;
185
230
  }
186
- flush();
231
+ appendCloneBlock(blocks, fileA, fileB, start, end, diag, N);
187
232
  }
233
+ return blocks;
234
+ };
235
+
236
+ const differentApps = (resolver, a, b) => {
237
+ const left = appOfFile(resolver, a);
238
+ const right = appOfFile(resolver, b);
239
+ return left !== null && right !== null && left !== right;
240
+ };
241
+
242
+ const cloneViolations = (blocks, resolver, profile, N, T) => {
188
243
  blocks.sort((x, y) => x.a.rel.localeCompare(y.a.rel) || x.line - y.line || x.b.rel.localeCompare(y.b.rel));
189
- const profile = graph.profile;
190
- const differentApps = (a, b) => { const left = appOfFile(graph.resolver, a); const right = appOfFile(graph.resolver, b); return left !== null && right !== null && left !== right; };
191
- const violations = blocks.slice(0, MAX_VIOLATIONS).map((block) => ({
244
+ return blocks.slice(0, MAX_VIOLATIONS).map((block) => ({
192
245
  ruleId: 'HFS_DUPLICATE_CODE', path: block.a.rel, line: block.line, endLine: block.endLine, twin: block.twin, lines: block.lines,
193
- message: `${block.a.rel}:${block.line}-${block.endLine} duplicates ${block.twin.path}:${block.twin.line}-${block.twin.endLine} (${block.lines} lines, threshold ${N} lines / ${T} tokens, identifiers and literals ignored); ${homeText(profile, block.a.unit === block.b.unit, differentApps(block.a.rel, block.b.rel))}.`,
246
+ message: `${block.a.rel}:${block.line}-${block.endLine} duplicates ${block.twin.path}:${block.twin.line}-${block.twin.endLine} (${block.lines} lines, threshold ${N} lines / ${T} tokens, identifiers and literals ignored); ${homeText(profile, block.a.unit === block.b.unit, differentApps(resolver, block.a.rel, block.b.rel))}.`,
194
247
  }));
248
+ };
249
+
250
+ const cloneCoverage = (entries, blocks, N, T) => {
195
251
  const owners = new Set();
196
252
  let duplicatedLines = 0;
197
253
  for (const block of blocks) { owners.add(block.a.unit); owners.add(block.b.unit); duplicatedLines += block.lines; }
198
- return { violations, coverage: { status: 'checked', minLines: N, minTokens: T, files: entries.length, cloneBlocks: blocks.length, ownersInvolved: owners.size, duplicatedLines,
199
- ...(blocks.length > MAX_VIOLATIONS ? { truncated: true } : {}) } };
254
+ return { status: 'checked', minLines: N, minTokens: T, files: entries.length, cloneBlocks: blocks.length, ownersInvolved: owners.size, duplicatedLines,
255
+ ...(blocks.length > MAX_VIOLATIONS ? { truncated: true } : {}) };
256
+ };
257
+
258
+ export function checkClones({ config, context, graph } = {}) {
259
+ const { lines: N, tokens: T } = graph.resolver.ruleParams().duplicateBlock;
260
+ const ts = context.ts ?? context.loaded.ts;
261
+ const entries = [];
262
+ for (const rel of [...graph.files.keys()].sort(byCodeUnit)) {
263
+ if (/\.d\.[cm]?tsx?$/.test(rel) || uniformByDesign(graph.resolver, rel)) continue;
264
+ const unit = graph.unit(rel);
265
+ if (!unit) continue;
266
+ entries.push({ rel, unit, ...tokenize(ts, graph.files.get(rel).sourceFile) });
267
+ }
268
+ const buckets = cloneBuckets(entries, N, T);
269
+ const blocks = cloneBlocks(entries, cloneHits(entries, buckets), N);
270
+ const profile = graph.profile;
271
+ const violations = cloneViolations(blocks, graph.resolver, profile, N, T);
272
+ return { violations, coverage: cloneCoverage(entries, blocks, N, T) };
200
273
  }