@endora-commerce/cli 0.100.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.
- package/LICENSE +21 -0
- package/README.md +37 -0
- package/dist/bin/endora.d.ts +28 -0
- package/dist/bin/endora.d.ts.map +1 -0
- package/dist/bin/endora.js +926 -0
- package/dist/bin/endora.js.map +1 -0
- package/dist/check/estate.d.ts +189 -0
- package/dist/check/estate.d.ts.map +1 -0
- package/dist/check/estate.js +1037 -0
- package/dist/check/estate.js.map +1 -0
- package/dist/check/hosts.d.ts +25 -0
- package/dist/check/hosts.d.ts.map +1 -0
- package/dist/check/hosts.js +1347 -0
- package/dist/check/hosts.js.map +1 -0
- package/dist/check/index.d.ts +60 -0
- package/dist/check/index.d.ts.map +1 -0
- package/dist/check/index.js +111 -0
- package/dist/check/index.js.map +1 -0
- package/dist/check/layout.d.ts +136 -0
- package/dist/check/layout.d.ts.map +1 -0
- package/dist/check/layout.js +262 -0
- package/dist/check/layout.js.map +1 -0
- package/dist/check/ledger.d.ts +98 -0
- package/dist/check/ledger.d.ts.map +1 -0
- package/dist/check/ledger.js +173 -0
- package/dist/check/ledger.js.map +1 -0
- package/dist/check/peer-owners.d.ts +116 -0
- package/dist/check/peer-owners.d.ts.map +1 -0
- package/dist/check/peer-owners.js +225 -0
- package/dist/check/peer-owners.js.map +1 -0
- package/dist/check/report.d.ts +33 -0
- package/dist/check/report.d.ts.map +1 -0
- package/dist/check/report.js +107 -0
- package/dist/check/report.js.map +1 -0
- package/dist/check/run.d.ts +147 -0
- package/dist/check/run.d.ts.map +1 -0
- package/dist/check/run.js +111 -0
- package/dist/check/run.js.map +1 -0
- package/dist/checks.d.ts +17 -0
- package/dist/checks.d.ts.map +1 -0
- package/dist/checks.js +17 -0
- package/dist/checks.js.map +1 -0
- package/dist/dev/index.d.ts +83 -0
- package/dist/dev/index.d.ts.map +1 -0
- package/dist/dev/index.js +298 -0
- package/dist/dev/index.js.map +1 -0
- package/dist/generate/divergence.d.ts +38 -0
- package/dist/generate/divergence.d.ts.map +1 -0
- package/dist/generate/divergence.js +237 -0
- package/dist/generate/divergence.js.map +1 -0
- package/dist/generate/index.d.ts +90 -0
- package/dist/generate/index.d.ts.map +1 -0
- package/dist/generate/index.js +369 -0
- package/dist/generate/index.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/inputs/declaration.d.ts +18 -0
- package/dist/inputs/declaration.d.ts.map +1 -0
- package/dist/inputs/declaration.js +64 -0
- package/dist/inputs/declaration.js.map +1 -0
- package/dist/inputs/env-file.d.ts +73 -0
- package/dist/inputs/env-file.d.ts.map +1 -0
- package/dist/inputs/env-file.js +134 -0
- package/dist/inputs/env-file.js.map +1 -0
- package/dist/inputs/prompt.d.ts +21 -0
- package/dist/inputs/prompt.d.ts.map +1 -0
- package/dist/inputs/prompt.js +59 -0
- package/dist/inputs/prompt.js.map +1 -0
- package/dist/inputs/resolve.d.ts +163 -0
- package/dist/inputs/resolve.d.ts.map +1 -0
- package/dist/inputs/resolve.js +290 -0
- package/dist/inputs/resolve.js.map +1 -0
- package/dist/install/host.d.ts +27 -0
- package/dist/install/host.d.ts.map +1 -0
- package/dist/install/host.js +90 -0
- package/dist/install/host.js.map +1 -0
- package/dist/install/index.d.ts +173 -0
- package/dist/install/index.d.ts.map +1 -0
- package/dist/install/index.js +793 -0
- package/dist/install/index.js.map +1 -0
- package/dist/install/wizard.d.ts +144 -0
- package/dist/install/wizard.d.ts.map +1 -0
- package/dist/install/wizard.js +362 -0
- package/dist/install/wizard.js.map +1 -0
- package/dist/lib/admin-artefacts.d.ts +70 -0
- package/dist/lib/admin-artefacts.d.ts.map +1 -0
- package/dist/lib/admin-artefacts.js +354 -0
- package/dist/lib/admin-artefacts.js.map +1 -0
- package/dist/lib/admin-surfaces.d.ts +298 -0
- package/dist/lib/admin-surfaces.d.ts.map +1 -0
- package/dist/lib/admin-surfaces.js +669 -0
- package/dist/lib/admin-surfaces.js.map +1 -0
- package/dist/lib/delegated-composer.d.ts +85 -0
- package/dist/lib/delegated-composer.d.ts.map +1 -0
- package/dist/lib/delegated-composer.js +241 -0
- package/dist/lib/delegated-composer.js.map +1 -0
- package/dist/lib/divergence-artefacts.d.ts +305 -0
- package/dist/lib/divergence-artefacts.d.ts.map +1 -0
- package/dist/lib/divergence-artefacts.js +828 -0
- package/dist/lib/divergence-artefacts.js.map +1 -0
- package/dist/lib/divergence.d.ts +337 -0
- package/dist/lib/divergence.d.ts.map +1 -0
- package/dist/lib/divergence.js +1005 -0
- package/dist/lib/divergence.js.map +1 -0
- package/dist/lib/docs-artefacts.d.ts +395 -0
- package/dist/lib/docs-artefacts.d.ts.map +1 -0
- package/dist/lib/docs-artefacts.js +781 -0
- package/dist/lib/docs-artefacts.js.map +1 -0
- package/dist/lib/emitted-exports.d.ts +21 -0
- package/dist/lib/emitted-exports.d.ts.map +1 -0
- package/dist/lib/emitted-exports.js +96 -0
- package/dist/lib/emitted-exports.js.map +1 -0
- package/dist/lib/emitted-freshness.d.ts +112 -0
- package/dist/lib/emitted-freshness.d.ts.map +1 -0
- package/dist/lib/emitted-freshness.js +288 -0
- package/dist/lib/emitted-freshness.js.map +1 -0
- package/dist/lib/entity-index-artefact.d.ts +95 -0
- package/dist/lib/entity-index-artefact.d.ts.map +1 -0
- package/dist/lib/entity-index-artefact.js +210 -0
- package/dist/lib/entity-index-artefact.js.map +1 -0
- package/dist/lib/instance-build-inputs.d.ts +108 -0
- package/dist/lib/instance-build-inputs.d.ts.map +1 -0
- package/dist/lib/instance-build-inputs.js +165 -0
- package/dist/lib/instance-build-inputs.js.map +1 -0
- package/dist/lib/module-docs.d.ts +473 -0
- package/dist/lib/module-docs.d.ts.map +1 -0
- package/dist/lib/module-docs.js +711 -0
- package/dist/lib/module-docs.js.map +1 -0
- package/dist/lib/module-package-subpaths.d.ts +56 -0
- package/dist/lib/module-package-subpaths.d.ts.map +1 -0
- package/dist/lib/module-package-subpaths.js +223 -0
- package/dist/lib/module-package-subpaths.js.map +1 -0
- package/dist/lib/module-packages.d.ts +200 -0
- package/dist/lib/module-packages.d.ts.map +1 -0
- package/dist/lib/module-packages.js +580 -0
- package/dist/lib/module-packages.js.map +1 -0
- package/dist/lib/module-population.d.ts +129 -0
- package/dist/lib/module-population.d.ts.map +1 -0
- package/dist/lib/module-population.js +172 -0
- package/dist/lib/module-population.js.map +1 -0
- package/dist/lib/module-roots.d.ts +337 -0
- package/dist/lib/module-roots.d.ts.map +1 -0
- package/dist/lib/module-roots.js +586 -0
- package/dist/lib/module-roots.js.map +1 -0
- package/dist/lib/nested-checkouts.d.ts +33 -0
- package/dist/lib/nested-checkouts.d.ts.map +1 -0
- package/dist/lib/nested-checkouts.js +160 -0
- package/dist/lib/nested-checkouts.js.map +1 -0
- package/dist/lib/platform-root.d.ts +43 -0
- package/dist/lib/platform-root.d.ts.map +1 -0
- package/dist/lib/platform-root.js +134 -0
- package/dist/lib/platform-root.js.map +1 -0
- package/dist/lib/platform-surface.d.ts +235 -0
- package/dist/lib/platform-surface.d.ts.map +1 -0
- package/dist/lib/platform-surface.js +393 -0
- package/dist/lib/platform-surface.js.map +1 -0
- package/dist/lib/port-registrations.d.ts +223 -0
- package/dist/lib/port-registrations.d.ts.map +1 -0
- package/dist/lib/port-registrations.js +532 -0
- package/dist/lib/port-registrations.js.map +1 -0
- package/dist/lib/read-size.d.ts +154 -0
- package/dist/lib/read-size.d.ts.map +1 -0
- package/dist/lib/read-size.js +182 -0
- package/dist/lib/read-size.js.map +1 -0
- package/dist/lib/registration-owners.d.ts +79 -0
- package/dist/lib/registration-owners.d.ts.map +1 -0
- package/dist/lib/registration-owners.js +77 -0
- package/dist/lib/registration-owners.js.map +1 -0
- package/dist/lib/release-index.d.ts +53 -0
- package/dist/lib/release-index.d.ts.map +1 -0
- package/dist/lib/release-index.js +162 -0
- package/dist/lib/release-index.js.map +1 -0
- package/dist/lib/repeating-timers.d.ts +79 -0
- package/dist/lib/repeating-timers.d.ts.map +1 -0
- package/dist/lib/repeating-timers.js +189 -0
- package/dist/lib/repeating-timers.js.map +1 -0
- package/dist/lib/source-text.d.ts +34 -0
- package/dist/lib/source-text.d.ts.map +1 -0
- package/dist/lib/source-text.js +80 -0
- package/dist/lib/source-text.js.map +1 -0
- package/dist/lib/specifiers.d.ts +20 -0
- package/dist/lib/specifiers.d.ts.map +1 -0
- package/dist/lib/specifiers.js +130 -0
- package/dist/lib/specifiers.js.map +1 -0
- package/dist/lib/sql-tables.d.ts +166 -0
- package/dist/lib/sql-tables.d.ts.map +1 -0
- package/dist/lib/sql-tables.js +464 -0
- package/dist/lib/sql-tables.js.map +1 -0
- package/dist/lib/switchable-modules.d.ts +54 -0
- package/dist/lib/switchable-modules.d.ts.map +1 -0
- package/dist/lib/switchable-modules.js +104 -0
- package/dist/lib/switchable-modules.js.map +1 -0
- package/dist/lib/tailwind-sources.d.ts +136 -0
- package/dist/lib/tailwind-sources.d.ts.map +1 -0
- package/dist/lib/tailwind-sources.js +307 -0
- package/dist/lib/tailwind-sources.js.map +1 -0
- package/dist/lib/ui-layer.d.ts +54 -0
- package/dist/lib/ui-layer.d.ts.map +1 -0
- package/dist/lib/ui-layer.js +57 -0
- package/dist/lib/ui-layer.js.map +1 -0
- package/dist/lib/workspace-packages.d.ts +186 -0
- package/dist/lib/workspace-packages.d.ts.map +1 -0
- package/dist/lib/workspace-packages.js +351 -0
- package/dist/lib/workspace-packages.js.map +1 -0
- package/dist/new-instance/deploy.d.ts +211 -0
- package/dist/new-instance/deploy.d.ts.map +1 -0
- package/dist/new-instance/deploy.js +1381 -0
- package/dist/new-instance/deploy.js.map +1 -0
- package/dist/new-instance/docs-toolchain.d.ts +66 -0
- package/dist/new-instance/docs-toolchain.d.ts.map +1 -0
- package/dist/new-instance/docs-toolchain.js +69 -0
- package/dist/new-instance/docs-toolchain.js.map +1 -0
- package/dist/new-instance/host.d.ts +124 -0
- package/dist/new-instance/host.d.ts.map +1 -0
- package/dist/new-instance/host.js +276 -0
- package/dist/new-instance/host.js.map +1 -0
- package/dist/new-instance/index.d.ts +118 -0
- package/dist/new-instance/index.d.ts.map +1 -0
- package/dist/new-instance/index.js +567 -0
- package/dist/new-instance/index.js.map +1 -0
- package/dist/new-instance/modules.d.ts +188 -0
- package/dist/new-instance/modules.d.ts.map +1 -0
- package/dist/new-instance/modules.js +392 -0
- package/dist/new-instance/modules.js.map +1 -0
- package/dist/new-instance/template.d.ts +505 -0
- package/dist/new-instance/template.d.ts.map +1 -0
- package/dist/new-instance/template.js +1886 -0
- package/dist/new-instance/template.js.map +1 -0
- package/dist/new-module/emit.d.ts +67 -0
- package/dist/new-module/emit.d.ts.map +1 -0
- package/dist/new-module/emit.js +1393 -0
- package/dist/new-module/emit.js.map +1 -0
- package/dist/new-module/host.d.ts +67 -0
- package/dist/new-module/host.d.ts.map +1 -0
- package/dist/new-module/host.js +224 -0
- package/dist/new-module/host.js.map +1 -0
- package/dist/new-module/index.d.ts +32 -0
- package/dist/new-module/index.d.ts.map +1 -0
- package/dist/new-module/index.js +193 -0
- package/dist/new-module/index.js.map +1 -0
- package/dist/new-module/spec.d.ts +188 -0
- package/dist/new-module/spec.d.ts.map +1 -0
- package/dist/new-module/spec.js +404 -0
- package/dist/new-module/spec.js.map +1 -0
- package/dist/new-module/text.d.ts +15 -0
- package/dist/new-module/text.d.ts.map +1 -0
- package/dist/new-module/text.js +22 -0
- package/dist/new-module/text.js.map +1 -0
- package/dist/new-storefront/dockerfile.d.ts +20 -0
- package/dist/new-storefront/dockerfile.d.ts.map +1 -0
- package/dist/new-storefront/dockerfile.js +131 -0
- package/dist/new-storefront/dockerfile.js.map +1 -0
- package/dist/new-storefront/gitignore.d.ts +23 -0
- package/dist/new-storefront/gitignore.d.ts.map +1 -0
- package/dist/new-storefront/gitignore.js +39 -0
- package/dist/new-storefront/gitignore.js.map +1 -0
- package/dist/new-storefront/index.d.ts +93 -0
- package/dist/new-storefront/index.d.ts.map +1 -0
- package/dist/new-storefront/index.js +329 -0
- package/dist/new-storefront/index.js.map +1 -0
- package/dist/new-storefront/npmrc.d.ts +98 -0
- package/dist/new-storefront/npmrc.d.ts.map +1 -0
- package/dist/new-storefront/npmrc.js +217 -0
- package/dist/new-storefront/npmrc.js.map +1 -0
- package/dist/new-storefront/reference.d.ts +189 -0
- package/dist/new-storefront/reference.d.ts.map +1 -0
- package/dist/new-storefront/reference.js +430 -0
- package/dist/new-storefront/reference.js.map +1 -0
- package/dist/new-storefront/rewrite.d.ts +171 -0
- package/dist/new-storefront/rewrite.d.ts.map +1 -0
- package/dist/new-storefront/rewrite.js +701 -0
- package/dist/new-storefront/rewrite.js.map +1 -0
- package/dist/release-index.json +284 -0
- package/dist/rules/action-route-permissions.d.ts +141 -0
- package/dist/rules/action-route-permissions.d.ts.map +1 -0
- package/dist/rules/action-route-permissions.js +556 -0
- package/dist/rules/action-route-permissions.js.map +1 -0
- package/dist/rules/bundle-pairing.d.ts +74 -0
- package/dist/rules/bundle-pairing.d.ts.map +1 -0
- package/dist/rules/bundle-pairing.js +281 -0
- package/dist/rules/bundle-pairing.js.map +1 -0
- package/dist/rules/channel-resolution.d.ts +17 -0
- package/dist/rules/channel-resolution.d.ts.map +1 -0
- package/dist/rules/channel-resolution.js +382 -0
- package/dist/rules/channel-resolution.js.map +1 -0
- package/dist/rules/command-coverage.d.ts +210 -0
- package/dist/rules/command-coverage.d.ts.map +1 -0
- package/dist/rules/command-coverage.js +714 -0
- package/dist/rules/command-coverage.js.map +1 -0
- package/dist/rules/container-imports.d.ts +60 -0
- package/dist/rules/container-imports.d.ts.map +1 -0
- package/dist/rules/container-imports.js +158 -0
- package/dist/rules/container-imports.js.map +1 -0
- package/dist/rules/default-language-prose.d.ts +212 -0
- package/dist/rules/default-language-prose.d.ts.map +1 -0
- package/dist/rules/default-language-prose.js +710 -0
- package/dist/rules/default-language-prose.js.map +1 -0
- package/dist/rules/diacritic-folds.d.ts +238 -0
- package/dist/rules/diacritic-folds.d.ts.map +1 -0
- package/dist/rules/diacritic-folds.js +681 -0
- package/dist/rules/diacritic-folds.js.map +1 -0
- package/dist/rules/entity-tenant-classification.d.ts +171 -0
- package/dist/rules/entity-tenant-classification.d.ts.map +1 -0
- package/dist/rules/entity-tenant-classification.js +323 -0
- package/dist/rules/entity-tenant-classification.js.map +1 -0
- package/dist/rules/entry-presence.d.ts +142 -0
- package/dist/rules/entry-presence.d.ts.map +1 -0
- package/dist/rules/entry-presence.js +339 -0
- package/dist/rules/entry-presence.js.map +1 -0
- package/dist/rules/entry-scope.d.ts +91 -0
- package/dist/rules/entry-scope.d.ts.map +1 -0
- package/dist/rules/entry-scope.js +404 -0
- package/dist/rules/entry-scope.js.map +1 -0
- package/dist/rules/env-inputs.d.ts +222 -0
- package/dist/rules/env-inputs.d.ts.map +1 -0
- package/dist/rules/env-inputs.js +951 -0
- package/dist/rules/env-inputs.js.map +1 -0
- package/dist/rules/kernel-boundary.d.ts +37 -0
- package/dist/rules/kernel-boundary.d.ts.map +1 -0
- package/dist/rules/kernel-boundary.js +195 -0
- package/dist/rules/kernel-boundary.js.map +1 -0
- package/dist/rules/nul-bytes.d.ts +233 -0
- package/dist/rules/nul-bytes.d.ts.map +1 -0
- package/dist/rules/nul-bytes.js +332 -0
- package/dist/rules/nul-bytes.js.map +1 -0
- package/dist/rules/platform-surface.d.ts +479 -0
- package/dist/rules/platform-surface.d.ts.map +1 -0
- package/dist/rules/platform-surface.js +749 -0
- package/dist/rules/platform-surface.js.map +1 -0
- package/dist/rules/port-catches.d.ts +225 -0
- package/dist/rules/port-catches.d.ts.map +1 -0
- package/dist/rules/port-catches.js +1374 -0
- package/dist/rules/port-catches.js.map +1 -0
- package/dist/rules/port-shape.d.ts +213 -0
- package/dist/rules/port-shape.d.ts.map +1 -0
- package/dist/rules/port-shape.js +670 -0
- package/dist/rules/port-shape.js.map +1 -0
- package/dist/rules/queue-names.d.ts +108 -0
- package/dist/rules/queue-names.d.ts.map +1 -0
- package/dist/rules/queue-names.js +395 -0
- package/dist/rules/queue-names.js.map +1 -0
- package/dist/rules/singleton-identity.d.ts +205 -0
- package/dist/rules/singleton-identity.d.ts.map +1 -0
- package/dist/rules/singleton-identity.js +830 -0
- package/dist/rules/singleton-identity.js.map +1 -0
- package/dist/rules/subscribe-seam.d.ts +121 -0
- package/dist/rules/subscribe-seam.d.ts.map +1 -0
- package/dist/rules/subscribe-seam.js +594 -0
- package/dist/rules/subscribe-seam.js.map +1 -0
- package/dist/rules/transaction-context.d.ts +40 -0
- package/dist/rules/transaction-context.d.ts.map +1 -0
- package/dist/rules/transaction-context.js +294 -0
- package/dist/rules/transaction-context.js.map +1 -0
- package/package.json +59 -0
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CI check — a module package's process singletons exist once (feature 080, T061;
|
|
3
|
+
* D-160.6 widened past entity classes).
|
|
4
|
+
*
|
|
5
|
+
* ## The mechanism
|
|
6
|
+
*
|
|
7
|
+
* The platform composes a module package through its **published artefact**: the
|
|
8
|
+
* generated composition imports `@endora-commerce/mod-<id>/backend`, which the
|
|
9
|
+
* package's `exports` map points at `dist`. A file that names the *same*
|
|
10
|
+
* package's **source** by filesystem path gets a second evaluation of that file
|
|
11
|
+
* and of everything on its import graph. Two copies in one process, and `tsc`
|
|
12
|
+
* cannot see it — the two spellings have identical types.
|
|
13
|
+
*
|
|
14
|
+
* T040c measured this for the platform's five directories: 59 runtime values,
|
|
15
|
+
* none shared, `instanceof HttpError` false across the boundary, every 404 from
|
|
16
|
+
* a packaged module a 500. D-160.6 wrote the rule down **about entity classes**,
|
|
17
|
+
* where the failure is a lookup miss: loud, at a known moment.
|
|
18
|
+
*
|
|
19
|
+
* **The asymmetry is the finding this check exists for.** A duplicated
|
|
20
|
+
* module-scope *value* fails **silently**. The second copy is simply empty, and
|
|
21
|
+
* every reader sees an empty registry rather than an error. It has now happened
|
|
22
|
+
* twice in two consecutive packaging batches:
|
|
23
|
+
*
|
|
24
|
+
* * batch one — seven files acquired a second, empty `paymentAdapterRegistry`;
|
|
25
|
+
* * batch two — `backend/test/integration/shipments/carrier-module-off.test.ts`
|
|
26
|
+
* registered its synthetic carrier into a second, empty
|
|
27
|
+
* `ShippingAdapterRegistry`. The adapter was never *found*, so the shipment
|
|
28
|
+
* path took its "no carrier configured" branch and reported success: a
|
|
29
|
+
* switched-off module read as **present**, which is the exact state that
|
|
30
|
+
* file exists to refuse (Principle XVII item 6). A silent duplication
|
|
31
|
+
* producing a silent fail-open, two layers down from the duplication.
|
|
32
|
+
*
|
|
33
|
+
* Neither was visible to `tsc`, to any other check, or to `test:unit:fast` —
|
|
34
|
+
* both live under the contract and integration trees that command skips.
|
|
35
|
+
*
|
|
36
|
+
* **Batch four is the third, and it is neither silent nor a value** (T061a;
|
|
37
|
+
* D-160.6.1). `invoices` moved, and seven files in the test tree acquired a
|
|
38
|
+
* second `Invoice` — an entity class, so D-160.6's own subject, but a
|
|
39
|
+
* `@TransitivelyScoped` **parent**: `KsefSubmission` names it, the platform
|
|
40
|
+
* resolves a tenant chain by class *name* (D-169/D-170), and
|
|
41
|
+
* `assertTransitiveParentsResolve` refuses an ambiguous name rather than
|
|
42
|
+
* guessing. So this duplication is a **hard refusal at ORM init** —
|
|
43
|
+
* `UnresolvableTenantParentError` raised inside `setupBackendServer`, failing
|
|
44
|
+
* every file in the fork rather than the one with the bad import, which makes it
|
|
45
|
+
* the loudest of the three and the hardest to attribute.
|
|
46
|
+
*
|
|
47
|
+
* **And not one of the seven named the entity.** They import an `invoices`
|
|
48
|
+
* *service*; four of those services import the entity. The duplication therefore
|
|
49
|
+
* sat one hop *behind* the reached binding, and conjunct 2 asks about the
|
|
50
|
+
* binding — which is why this file's own header used to name the shape as out of
|
|
51
|
+
* reach. It is the third signal below.
|
|
52
|
+
*
|
|
53
|
+
* ## The rule, and why it is not "no module package source is ever reached"
|
|
54
|
+
*
|
|
55
|
+
* Several hundred value-position reaches into a module package's source stand in
|
|
56
|
+
* this tree — the run prints the number as `sites=`, and it grows by a module's
|
|
57
|
+
* worth every time the sweep packages one — and the overwhelming majority are
|
|
58
|
+
* correct: a unit test that constructs its own service and hands it a stubbed
|
|
59
|
+
* `EntityManager` has **one** copy in its process, so there is no second copy
|
|
60
|
+
* for it to disagree with (D-168 says so in `test/helpers/package-entities.ts`).
|
|
61
|
+
* A blanket rule would therefore be a ledger of nothing but exceptions, which is
|
|
62
|
+
* the shape `check:diacritic-folds`' header refuses, and it would have no
|
|
63
|
+
* available repair: a package's `exports` map publishes no service class, so
|
|
64
|
+
* there is nothing for most of those reaches to be rewritten to.
|
|
65
|
+
*
|
|
66
|
+
* So the rule is the mechanism itself, and it is a **conjunction**:
|
|
67
|
+
*
|
|
68
|
+
* 1. **Both copies are in one process.** The reaching file's own value-import
|
|
69
|
+
* closure also loads that package's published artefact — through the
|
|
70
|
+
* generated composition, through a test harness that composes it, or by
|
|
71
|
+
* naming the bare specifier itself. This is derived, never declared: it is
|
|
72
|
+
* what makes the five entity reaches in unit tests *correct* rather than
|
|
73
|
+
* ledgered.
|
|
74
|
+
* 2. **The platform composed the reached object.** Identity only matters where
|
|
75
|
+
* something else holds the other copy — so the binding is one the package's
|
|
76
|
+
* own composition hands to the container (`asValue` / `asFunction(() => x)`
|
|
77
|
+
* / `asClass`), or a member of the `entities` array the ORM registered.
|
|
78
|
+
*
|
|
79
|
+
* Both conjuncts come off the artefacts on every run. A thirtieth package, a new
|
|
80
|
+
* registry and a new entity are covered by existing, and no list of them is
|
|
81
|
+
* written down (D-100).
|
|
82
|
+
*
|
|
83
|
+
* ## Three findings
|
|
84
|
+
*
|
|
85
|
+
* * **`composed-singleton-reach`** — the conjunction above, with the binding
|
|
86
|
+
* named. **No ledger, deliberately**: the composed container and
|
|
87
|
+
* `test/helpers/package-singletons.ts` / `package-entities.ts` are the
|
|
88
|
+
* supported doors and are always available, so an entry could only license
|
|
89
|
+
* re-opening the defect.
|
|
90
|
+
* * **`chain-parent-reach`** — the same conjunction with conjunct 2 asked of
|
|
91
|
+
* the reach's **import closure** instead of the binding it names, narrowed
|
|
92
|
+
* to the one class of duplication the platform *refuses* rather than
|
|
93
|
+
* tolerates: a `@TransitivelyScoped` parent (batch four, above). Also no
|
|
94
|
+
* ledger, for the same reason and one more — a second copy of one of these
|
|
95
|
+
* stops the whole fork booting, so an entry could not describe a site that
|
|
96
|
+
* is right to stand.
|
|
97
|
+
*
|
|
98
|
+
* **The narrowing is the design, not a compromise.** The general shape —
|
|
99
|
+
* *any* composed singleton reached transitively — is every reach this run
|
|
100
|
+
* prints as `sites=`, most of them correct and most with no available repair
|
|
101
|
+
* (D-160.6.1's "deliberately not ruled"), so it would be a ledger of nothing
|
|
102
|
+
* but exceptions: the shape `check:diacritic-folds`' header refuses and the
|
|
103
|
+
* shape that got this check's third signal deleted. The chain parents are
|
|
104
|
+
* **derived** from the decorators on every run — two today (`Invoice`,
|
|
105
|
+
* `Order`), one of them in a package — so a third is covered by existing and
|
|
106
|
+
* no list of them is written down (D-100).
|
|
107
|
+
*
|
|
108
|
+
* **No depth limit, and the number is measured rather than picked.** On this
|
|
109
|
+
* tree the reverse walk terminates at **2** hops, and it costs **2.2 ms** of
|
|
110
|
+
* a 10.1 s run — 8011 value edges over 3234 files, one BFS per duplicated
|
|
111
|
+
* class rather than one per site. A cap at one hop would already be blind to
|
|
112
|
+
* two live consumers (`test/unit/invoices/auto-issue-reactor.test.ts` and
|
|
113
|
+
* `issue-route-email-outcome.test.ts`, both correct today because they
|
|
114
|
+
* compose nothing), and the next service extracted between a test and an
|
|
115
|
+
* entity moves a site past whatever number were written here.
|
|
116
|
+
* * **`whole-file-reach`** — a namespace, dynamic, side-effect or `require`
|
|
117
|
+
* reach into a package's source under conjunct 1. It names no binding, so
|
|
118
|
+
* it takes the file's whole graph whatever that is, and no per-binding
|
|
119
|
+
* predicate can judge it (`check:platform-surface`'s reasoning verbatim).
|
|
120
|
+
* Two-way ledger.
|
|
121
|
+
*
|
|
122
|
+
* ## What it cannot see, stated here rather than discovered later
|
|
123
|
+
*
|
|
124
|
+
* * A **computed** specifier — `await import(pathVariable)`. A literal
|
|
125
|
+
* dynamic import is `whole-file-reach`; a built one is outside every static
|
|
126
|
+
* check in this repository.
|
|
127
|
+
* * A module named as a **string** that is only *read* as text
|
|
128
|
+
* (`readFileSync(resolve(root, 'packages/modules/audit_logs/src/…'))`, one
|
|
129
|
+
* live site, deliberate and documented). That loads nothing, so it
|
|
130
|
+
* duplicates nothing; a string that is later *executed* would need dataflow
|
|
131
|
+
* the expression does not carry.
|
|
132
|
+
* * A singleton reached **transitively** that is *not* a tenant-chain parent —
|
|
133
|
+
* a test that imports a service class from a package's source, where that
|
|
134
|
+
* class's module body closes over the package's registry. `chain-parent-reach`
|
|
135
|
+
* walks the closure for the classes whose duplication is a refusal;
|
|
136
|
+
* everything else on it is still judged per binding, so a second copy that
|
|
137
|
+
* fails *silently* one hop down is invisible here. That is the narrowing
|
|
138
|
+
* three paragraphs above, and it is a decision rather than an oversight:
|
|
139
|
+
* D-160.6.1 leaves the general reach deliberately unruled, because a
|
|
140
|
+
* package's `exports` map publishes no service class and most of those
|
|
141
|
+
* reaches have no repair to be pointed at.
|
|
142
|
+
* * **A reach whose duplication only matters in a process it cannot name.**
|
|
143
|
+
* Conjunct 1 asks about the reaching file's own closure, which
|
|
144
|
+
* under-approximates for a file nothing runs on its own: a test helper
|
|
145
|
+
* reaching the harness through `import type` loads no artefact, while every
|
|
146
|
+
* test importing it loads both. `chain-parent-reach` answers this by walking
|
|
147
|
+
* value edges *backwards* as well (`coLoadedArtefacts`) — measured, it adds
|
|
148
|
+
* batch four's seventh site and no false one — and the other two signals
|
|
149
|
+
* deliberately do not, because widening the conjunct that clears several
|
|
150
|
+
* hundred correct reaches is a separate question with a separate
|
|
151
|
+
* measurement.
|
|
152
|
+
* * **Which copy produced the object under test.** Batch one's *second*
|
|
153
|
+
* finding was a service class duplicated the same way, caught by
|
|
154
|
+
* `toBeInstanceOf(AddressService)` and by nothing else — the instance came
|
|
155
|
+
* from the composed container and the class from the source. An
|
|
156
|
+
* `identity-comparison` signal over `instanceof` / `toBeInstanceOf` against
|
|
157
|
+
* a source-reached binding was written, measured and **removed**: it fired
|
|
158
|
+
* on all three sites in the tree and all three were correct, because in
|
|
159
|
+
* each of them the compared *object* was built by the test from the same
|
|
160
|
+
* source copy (`newsletter`'s provider registry twice,
|
|
161
|
+
* `transactional_emails`' reconciler once). The property it needs — which
|
|
162
|
+
* copy produced the left operand — is not lexically decidable, so the
|
|
163
|
+
* signal would have been a ledger of nothing but exceptions, which is the
|
|
164
|
+
* shape `check:diacritic-folds`' header refuses. Conjunct 2 is deliberately
|
|
165
|
+
* the narrower question, asked of an artefact rather than of dataflow.
|
|
166
|
+
* * `admin` and `storefront`. Neither composes the backend platform, so
|
|
167
|
+
* conjunct 1 is false for every file in them by construction.
|
|
168
|
+
* * A tenant chain whose parent is **computed** — `@TransitivelyScoped(NAME,
|
|
169
|
+
* 'fk')`. That is not a blind spot but a **refusal**: the signal's whole
|
|
170
|
+
* population is those literals, so an unreadable one exits 2 rather than
|
|
171
|
+
* quietly shrinking it.
|
|
172
|
+
* * A `@TransitivelyScoped` on a class that is not `@Entity()`. Feature 050's
|
|
173
|
+
* classification is total over persisted entities, so a real chain's child
|
|
174
|
+
* is one; without the conjunct the population is 31 names on this tree, 29
|
|
175
|
+
* of them the tenancy suite's synthetic fixtures.
|
|
176
|
+
*
|
|
177
|
+
* Static analysis through the TypeScript compiler API, so a specifier mentioned
|
|
178
|
+
* in a comment or a string literal is not a finding.
|
|
179
|
+
*
|
|
180
|
+
* ## One analysis, two hosts
|
|
181
|
+
*
|
|
182
|
+
* This file is the analysis (`specs/101-endora-check/contracts/package-scope-layout.md`
|
|
183
|
+
* §6). `backend/scripts/check-singleton-identity.ts` hosts it over every
|
|
184
|
+
* consumer root in this repository and holds `WHOLE_FILE_REACHES_ALLOWED`,
|
|
185
|
+
* which is a ledger of *this* tree's reaches, so `allowed` is an argument and
|
|
186
|
+
* not a value this file reads — a ledger read from inside the analysis is a
|
|
187
|
+
* ledger only one host can have.
|
|
188
|
+
*
|
|
189
|
+
* `endora check`'s host lands with Phase 3 and its estate entry says why: the
|
|
190
|
+
* rule's subject is a reach into a module package's source from a process that
|
|
191
|
+
* also loads that package's *artefact*, and `specifierGraph` skips a reach whose
|
|
192
|
+
* target is the reaching file's **own** package — correctly, since a package's
|
|
193
|
+
* internal relative imports are one copy and not two. So one package in
|
|
194
|
+
* isolation has no second package for a reach to land in, and what the rule
|
|
195
|
+
* needs is the peers' sources, which is Tier B's input rather than Tier A's.
|
|
196
|
+
*/
|
|
197
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
198
|
+
import { join } from 'node:path';
|
|
199
|
+
import ts from 'typescript';
|
|
200
|
+
import { namedSpecifiers } from '../lib/specifiers.js';
|
|
201
|
+
const REGISTRATION_CALLS = /(?:^|\.)(asValue|asFunction|asClass)$/;
|
|
202
|
+
/**
|
|
203
|
+
* The half of a composed singleton's reason that says the **ORM** holds the
|
|
204
|
+
* other copy.
|
|
205
|
+
*
|
|
206
|
+
* One constant rather than the same literal in three places: the chain-parent
|
|
207
|
+
* signal and the `entities-registry` reconciliation both narrow the composed set
|
|
208
|
+
* to this derivation, and a second spelling of it is two answers waiting to
|
|
209
|
+
* disagree about which classes the ORM registered.
|
|
210
|
+
*/
|
|
211
|
+
const ENTITIES_ARRAY_WHY = "the ORM registered this class from the package's entities array";
|
|
212
|
+
/** The decorator whose first argument names a tenant chain's parent by class name. */
|
|
213
|
+
const TRANSITIVE_SCOPE_DECORATOR = 'TransitivelyScoped';
|
|
214
|
+
/** The candidate spellings a specifier can reach, in the order node would try. */
|
|
215
|
+
function candidateKeys(base) {
|
|
216
|
+
return [
|
|
217
|
+
base.replace(/\.js$/, '.ts'),
|
|
218
|
+
base.replace(/\.js$/, '.tsx'),
|
|
219
|
+
`${base}.ts`,
|
|
220
|
+
`${base}.tsx`,
|
|
221
|
+
`${base}/index.ts`,
|
|
222
|
+
base,
|
|
223
|
+
];
|
|
224
|
+
}
|
|
225
|
+
/** `a/b/../c` → `a/c`, over the one separator every key in this check uses. */
|
|
226
|
+
function normalizeKey(path) {
|
|
227
|
+
const out = [];
|
|
228
|
+
for (const part of path.split('/')) {
|
|
229
|
+
if (part === '' || part === '.')
|
|
230
|
+
continue;
|
|
231
|
+
if (part === '..')
|
|
232
|
+
out.pop();
|
|
233
|
+
else
|
|
234
|
+
out.push(part);
|
|
235
|
+
}
|
|
236
|
+
return out.join('/');
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Which walked file a relative specifier written in `fromKey` names, or `null`.
|
|
240
|
+
*
|
|
241
|
+
* Only relative specifiers: a bare one is a package boundary and is conjunct 1's
|
|
242
|
+
* business, not conjunct 2's.
|
|
243
|
+
*/
|
|
244
|
+
export function resolveRelative(fromKey, specifier, keys) {
|
|
245
|
+
if (!specifier.startsWith('.'))
|
|
246
|
+
return null;
|
|
247
|
+
const base = normalizeKey(`${fromKey.split('/').slice(0, -1).join('/')}/${specifier}`);
|
|
248
|
+
for (const candidate of candidateKeys(base))
|
|
249
|
+
if (keys.has(candidate))
|
|
250
|
+
return candidate;
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
function sourceFileOf(key, text) {
|
|
254
|
+
return ts.createSourceFile(key, text, ts.ScriptTarget.Latest, true);
|
|
255
|
+
}
|
|
256
|
+
function isExported(node) {
|
|
257
|
+
return (ts.canHaveModifiers(node) &&
|
|
258
|
+
(ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword));
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* What each name a file exports *is*, at module scope.
|
|
262
|
+
*
|
|
263
|
+
* The distinction that matters is `value` (one object, created when the module
|
|
264
|
+
* evaluates) against `function` / `class` (code, re-created identically by a
|
|
265
|
+
* second evaluation and only identity-bearing where somebody compares it —
|
|
266
|
+
* which is `identity-comparison`'s job). `entity` is a class the ORM keys its
|
|
267
|
+
* metadata on, so a second one is a lookup miss.
|
|
268
|
+
*/
|
|
269
|
+
export function exportedKinds(key, text) {
|
|
270
|
+
const sf = sourceFileOf(key, text);
|
|
271
|
+
const kinds = new Map();
|
|
272
|
+
for (const statement of sf.statements) {
|
|
273
|
+
if (!isExported(statement))
|
|
274
|
+
continue;
|
|
275
|
+
if (ts.isVariableStatement(statement)) {
|
|
276
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
277
|
+
if (ts.isIdentifier(declaration.name))
|
|
278
|
+
kinds.set(declaration.name.text, 'value');
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
else if (ts.isClassDeclaration(statement) && statement.name) {
|
|
282
|
+
const decorated = (ts.getDecorators(statement) ?? []).some((decorator) => decorator.getText(sf).startsWith('@Entity'));
|
|
283
|
+
kinds.set(statement.name.text, decorated ? 'entity' : 'class');
|
|
284
|
+
}
|
|
285
|
+
else if (ts.isFunctionDeclaration(statement) && statement.name) {
|
|
286
|
+
kinds.set(statement.name.text, 'function');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return kinds;
|
|
290
|
+
}
|
|
291
|
+
/** Where each name a file imports as a value comes from, resolved to a walked key. */
|
|
292
|
+
function valueImportOrigins(key, text, keys) {
|
|
293
|
+
const sf = sourceFileOf(key, text);
|
|
294
|
+
const origins = new Map();
|
|
295
|
+
for (const statement of sf.statements) {
|
|
296
|
+
if (!ts.isImportDeclaration(statement) ||
|
|
297
|
+
!statement.importClause ||
|
|
298
|
+
statement.importClause.isTypeOnly ||
|
|
299
|
+
!ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const target = resolveRelative(key, statement.moduleSpecifier.text, keys);
|
|
303
|
+
if (target === null)
|
|
304
|
+
continue;
|
|
305
|
+
if (statement.importClause.name)
|
|
306
|
+
origins.set(statement.importClause.name.text, target);
|
|
307
|
+
const bound = statement.importClause.namedBindings;
|
|
308
|
+
if (bound && ts.isNamedImports(bound)) {
|
|
309
|
+
for (const element of bound.elements) {
|
|
310
|
+
if (!element.isTypeOnly)
|
|
311
|
+
origins.set(element.name.text, target);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return origins;
|
|
316
|
+
}
|
|
317
|
+
/** `<file>#<name>` — the identity of a binding, in one namespace. */
|
|
318
|
+
export function bindingKey(file, name) {
|
|
319
|
+
return `${file}#${name}`;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Every binding the platform holds exactly one of, derived from the packages'
|
|
323
|
+
* own sources.
|
|
324
|
+
*
|
|
325
|
+
* Two derivations and no third, each naming the thing that holds the other copy:
|
|
326
|
+
* the **container** (`asValue` / `asFunction(() => x)` / `asClass`) and the
|
|
327
|
+
* **ORM** (a member of the `entities` array). Both are read as literal AST
|
|
328
|
+
* nodes, so a mention in a comment contributes nothing.
|
|
329
|
+
*/
|
|
330
|
+
export function composedSingletons(input) {
|
|
331
|
+
const keys = new Set(input.sources.keys());
|
|
332
|
+
const composed = new Map();
|
|
333
|
+
for (const pkg of input.packages) {
|
|
334
|
+
const prefix = `${pkg.root}/`;
|
|
335
|
+
for (const [key, text] of input.sources) {
|
|
336
|
+
if (!key.startsWith(prefix))
|
|
337
|
+
continue;
|
|
338
|
+
const sf = sourceFileOf(key, text);
|
|
339
|
+
const origins = valueImportOrigins(key, text, keys);
|
|
340
|
+
const ownKinds = exportedKinds(key, text);
|
|
341
|
+
const record = (identifier, why, wanted) => {
|
|
342
|
+
const from = origins.get(identifier.text) ?? (ownKinds.has(identifier.text) ? key : null);
|
|
343
|
+
if (from === null)
|
|
344
|
+
return;
|
|
345
|
+
const text2 = input.sources.get(from);
|
|
346
|
+
if (text2 === undefined)
|
|
347
|
+
return;
|
|
348
|
+
const kind = exportedKinds(from, text2).get(identifier.text);
|
|
349
|
+
if (kind === undefined || !wanted.includes(kind))
|
|
350
|
+
return;
|
|
351
|
+
composed.set(bindingKey(from, identifier.text), `${pkg.moduleId}: ${why}`);
|
|
352
|
+
};
|
|
353
|
+
const visit = (node) => {
|
|
354
|
+
if (ts.isCallExpression(node) && REGISTRATION_CALLS.test(node.expression.getText(sf))) {
|
|
355
|
+
const [first] = node.arguments;
|
|
356
|
+
const call = node.expression.getText(sf);
|
|
357
|
+
if (first !== undefined) {
|
|
358
|
+
if (ts.isIdentifier(first)) {
|
|
359
|
+
record(first, `the composed container holds this object (${call})`, [
|
|
360
|
+
'value',
|
|
361
|
+
'class',
|
|
362
|
+
'entity',
|
|
363
|
+
]);
|
|
364
|
+
}
|
|
365
|
+
else if (ts.isArrowFunction(first) && ts.isIdentifier(first.body)) {
|
|
366
|
+
record(first.body, `the composed container holds this object (${call})`, [
|
|
367
|
+
'value',
|
|
368
|
+
'class',
|
|
369
|
+
'entity',
|
|
370
|
+
]);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (ts.isVariableDeclaration(node) &&
|
|
375
|
+
ts.isIdentifier(node.name) &&
|
|
376
|
+
node.name.text === 'entities' &&
|
|
377
|
+
node.initializer &&
|
|
378
|
+
ts.isArrayLiteralExpression(node.initializer)) {
|
|
379
|
+
for (const element of node.initializer.elements) {
|
|
380
|
+
if (ts.isIdentifier(element)) {
|
|
381
|
+
record(element, ENTITIES_ARRAY_WHY, ['entity']);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
node.forEachChild(visit);
|
|
386
|
+
};
|
|
387
|
+
sf.forEachChild(visit);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return composed;
|
|
391
|
+
}
|
|
392
|
+
export function transitiveParents(sources) {
|
|
393
|
+
const parents = new Map();
|
|
394
|
+
const unreadable = [];
|
|
395
|
+
for (const [key, text] of sources) {
|
|
396
|
+
if (!text.includes(TRANSITIVE_SCOPE_DECORATOR))
|
|
397
|
+
continue;
|
|
398
|
+
const sf = sourceFileOf(key, text);
|
|
399
|
+
const visit = (node) => {
|
|
400
|
+
if (ts.isClassDeclaration(node)) {
|
|
401
|
+
const decorators = ts.getDecorators(node) ?? [];
|
|
402
|
+
const persisted = decorators.some((decorator) => decorator.getText(sf).startsWith('@Entity'));
|
|
403
|
+
for (const decorator of persisted ? decorators : []) {
|
|
404
|
+
const call = decorator.expression;
|
|
405
|
+
if (!ts.isCallExpression(call))
|
|
406
|
+
continue;
|
|
407
|
+
if (call.expression.getText(sf) !== TRANSITIVE_SCOPE_DECORATOR)
|
|
408
|
+
continue;
|
|
409
|
+
const [first] = call.arguments;
|
|
410
|
+
const line = sf.getLineAndCharacterOfPosition(decorator.getStart(sf)).line + 1;
|
|
411
|
+
if (first === undefined || !ts.isStringLiteral(first)) {
|
|
412
|
+
unreadable.push(`${key}:${line}`);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const children = parents.get(first.text) ?? [];
|
|
416
|
+
children.push(node.name?.text ?? `<anonymous at ${key}:${line}>`);
|
|
417
|
+
parents.set(first.text, children);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
node.forEachChild(visit);
|
|
421
|
+
};
|
|
422
|
+
sf.forEachChild(visit);
|
|
423
|
+
}
|
|
424
|
+
return { parents, unreadable };
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Where each `@Entity()` class the walk read is declared, by class name.
|
|
428
|
+
*
|
|
429
|
+
* The independent half of the `tenant-chains` reconciliation: the decorator on
|
|
430
|
+
* the child names a parent, and this says whether the walk reached the file that
|
|
431
|
+
* declares it. Pre-filtered on the decorator's own text, so it parses the ~230
|
|
432
|
+
* files that hold an entity rather than all 3200.
|
|
433
|
+
*/
|
|
434
|
+
export function entityClassLocations(sources) {
|
|
435
|
+
const found = new Map();
|
|
436
|
+
for (const [key, text] of sources) {
|
|
437
|
+
if (!text.includes('@Entity'))
|
|
438
|
+
continue;
|
|
439
|
+
for (const [name, kind] of exportedKinds(key, text)) {
|
|
440
|
+
if (kind !== 'entity')
|
|
441
|
+
continue;
|
|
442
|
+
const where = found.get(name) ?? [];
|
|
443
|
+
where.push(key);
|
|
444
|
+
found.set(name, where);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return found;
|
|
448
|
+
}
|
|
449
|
+
export function chainParentSubjects(input, scan = transitiveParents(input.sources), composed = composedSingletons(input)) {
|
|
450
|
+
const subjects = [];
|
|
451
|
+
for (const [key, why] of composed) {
|
|
452
|
+
if (!why.includes(ENTITIES_ARRAY_WHY))
|
|
453
|
+
continue;
|
|
454
|
+
const cut = key.lastIndexOf('#');
|
|
455
|
+
const file = key.slice(0, cut);
|
|
456
|
+
const className = key.slice(cut + 1);
|
|
457
|
+
const children = scan.parents.get(className);
|
|
458
|
+
if (children === undefined)
|
|
459
|
+
continue;
|
|
460
|
+
subjects.push({ file, className, moduleId: why.split(':')[0] ?? '', children });
|
|
461
|
+
}
|
|
462
|
+
return subjects;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* For every walked file, the chain parents its own value-import closure loads.
|
|
466
|
+
*
|
|
467
|
+
* A **reverse** breadth-first walk from each subject rather than a forward one
|
|
468
|
+
* per reach: the subjects are few and the reaches are many, so this is one BFS
|
|
469
|
+
* per duplicated class over the whole edge set instead of one per site, and it
|
|
470
|
+
* yields the *shortest* path for free — which is what the message prints, and
|
|
471
|
+
* what makes the depth question answerable rather than assumed.
|
|
472
|
+
*
|
|
473
|
+
* **No depth limit**, and the measurement rather than the taste is the reason:
|
|
474
|
+
* over this repository the real hits sit at 1 and 2 hops, and the walk to
|
|
475
|
+
* exhaustion costs 0.1 s of a 13 s run (feature 080, T061a). A cap would be a
|
|
476
|
+
* number written down that the next service extraction — one more file between
|
|
477
|
+
* the test and the entity — would silently step past, which is the failure mode
|
|
478
|
+
* this check's own header spends four paragraphs on.
|
|
479
|
+
*/
|
|
480
|
+
export function chainParentReaches(edges, subjects) {
|
|
481
|
+
const reverse = new Map();
|
|
482
|
+
for (const [from, targets] of edges) {
|
|
483
|
+
for (const target of targets) {
|
|
484
|
+
const callers = reverse.get(target) ?? [];
|
|
485
|
+
callers.push(from);
|
|
486
|
+
reverse.set(target, callers);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const answer = new Map();
|
|
490
|
+
for (const subject of subjects) {
|
|
491
|
+
const seen = new Map([[subject.file, [subject.file]]]);
|
|
492
|
+
let frontier = [subject.file];
|
|
493
|
+
let hops = 0;
|
|
494
|
+
while (frontier.length > 0) {
|
|
495
|
+
for (const file of frontier) {
|
|
496
|
+
const hits = answer.get(file) ?? [];
|
|
497
|
+
hits.push({ subject, hops, via: [...(seen.get(file) ?? [file])].reverse() });
|
|
498
|
+
answer.set(file, hits);
|
|
499
|
+
}
|
|
500
|
+
const next = [];
|
|
501
|
+
for (const file of frontier) {
|
|
502
|
+
for (const caller of reverse.get(file) ?? []) {
|
|
503
|
+
if (seen.has(caller))
|
|
504
|
+
continue;
|
|
505
|
+
seen.set(caller, [...(seen.get(file) ?? [file]), caller]);
|
|
506
|
+
next.push(caller);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
frontier = next;
|
|
510
|
+
hops += 1;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return answer;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Conjunct 1, asked of the **process** rather than of the file — for the
|
|
517
|
+
* chain-parent signal only.
|
|
518
|
+
*
|
|
519
|
+
* The per-file closure under-approximates for a file nothing runs on its own: a
|
|
520
|
+
* test *helper* reaches the harness through `import type` and so loads no
|
|
521
|
+
* artefact of its own, while every test that imports it loads both. Batch four's
|
|
522
|
+
* seventh site is exactly that file (`test/helpers/orders-neighbour-ports.ts`),
|
|
523
|
+
* and the narrow conjunct clears it while the tree it lives in fails at ORM
|
|
524
|
+
* init.
|
|
525
|
+
*
|
|
526
|
+
* So a file counts as sharing a process with artefact `A` when **some** walked
|
|
527
|
+
* file's value-import closure holds both it and `A` — computed by walking value
|
|
528
|
+
* edges backwards from the file and unioning conjunct 1's answer over everything
|
|
529
|
+
* that reaches it. It is used **only** by the chain-parent signal, deliberately:
|
|
530
|
+
* widening the named-binding signal the same way changes the population that
|
|
531
|
+
* clears several hundred correct reaches, which is a separate question with a
|
|
532
|
+
* separate measurement. Measured here — on this repository it adds one true
|
|
533
|
+
* finding and no false one.
|
|
534
|
+
*/
|
|
535
|
+
function coLoadedArtefacts(edges, loaded, files) {
|
|
536
|
+
const reverse = new Map();
|
|
537
|
+
for (const [from, targets] of edges) {
|
|
538
|
+
for (const target of targets) {
|
|
539
|
+
const callers = reverse.get(target) ?? [];
|
|
540
|
+
callers.push(from);
|
|
541
|
+
reverse.set(target, callers);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const answer = new Map();
|
|
545
|
+
for (const file of files) {
|
|
546
|
+
if (answer.has(file))
|
|
547
|
+
continue;
|
|
548
|
+
const out = new Set(loaded.get(file) ?? []);
|
|
549
|
+
const seen = new Set([file]);
|
|
550
|
+
const queue = [file];
|
|
551
|
+
while (queue.length > 0) {
|
|
552
|
+
const current = queue.shift();
|
|
553
|
+
for (const id of loaded.get(current) ?? [])
|
|
554
|
+
out.add(id);
|
|
555
|
+
for (const caller of reverse.get(current) ?? []) {
|
|
556
|
+
if (seen.has(caller))
|
|
557
|
+
continue;
|
|
558
|
+
seen.add(caller);
|
|
559
|
+
queue.push(caller);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
answer.set(file, out);
|
|
563
|
+
}
|
|
564
|
+
return answer;
|
|
565
|
+
}
|
|
566
|
+
function packageOf(key, packages) {
|
|
567
|
+
for (const pkg of packages)
|
|
568
|
+
if (key.startsWith(`${pkg.root}/`))
|
|
569
|
+
return pkg;
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
function bareNameOf(specifier) {
|
|
573
|
+
const parts = specifier.split('/');
|
|
574
|
+
return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : (parts[0] ?? specifier);
|
|
575
|
+
}
|
|
576
|
+
function specifierGraph(input) {
|
|
577
|
+
const keys = new Set(input.sources.keys());
|
|
578
|
+
const direct = new Map();
|
|
579
|
+
const edges = new Map();
|
|
580
|
+
const reaches = [];
|
|
581
|
+
for (const [key, text] of input.sources) {
|
|
582
|
+
const own = packageOf(key, input.packages);
|
|
583
|
+
const mine = new Set();
|
|
584
|
+
const targets = [];
|
|
585
|
+
for (const specifier of namedSpecifiers(text, key)) {
|
|
586
|
+
if (specifier.kind === 'type-only-import' || specifier.kind === 'import-type-node')
|
|
587
|
+
continue;
|
|
588
|
+
const bare = bareNameOf(specifier.text);
|
|
589
|
+
const named = input.packages.find((pkg) => pkg.npmName === bare);
|
|
590
|
+
if (named !== undefined && (own === null || own.npmName !== bare))
|
|
591
|
+
mine.add(named.moduleId);
|
|
592
|
+
const target = resolveRelative(key, specifier.text, keys);
|
|
593
|
+
if (target === null)
|
|
594
|
+
continue;
|
|
595
|
+
targets.push(target);
|
|
596
|
+
const pkg = packageOf(target, input.packages);
|
|
597
|
+
if (pkg === null || (own !== null && own.root === pkg.root))
|
|
598
|
+
continue;
|
|
599
|
+
reaches.push({
|
|
600
|
+
file: key,
|
|
601
|
+
line: specifier.line,
|
|
602
|
+
target,
|
|
603
|
+
pkg,
|
|
604
|
+
bindings: specifier.bindings,
|
|
605
|
+
kind: specifier.kind,
|
|
606
|
+
whole: specifier.kind === 'side-effect-import' ||
|
|
607
|
+
specifier.kind === 'dynamic-import' ||
|
|
608
|
+
specifier.kind === 'require-call' ||
|
|
609
|
+
specifier.bindings.some((binding) => binding.startsWith('* as ')),
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
direct.set(key, mine);
|
|
613
|
+
edges.set(key, targets);
|
|
614
|
+
}
|
|
615
|
+
return { direct, edges, reaches };
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Every module package whose **published artefact** this file's value-import
|
|
619
|
+
* closure loads — conjunct 1, derived rather than declared.
|
|
620
|
+
*
|
|
621
|
+
* The closure is over value edges only: a `import type` resolves at build time
|
|
622
|
+
* and evaluates nothing, so it puts no second copy in any process.
|
|
623
|
+
*/
|
|
624
|
+
function artefactsLoaded(input, graph) {
|
|
625
|
+
const { direct, edges } = graph;
|
|
626
|
+
const memo = new Map();
|
|
627
|
+
const resolve = (key, stack) => {
|
|
628
|
+
const cached = memo.get(key);
|
|
629
|
+
if (cached !== undefined)
|
|
630
|
+
return cached;
|
|
631
|
+
if (stack.has(key))
|
|
632
|
+
return new Set();
|
|
633
|
+
stack.add(key);
|
|
634
|
+
const out = new Set(direct.get(key) ?? []);
|
|
635
|
+
for (const target of edges.get(key) ?? []) {
|
|
636
|
+
for (const id of resolve(target, stack))
|
|
637
|
+
out.add(id);
|
|
638
|
+
}
|
|
639
|
+
stack.delete(key);
|
|
640
|
+
// A cycle member is not memoised under a partial answer: the first key on
|
|
641
|
+
// the cycle would otherwise freeze the empty set every later visitor reads.
|
|
642
|
+
if (stack.size === 0)
|
|
643
|
+
memo.set(key, out);
|
|
644
|
+
return out;
|
|
645
|
+
};
|
|
646
|
+
const answer = new Map();
|
|
647
|
+
for (const key of input.sources.keys())
|
|
648
|
+
answer.set(key, resolve(key, new Set()));
|
|
649
|
+
return answer;
|
|
650
|
+
}
|
|
651
|
+
/** The whole analysis, pure over {@link SingletonIdentityInput}. */
|
|
652
|
+
export function checkSingletonIdentity(input, allowed) {
|
|
653
|
+
const keys = new Set(input.sources.keys());
|
|
654
|
+
const composed = composedSingletons(input);
|
|
655
|
+
const graph = specifierGraph(input);
|
|
656
|
+
const loaded = artefactsLoaded(input, graph);
|
|
657
|
+
const parentScan = transitiveParents(input.sources);
|
|
658
|
+
const subjects = chainParentSubjects(input, parentScan, composed);
|
|
659
|
+
const chainReaches = chainParentReaches(graph.edges, subjects);
|
|
660
|
+
const findings = [];
|
|
661
|
+
const reaches = graph.reaches;
|
|
662
|
+
const coLoaded = coLoadedArtefacts(graph.edges, loaded, reaches.filter((reach) => chainReaches.has(reach.target)).map((reach) => reach.file));
|
|
663
|
+
const usedAllowances = new Set();
|
|
664
|
+
for (const reach of reaches) {
|
|
665
|
+
// Conjunct 1: both copies in one process. Derived, never declared.
|
|
666
|
+
const sharesProcess = loaded.get(reach.file)?.has(reach.pkg.moduleId) ?? false;
|
|
667
|
+
// Conjunct 2 asked of the reach's **closure** rather than of the binding it
|
|
668
|
+
// names, and only for the classes whose duplication the platform refuses.
|
|
669
|
+
// It runs before the two per-binding signals below because it is the one
|
|
670
|
+
// that survives a `whole` reach and a reach naming an innocent binding
|
|
671
|
+
// alike: what makes it a finding is what the specifier *loads*.
|
|
672
|
+
for (const hit of chainReaches.get(reach.target) ?? []) {
|
|
673
|
+
const { subject } = hit;
|
|
674
|
+
if (!(coLoaded.get(reach.file)?.has(subject.moduleId) ?? false))
|
|
675
|
+
continue;
|
|
676
|
+
// Hop 0 naming the class itself is already `composed-singleton-reach`,
|
|
677
|
+
// which says the same thing with the binding named. Reporting it twice
|
|
678
|
+
// would make the count a property of how the message is phrased.
|
|
679
|
+
if (hit.hops === 0 && reach.bindings.includes(subject.className))
|
|
680
|
+
continue;
|
|
681
|
+
findings.push({
|
|
682
|
+
kind: 'chain-parent-reach',
|
|
683
|
+
file: reach.file,
|
|
684
|
+
line: reach.line,
|
|
685
|
+
moduleId: subject.moduleId,
|
|
686
|
+
target: subject.file,
|
|
687
|
+
binding: subject.className,
|
|
688
|
+
why: `\`${subject.className}\` is a \`@TransitivelyScoped\` parent — ` +
|
|
689
|
+
`${subject.children.join(', ')} name${subject.children.length === 1 ? 's' : ''} it — ` +
|
|
690
|
+
'and the platform resolves a tenant chain by class **name**, so two classes of that ' +
|
|
691
|
+
'name are an ambiguity `assertTransitiveParentsResolve` refuses at ORM init rather ' +
|
|
692
|
+
'than guesses at: `UnresolvableTenantParentError` inside `setupBackendServer`, which ' +
|
|
693
|
+
'fails every file in the process rather than this one. This specifier loads it ' +
|
|
694
|
+
`${hit.hops} hop(s) down, through ${hit.via.join(' -> ')}`,
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
if (!sharesProcess)
|
|
698
|
+
continue;
|
|
699
|
+
if (reach.whole) {
|
|
700
|
+
const ledgerKey = `${reach.file}:${reach.target}`;
|
|
701
|
+
if (allowed[ledgerKey] !== undefined) {
|
|
702
|
+
usedAllowances.add(ledgerKey);
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
findings.push({
|
|
706
|
+
kind: 'whole-file-reach',
|
|
707
|
+
file: reach.file,
|
|
708
|
+
line: reach.line,
|
|
709
|
+
moduleId: reach.pkg.moduleId,
|
|
710
|
+
target: reach.target,
|
|
711
|
+
binding: null,
|
|
712
|
+
why: `a ${reach.kind} names no binding, so it takes the file's whole import graph — ` +
|
|
713
|
+
`every module-scope value on it is a second copy while this process also holds ` +
|
|
714
|
+
`${reach.pkg.npmName}`,
|
|
715
|
+
});
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
for (const binding of reach.bindings) {
|
|
719
|
+
const why = composed.get(bindingKey(reach.target, binding));
|
|
720
|
+
if (why === undefined)
|
|
721
|
+
continue;
|
|
722
|
+
findings.push({
|
|
723
|
+
kind: 'composed-singleton-reach',
|
|
724
|
+
file: reach.file,
|
|
725
|
+
line: reach.line,
|
|
726
|
+
moduleId: reach.pkg.moduleId,
|
|
727
|
+
target: reach.target,
|
|
728
|
+
binding,
|
|
729
|
+
why,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
for (const ledgerKey of Object.keys(allowed)) {
|
|
734
|
+
if (usedAllowances.has(ledgerKey))
|
|
735
|
+
continue;
|
|
736
|
+
const [file = ledgerKey, target = ''] = ledgerKey.split(':');
|
|
737
|
+
// Staleness is judged **in the tree that holds the file**. A ledger entry
|
|
738
|
+
// names a site, and a site cannot be found absent from a tree its file is
|
|
739
|
+
// not in — the fixture backends of `moved-module-tree.test.ts` carry no
|
|
740
|
+
// test tree, and reporting four stale entries there would be the check
|
|
741
|
+
// going red for the fixture's shape rather than for the repository's. The
|
|
742
|
+
// other direction of the two-way rule — an entry whose file was *deleted*
|
|
743
|
+
// — is held by `check-singleton-identity.test.ts`, which resolves every key
|
|
744
|
+
// against this checkout's disk.
|
|
745
|
+
if (!keys.has(file))
|
|
746
|
+
continue;
|
|
747
|
+
findings.push({
|
|
748
|
+
kind: 'stale-allowance',
|
|
749
|
+
file,
|
|
750
|
+
line: 0,
|
|
751
|
+
moduleId: '',
|
|
752
|
+
target,
|
|
753
|
+
binding: null,
|
|
754
|
+
why: 'the ledger allows a whole-file reach this walk did not find',
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
const packagesWithEntities = [
|
|
758
|
+
...new Set([...composed.entries()]
|
|
759
|
+
.filter(([, why]) => why.includes(ENTITIES_ARRAY_WHY))
|
|
760
|
+
.map(([, why]) => why.split(':')[0] ?? '')),
|
|
761
|
+
].sort();
|
|
762
|
+
const entityClasses = entityClassLocations(input.sources);
|
|
763
|
+
const chainParentNames = [...parentScan.parents.keys()].sort();
|
|
764
|
+
return {
|
|
765
|
+
findings,
|
|
766
|
+
sites: reaches.length,
|
|
767
|
+
singletons: composed.size,
|
|
768
|
+
packagesWithEntities,
|
|
769
|
+
chainParentNames,
|
|
770
|
+
resolvedChainParentNames: chainParentNames.filter((name) => entityClasses.has(name)),
|
|
771
|
+
unreadableChainParents: parentScan.unreadable,
|
|
772
|
+
chainParentSubjects: subjects,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
const SKIPPED_DIRECTORIES = new Set(['node_modules', 'dist', '.git', 'build', '.next', 'coverage']);
|
|
776
|
+
/**
|
|
777
|
+
* Every TypeScript source under `dir`, with the rule's own directory prunes.
|
|
778
|
+
*
|
|
779
|
+
* Exported because the population is part of the rule: two hosts computing
|
|
780
|
+
* "which files this check reads" two ways is the shape that lets one of them go
|
|
781
|
+
* half-blind.
|
|
782
|
+
*/
|
|
783
|
+
export function collectSingletonSources(dir, out = []) {
|
|
784
|
+
let entries;
|
|
785
|
+
try {
|
|
786
|
+
entries = readdirSync(dir);
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
return out;
|
|
790
|
+
}
|
|
791
|
+
for (const name of entries) {
|
|
792
|
+
const full = join(dir, name);
|
|
793
|
+
let directory;
|
|
794
|
+
try {
|
|
795
|
+
directory = statSync(full).isDirectory();
|
|
796
|
+
}
|
|
797
|
+
catch {
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
if (directory) {
|
|
801
|
+
if (SKIPPED_DIRECTORIES.has(name))
|
|
802
|
+
continue;
|
|
803
|
+
collectSingletonSources(full, out);
|
|
804
|
+
}
|
|
805
|
+
else if ((name.endsWith('.ts') || name.endsWith('.tsx')) && !name.endsWith('.d.ts')) {
|
|
806
|
+
out.push(full);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return out;
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Which module packages the committed ORM registry imports an `entities` array
|
|
813
|
+
* from — the independent derivation the walk is reconciled against (issue #244).
|
|
814
|
+
*
|
|
815
|
+
* It is a second author's answer to the same question: the registry is generated
|
|
816
|
+
* from a filesystem walk of the packages' `exports` maps and names each package
|
|
817
|
+
* by its bare specifier, while this check derives entities from each package's
|
|
818
|
+
* own source. A walk that stopped reading a package's sources makes the two
|
|
819
|
+
* disagree in the same run.
|
|
820
|
+
*/
|
|
821
|
+
export function packagesInEntitiesRegistry(source, npmNames) {
|
|
822
|
+
const found = new Set();
|
|
823
|
+
for (const match of source.matchAll(/entities as \w+\s*\}\s*from\s*'([^']+)'/g)) {
|
|
824
|
+
const bare = bareNameOf(match[1] ?? '');
|
|
825
|
+
if (npmNames.has(bare))
|
|
826
|
+
found.add(bare);
|
|
827
|
+
}
|
|
828
|
+
return [...found].sort();
|
|
829
|
+
}
|
|
830
|
+
//# sourceMappingURL=singleton-identity.js.map
|