@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,714 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command-coverage check — feature 054 (FR-009 / FR-010, Constitution Principle XIII).
|
|
3
|
+
*
|
|
4
|
+
* `pnpm --filter backend run check:command-coverage -- [--strict] [--module <name> ...]`
|
|
5
|
+
*
|
|
6
|
+
* Flags, **per method/function/route handler** in every file a module owns:
|
|
7
|
+
* 1. an **unaudited sensitive write** — a method that performs a mutation call
|
|
8
|
+
* (`persist*`, `nativeUpdate`, `nativeDelete`, `remove*`, `flush`) but
|
|
9
|
+
* neither runs a Command (`commandBus.run(...)`) nor writes audit
|
|
10
|
+
* (`.record*(...)`) in the same method;
|
|
11
|
+
* 2. a **double-audit** — a method that BOTH runs a Command AND writes audit by
|
|
12
|
+
* hand (a converted write must remove its manual audit call, FR-010).
|
|
13
|
+
*
|
|
14
|
+
* Method-level (not file-level) so a partially-migrated file is judged per
|
|
15
|
+
* method: a converted `adjust` no longer masks an unaudited `grant` in the same
|
|
16
|
+
* file, and a Command in one method is not mistaken for a double-audit against a
|
|
17
|
+
* legacy `record()` in another.
|
|
18
|
+
*
|
|
19
|
+
* Escape hatch: a genuinely non-sensitive write (bookkeeping rows — progress
|
|
20
|
+
* counters, cache, queue state) can be exempted by putting a
|
|
21
|
+
* `command-coverage-ignore: <reason>` comment anywhere in the method. This keeps
|
|
22
|
+
* "build-breaking per module" honest without forcing audit onto non-domain writes,
|
|
23
|
+
* mirroring the audited `withSystemScope` escape hatch for tenancy.
|
|
24
|
+
*
|
|
25
|
+
* ## The escape hatch is a two-way ratchet (issue #116)
|
|
26
|
+
*
|
|
27
|
+
* 185 methods carry that comment, and until now nothing ever re-read one. An
|
|
28
|
+
* ignore written for a write that has since moved elsewhere — into a Command, or
|
|
29
|
+
* into another module's audited service — went on reading as a considered
|
|
30
|
+
* decision about a write that is no longer there, and the next person to add a
|
|
31
|
+
* write to that method inherited the exemption silently.
|
|
32
|
+
*
|
|
33
|
+
* So a marker on a method that **no longer writes at all** is reported as
|
|
34
|
+
* `stale-ignore`, in the idiom of `PORT_CATCHES_TO_DRAIN` and
|
|
35
|
+
* `BARE_SUBSCRIPTIONS_TO_DRAIN`: an unledgered violation fails the build, and an
|
|
36
|
+
* entry that no longer describes one fails it too. MR !532's marker in
|
|
37
|
+
* `orders/order-completion-reactor.ts` — added because a refactor made an
|
|
38
|
+
* *existing* write visible to this check — is exactly the entry that has to be
|
|
39
|
+
* re-verified rather than trusted, and now it is, on every run.
|
|
40
|
+
*
|
|
41
|
+
* **The staleness half looks for writes more widely than the flagging half**,
|
|
42
|
+
* and the asymmetry is deliberate: both errors then fall on the safe side. The
|
|
43
|
+
* flagging half only flags an ORM mutation call it is sure about; the staleness
|
|
44
|
+
* half additionally counts a raw SQL write statement (`conn.execute` with an
|
|
45
|
+
* `update`/`insert into`/`delete from`) and any write reached transitively
|
|
46
|
+
* through `this.<name>(…)` in the same file — so a marker guarding a real write
|
|
47
|
+
* this check cannot itself see is left alone, and only a marker guarding nothing
|
|
48
|
+
* is reported.
|
|
49
|
+
*
|
|
50
|
+
* **With one subtraction (D-89c): a downstream write that itself carries a
|
|
51
|
+
* marker does not keep the caller's marker alive.** The transitive rule exists
|
|
52
|
+
* so a marker over a write this check cannot see is not called stale; when the
|
|
53
|
+
* write it reaches is *already exempted where it happens*, the caller's marker
|
|
54
|
+
* is provably guarding nothing and the ratchet has to say so. The worked
|
|
55
|
+
* example carries the failure and its correction in one file:
|
|
56
|
+
* `payments/services/payment-reference-port.ts` marked `stampExternalReference`
|
|
57
|
+
* and `stampExternalReferenceIfAbsent`, neither of which writes, and later
|
|
58
|
+
* marked the private `stamp` that does the `flush` — with a comment explaining
|
|
59
|
+
* that the marker had to be repeated there "because the check reads the
|
|
60
|
+
* function that writes". Both redundant caller markers survived, and nothing
|
|
61
|
+
* could see them.
|
|
62
|
+
*
|
|
63
|
+
* ## What "every service" was allowed to mean (issue #122)
|
|
64
|
+
*
|
|
65
|
+
* The walk matched `**/services/<file>.ts` — **one level, nothing else**. Over
|
|
66
|
+
* the tree that is **472 of 1152** module files: 680 were never opened, 78 of
|
|
67
|
+
* them containing a write signal. Invisible were `pim_ergonode/services/import/`,
|
|
68
|
+
* `product_feeds/services/delivery/` and `services/queues/` (nested a level too
|
|
69
|
+
* deep), every `workers/`, `queues/` and `jobs/` file, every `commands/` file,
|
|
70
|
+
* every `routes*.ts`, every `backend.ts` boot hook, every `scripts/` entry point
|
|
71
|
+
* and every `seeds/` reconciler. Queue consumers and admin route handlers are
|
|
72
|
+
* exactly where writes live, so the check read clean over the two categories
|
|
73
|
+
* Principle XIII is most about.
|
|
74
|
+
*
|
|
75
|
+
* The boundary is now **every `.ts` file under `src/modules/` and `src/apps/`**.
|
|
76
|
+
* Four exclusions remain, and each is an argument rather than an omission:
|
|
77
|
+
*
|
|
78
|
+
* - `migrations/` — DDL applied by the migrator with no request, no actor and
|
|
79
|
+
* no undo; an audit entry for one would have nobody to attribute it to. The
|
|
80
|
+
* migration registry and `db:fresh` are its gate.
|
|
81
|
+
* - `*.test.ts` / `*.d.ts` — not shipped code.
|
|
82
|
+
* - `audit_logs/` — the audit writer itself. Its writes *are* the audit
|
|
83
|
+
* entries; requiring one for each is circular.
|
|
84
|
+
*
|
|
85
|
+
* Everything else is judged, including `seeds/` and `scripts/`: a CLI entry
|
|
86
|
+
* point creating an administrator and a boot reconciler writing predefined rows
|
|
87
|
+
* are operator-visible writes that happen to run outside a request, and a
|
|
88
|
+
* category-wide exemption for them is the same mistake one folder over.
|
|
89
|
+
*
|
|
90
|
+
* ## Two narrowings the widening forced
|
|
91
|
+
*
|
|
92
|
+
* A wider walk meets shapes a `services/` file rarely has, and a check that
|
|
93
|
+
* answers them with exemptions is lying about the tree rather than reading it:
|
|
94
|
+
*
|
|
95
|
+
* 1. **`remove` is only an ORM mutation off an EntityManager.** 30 of the 36
|
|
96
|
+
* first-pass findings were `deps.<x>Service.remove(id)` in a route handler
|
|
97
|
+
* — a call into an audited service. Every other name in the vocabulary
|
|
98
|
+
* (`persist`, `persistAndFlush`, `nativeUpdate`, `nativeDelete`,
|
|
99
|
+
* `removeAndFlush`, `flush`) is MikroORM's alone and still counts off any
|
|
100
|
+
* receiver. The **staleness half keeps counting `remove` everywhere**, so
|
|
101
|
+
* the asymmetry above survives: the narrowing can only add a report, never
|
|
102
|
+
* silence one.
|
|
103
|
+
* 2. **A route file is judged per handler.** `registerXAdminRoutes` is not a
|
|
104
|
+
* unit of work; each handler is. Read as one unit, a single
|
|
105
|
+
* `commandBus.run` anywhere in the file clears every other handler in it —
|
|
106
|
+
* the masking the per-method rule exists to prevent, one level up. It also
|
|
107
|
+
* manufactured two `double-audit` reports across handlers that never met.
|
|
108
|
+
*
|
|
109
|
+
* ## What this check cannot see, and will not pretend to (D-89b)
|
|
110
|
+
*
|
|
111
|
+
* This check reads **call** shapes. A field assignment on a managed entity —
|
|
112
|
+
* `order.status = ref`, `refund.settlementState = outcome.state` — is a write
|
|
113
|
+
* the unit of work will flush and this check cannot see it. The unit is judged
|
|
114
|
+
* by the calls it makes, so an assignment is caught only when the same unit also
|
|
115
|
+
* calls one of the vocabulary. A unit that assigns and lets its caller flush is
|
|
116
|
+
* outside the population, **by construction and not by exemption**.
|
|
117
|
+
*
|
|
118
|
+
* That is refused deliberately rather than deferred. The shapes are not
|
|
119
|
+
* separable by a static name test: `x.y = z` is the most common statement form
|
|
120
|
+
* in the language, `x` is a managed entity only when the type checker says so,
|
|
121
|
+
* and this check does not build a program. Reading every assignment as a
|
|
122
|
+
* candidate write would put a finding on most methods in the tree and teach
|
|
123
|
+
* people to write exemptions; reading none of them keeps the check's green
|
|
124
|
+
* honest — provided the green is read as what it is. So: a green
|
|
125
|
+
* `check:command-coverage --strict` means *"no unaudited sensitive write of a
|
|
126
|
+
* shape this check can see"*, and it does not mean *"every sensitive write is
|
|
127
|
+
* audited"*. It cannot be made to mean the second at anything like this cost.
|
|
128
|
+
*
|
|
129
|
+
* Scope & staging: build-breaks (exit 1) for **migrated modules**
|
|
130
|
+
* (`MIGRATED_MODULES`, in the repository-scope host, or `--module`); any other
|
|
131
|
+
* module would be report-only. The platform-wide rollout is COMPLETE and CI runs
|
|
132
|
+
* with `--strict`, so a finding in ANY module, including a brand-new one not yet
|
|
133
|
+
* in the list, fails the build.
|
|
134
|
+
*
|
|
135
|
+
* ## One analysis, two hosts
|
|
136
|
+
*
|
|
137
|
+
* This file is the analysis. `backend/scripts/check-command-coverage.ts` hosts it
|
|
138
|
+
* over this repository's module tree and owns the rollout ledger, which is a
|
|
139
|
+
* fact about *these* modules; `endora check` hosts it over one module package
|
|
140
|
+
* (`specs/101-endora-check/contracts/package-scope-layout.md` §6). A third-party
|
|
141
|
+
* package is in no rollout, so the package-scope host judges it strictly — which
|
|
142
|
+
* is what `--strict` already means here.
|
|
143
|
+
*/
|
|
144
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
145
|
+
import { join } from 'node:path';
|
|
146
|
+
import ts from 'typescript';
|
|
147
|
+
/**
|
|
148
|
+
* Mutation names that belong to MikroORM and to nothing else in this tree, so
|
|
149
|
+
* they count off any receiver.
|
|
150
|
+
*/
|
|
151
|
+
const ORM_MUTATIONS = new Set([
|
|
152
|
+
'persist',
|
|
153
|
+
'persistAndFlush',
|
|
154
|
+
'nativeUpdate',
|
|
155
|
+
'nativeDelete',
|
|
156
|
+
'removeAndFlush',
|
|
157
|
+
'flush',
|
|
158
|
+
]);
|
|
159
|
+
/**
|
|
160
|
+
* Mutation names that are also ordinary vocabulary. `remove` is MikroORM's and
|
|
161
|
+
* also every service's, every queue backend's and every transport client's —
|
|
162
|
+
* `deps.countryService.remove(code)`, `scheduler.remove(id)`,
|
|
163
|
+
* `ftpClient.remove(path)`. `create` is worse on the same axis: it is the name
|
|
164
|
+
* of nearly every service method in this tree. Both count for the flagging half
|
|
165
|
+
* only off an EntityManager; the staleness half counts them everywhere (see the
|
|
166
|
+
* header).
|
|
167
|
+
*
|
|
168
|
+
* `create` joined in D-89. `em.create(Entity, …)` puts a managed entity into the
|
|
169
|
+
* unit of work — the insert is queued from that moment and the next `flush`
|
|
170
|
+
* writes it, whoever calls it — so a unit whose only mutation call is an
|
|
171
|
+
* `em.create` was invisible to this check while being every bit as much a write
|
|
172
|
+
* as the `persist` next door. That is the hole that let a CSV import rewrite
|
|
173
|
+
* catalogue and stock unaudited for a year.
|
|
174
|
+
*/
|
|
175
|
+
const AMBIGUOUS_MUTATIONS = new Set(['remove', 'create']);
|
|
176
|
+
const MUTATION_METHODS = new Set([...ORM_MUTATIONS, ...AMBIGUOUS_MUTATIONS]);
|
|
177
|
+
/**
|
|
178
|
+
* An identifier that names a MikroORM EntityManager, as this tree spells it:
|
|
179
|
+
* `em`, the transactional `tx` / `trx`, the short forked forms `tem` / `cem`,
|
|
180
|
+
* any `<word>Em` (`txEm`, `targetEm`), and the factories `em()` / `emFactory()`.
|
|
181
|
+
*
|
|
182
|
+
* Deliberately *not* "anything ending in em" — that matches `item` and `system`,
|
|
183
|
+
* and a check that flags `item.remove(x)` teaches people to write exemptions.
|
|
184
|
+
*/
|
|
185
|
+
const ENTITY_MANAGER_NAME = /^(?:em|tem|cem|tx|trx|entityManager|emFactory|[A-Za-z]+Em)$/;
|
|
186
|
+
/**
|
|
187
|
+
* Whether an expression is (or yields) an EntityManager, by name.
|
|
188
|
+
*
|
|
189
|
+
* Unwraps calls and the usual wrappers, so `this.em()`, `this.deps.em()` and
|
|
190
|
+
* `em.fork()` all resolve; recurses through a chained mutation so
|
|
191
|
+
* `em.remove(a).remove(b)` stays an EntityManager at every link.
|
|
192
|
+
*/
|
|
193
|
+
function isEntityManagerReceiver(node) {
|
|
194
|
+
let current = node;
|
|
195
|
+
while (ts.isCallExpression(current) ||
|
|
196
|
+
ts.isNonNullExpression(current) ||
|
|
197
|
+
ts.isParenthesizedExpression(current) ||
|
|
198
|
+
ts.isAwaitExpression(current)) {
|
|
199
|
+
current = current.expression;
|
|
200
|
+
}
|
|
201
|
+
const name = ts.isPropertyAccessExpression(current)
|
|
202
|
+
? current.name.text
|
|
203
|
+
: ts.isIdentifier(current)
|
|
204
|
+
? current.text
|
|
205
|
+
: null;
|
|
206
|
+
if (name === null)
|
|
207
|
+
return false;
|
|
208
|
+
if (ENTITY_MANAGER_NAME.test(name))
|
|
209
|
+
return true;
|
|
210
|
+
if (ts.isPropertyAccessExpression(current) &&
|
|
211
|
+
(MUTATION_METHODS.has(name) || name === 'fork' || name === 'transactional')) {
|
|
212
|
+
return isEntityManagerReceiver(current.expression);
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Fastify route registrations. Each function argument is its own unit of work.
|
|
218
|
+
*
|
|
219
|
+
* A call only counts when its first argument is a string literal path, which is
|
|
220
|
+
* what tells `app.get('/api/…', handler)` apart from `cache.get(key)` and
|
|
221
|
+
* `set.delete(key)`. `.route({ … })` is not in the list because the tree does
|
|
222
|
+
* not use it; a first use would be invisible here, so it is named in the
|
|
223
|
+
* companion test rather than left to be discovered.
|
|
224
|
+
*/
|
|
225
|
+
const ROUTE_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'all']);
|
|
226
|
+
/** Option-object properties whose value is a handler in its own right. */
|
|
227
|
+
const HANDLER_PROPERTIES = new Set(['handler', 'preHandler', 'onRequest', 'preValidation']);
|
|
228
|
+
/**
|
|
229
|
+
* A SQL statement that writes, as it appears in a string or template literal
|
|
230
|
+
* handed to `conn.execute` / `em.execute`.
|
|
231
|
+
*
|
|
232
|
+
* Only the staleness half reads this. Matching prose in a literal
|
|
233
|
+
* (`'update the row'`) marks the unit as writing, which suppresses a staleness
|
|
234
|
+
* report — the safe direction, since the cost is a marker left standing rather
|
|
235
|
+
* than a marker deleted off a live write.
|
|
236
|
+
*/
|
|
237
|
+
const SQL_WRITE = /\b(insert\s+into|update\s+["`']?[a-z_]|delete\s+from|truncate\s+table)/i;
|
|
238
|
+
/**
|
|
239
|
+
* Writes that leave the database entirely — BullMQ schedulers and Redis keys.
|
|
240
|
+
*
|
|
241
|
+
* Only the staleness half reads this, for the same reason it reads `SQL_WRITE`.
|
|
242
|
+
* `product_feeds/workers/taxonomy-refresh-worker.ts` documents its
|
|
243
|
+
* `queue.removeJobScheduler(…)` with a marker that says, correctly, "Redis-only";
|
|
244
|
+
* a sweep that knew only ORM and SQL called that marker dead the moment
|
|
245
|
+
* `workers/` came into scope. The flagging half deliberately does not read it —
|
|
246
|
+
* a queue write is not a Command Bus write.
|
|
247
|
+
*/
|
|
248
|
+
const NON_SQL_WRITE_METHODS = new Set([
|
|
249
|
+
'removeJobScheduler',
|
|
250
|
+
'upsertJobScheduler',
|
|
251
|
+
'removeRepeatable',
|
|
252
|
+
'removeRepeatableByKey',
|
|
253
|
+
'obliterate',
|
|
254
|
+
'drain',
|
|
255
|
+
'clean',
|
|
256
|
+
'del',
|
|
257
|
+
'unlink',
|
|
258
|
+
'hset',
|
|
259
|
+
'hdel',
|
|
260
|
+
'expire',
|
|
261
|
+
'setex',
|
|
262
|
+
]);
|
|
263
|
+
const AUDIT_RECEIVER = /(auditLog|auditLogService|auditService|AuditLogService|cartAuditService|\.audit)$/;
|
|
264
|
+
const SUPPRESS_TOKEN = 'command-coverage-ignore';
|
|
265
|
+
/** Whether an object literal is a Command definition (`action` + `run` props). */
|
|
266
|
+
function isCommandLiteral(node) {
|
|
267
|
+
const names = new Set(node.properties
|
|
268
|
+
.map((p) => (p.name && ts.isIdentifier(p.name) ? p.name.text : null))
|
|
269
|
+
.filter((n) => n !== null));
|
|
270
|
+
return names.has('action') && names.has('run');
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Scan a single method/function subtree for mutation / audit / command calls.
|
|
274
|
+
*
|
|
275
|
+
* `nested` holds the route handlers that are units of their own; their subtrees
|
|
276
|
+
* belong to them, not to the registration function that contains them.
|
|
277
|
+
*/
|
|
278
|
+
function scanUnit(node, sf, nested) {
|
|
279
|
+
const scan = {
|
|
280
|
+
hasMutation: false,
|
|
281
|
+
mutationLine: null,
|
|
282
|
+
hasAuditWrite: false,
|
|
283
|
+
runsCommand: false,
|
|
284
|
+
definesCommand: false,
|
|
285
|
+
calls: new Set(),
|
|
286
|
+
writesAnything: false,
|
|
287
|
+
};
|
|
288
|
+
const visit = (n) => {
|
|
289
|
+
if (nested.has(n))
|
|
290
|
+
return;
|
|
291
|
+
if (ts.isObjectLiteralExpression(n) && isCommandLiteral(n)) {
|
|
292
|
+
scan.definesCommand = true;
|
|
293
|
+
}
|
|
294
|
+
if (ts.isStringLiteralLike(n) && SQL_WRITE.test(n.text)) {
|
|
295
|
+
scan.writesAnything = true;
|
|
296
|
+
}
|
|
297
|
+
// The sanctioned free-function audit primitive (commands/audit-from-context):
|
|
298
|
+
// `recordAuditFromContext(auditLog, em, …)` writes a co-transactional entry.
|
|
299
|
+
if (ts.isCallExpression(n) &&
|
|
300
|
+
ts.isIdentifier(n.expression) &&
|
|
301
|
+
n.expression.text === 'recordAuditFromContext') {
|
|
302
|
+
scan.hasAuditWrite = true;
|
|
303
|
+
}
|
|
304
|
+
// A bare `helper(...)` — delegation to a module-level function.
|
|
305
|
+
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
|
|
306
|
+
scan.calls.add(n.expression.text);
|
|
307
|
+
}
|
|
308
|
+
if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression)) {
|
|
309
|
+
const method = n.expression.name.text;
|
|
310
|
+
const receiver = n.expression.expression;
|
|
311
|
+
const receiverText = receiver.getText(sf);
|
|
312
|
+
if (NON_SQL_WRITE_METHODS.has(method)) {
|
|
313
|
+
scan.writesAnything = true;
|
|
314
|
+
}
|
|
315
|
+
if (MUTATION_METHODS.has(method)) {
|
|
316
|
+
// The staleness half reads the widest possible answer; the flagging half
|
|
317
|
+
// asks an ambiguous name to prove its receiver is an EntityManager.
|
|
318
|
+
scan.writesAnything = true;
|
|
319
|
+
if (!AMBIGUOUS_MUTATIONS.has(method) || isEntityManagerReceiver(receiver)) {
|
|
320
|
+
scan.hasMutation = true;
|
|
321
|
+
if (scan.mutationLine === null) {
|
|
322
|
+
scan.mutationLine = sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if ((method === 'record' || method === 'recordWithin') && AUDIT_RECEIVER.test(receiverText)) {
|
|
327
|
+
scan.hasAuditWrite = true;
|
|
328
|
+
}
|
|
329
|
+
if (method === 'run' && /commandBus$/.test(receiverText)) {
|
|
330
|
+
scan.runsCommand = true;
|
|
331
|
+
}
|
|
332
|
+
// `this.<name>(...)` — a candidate delegation to an audited runner.
|
|
333
|
+
if (receiver.kind === ts.SyntaxKind.ThisKeyword) {
|
|
334
|
+
scan.calls.add(method);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
ts.forEachChild(n, visit);
|
|
338
|
+
};
|
|
339
|
+
ts.forEachChild(node, visit);
|
|
340
|
+
return scan;
|
|
341
|
+
}
|
|
342
|
+
function unitName(node) {
|
|
343
|
+
if ((ts.isMethodDeclaration(node) ||
|
|
344
|
+
ts.isFunctionDeclaration(node) ||
|
|
345
|
+
ts.isGetAccessorDeclaration(node) ||
|
|
346
|
+
ts.isSetAccessorDeclaration(node)) &&
|
|
347
|
+
node.name) {
|
|
348
|
+
return node.name.getText();
|
|
349
|
+
}
|
|
350
|
+
if (ts.isConstructorDeclaration(node))
|
|
351
|
+
return 'constructor';
|
|
352
|
+
if (ts.isPropertyDeclaration(node) && node.name)
|
|
353
|
+
return node.name.getText();
|
|
354
|
+
if (ts.isVariableDeclaration(node) && node.name)
|
|
355
|
+
return node.name.getText();
|
|
356
|
+
return '<anonymous>';
|
|
357
|
+
}
|
|
358
|
+
function isArrowOrFn(node) {
|
|
359
|
+
return !!node && (ts.isArrowFunction(node) || ts.isFunctionExpression(node));
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Route handlers, as units of their own.
|
|
363
|
+
*
|
|
364
|
+
* Fastify registers them as function arguments to `app.<method>('/path', …)`,
|
|
365
|
+
* so a registration function contains every handler in the file. Judged as one
|
|
366
|
+
* unit it hides them from each other; each handler gets its own unit here, named
|
|
367
|
+
* `POST /api/v1/admin/…` so a finding points at the route an operator calls.
|
|
368
|
+
*/
|
|
369
|
+
function collectRouteHandlers(sf) {
|
|
370
|
+
const handlers = [];
|
|
371
|
+
const visit = (node) => {
|
|
372
|
+
if (ts.isCallExpression(node) &&
|
|
373
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
374
|
+
ROUTE_METHODS.has(node.expression.name.text) &&
|
|
375
|
+
node.arguments.length > 1 &&
|
|
376
|
+
node.arguments[0] !== undefined &&
|
|
377
|
+
ts.isStringLiteralLike(node.arguments[0])) {
|
|
378
|
+
const name = `${node.expression.name.text.toUpperCase()} ${node.arguments[0].text}`;
|
|
379
|
+
for (const arg of node.arguments.slice(1)) {
|
|
380
|
+
if (isArrowOrFn(arg)) {
|
|
381
|
+
handlers.push({ name, node: arg, suppressed: false, suppressedLine: null });
|
|
382
|
+
}
|
|
383
|
+
else if (ts.isObjectLiteralExpression(arg)) {
|
|
384
|
+
for (const prop of arg.properties) {
|
|
385
|
+
if (ts.isPropertyAssignment(prop) &&
|
|
386
|
+
ts.isIdentifier(prop.name) &&
|
|
387
|
+
HANDLER_PROPERTIES.has(prop.name.text) &&
|
|
388
|
+
isArrowOrFn(prop.initializer)) {
|
|
389
|
+
handlers.push({
|
|
390
|
+
name: `${name} (${prop.name.text})`,
|
|
391
|
+
node: prop.initializer,
|
|
392
|
+
suppressed: false,
|
|
393
|
+
suppressedLine: null,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
ts.forEachChild(node, visit);
|
|
401
|
+
};
|
|
402
|
+
ts.forEachChild(sf, visit);
|
|
403
|
+
return handlers;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Function-valued locals — `const audit = async (…) => …` declared *inside* a
|
|
407
|
+
* registration function or a method.
|
|
408
|
+
*
|
|
409
|
+
* They are **not** judged: a closure's write belongs to the unit that runs it,
|
|
410
|
+
* which is why `collectUnits` only takes a top-level `const fn = () => …`. But
|
|
411
|
+
* they are legitimate delegation targets, and `organizations/routes.admin.ts`
|
|
412
|
+
* is built entirely on one — every audited handler in it calls the file's local
|
|
413
|
+
* `audit(…)`. Without them, six handlers that audit correctly were reported as
|
|
414
|
+
* unaudited, which is the wrong direction for a check nobody may exempt away.
|
|
415
|
+
*/
|
|
416
|
+
function collectHelperClosures(sf) {
|
|
417
|
+
const helpers = [];
|
|
418
|
+
const visit = (node) => {
|
|
419
|
+
if (ts.isVariableDeclaration(node) &&
|
|
420
|
+
isArrowOrFn(node.initializer) &&
|
|
421
|
+
ts.isIdentifier(node.name) &&
|
|
422
|
+
// top-level ones are already units in their own right
|
|
423
|
+
!(node.parent?.parent?.parent !== undefined && ts.isSourceFile(node.parent.parent.parent))) {
|
|
424
|
+
helpers.push({ name: node.name.text, node });
|
|
425
|
+
}
|
|
426
|
+
ts.forEachChild(node, visit);
|
|
427
|
+
};
|
|
428
|
+
ts.forEachChild(sf, visit);
|
|
429
|
+
return helpers;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Collect top-level method/function units plus every route handler, and the set
|
|
433
|
+
* of handler nodes their enclosing registration function must not absorb.
|
|
434
|
+
*/
|
|
435
|
+
function collectUnits(sf) {
|
|
436
|
+
const handlers = collectRouteHandlers(sf);
|
|
437
|
+
const nested = new Set(handlers.map((h) => h.node));
|
|
438
|
+
const units = [];
|
|
439
|
+
const walk = (node) => {
|
|
440
|
+
let root = null;
|
|
441
|
+
if (ts.isMethodDeclaration(node) ||
|
|
442
|
+
ts.isFunctionDeclaration(node) ||
|
|
443
|
+
ts.isConstructorDeclaration(node) ||
|
|
444
|
+
ts.isGetAccessorDeclaration(node) ||
|
|
445
|
+
ts.isSetAccessorDeclaration(node)) {
|
|
446
|
+
root = node;
|
|
447
|
+
}
|
|
448
|
+
else if (ts.isPropertyDeclaration(node) && isArrowOrFn(node.initializer)) {
|
|
449
|
+
root = node; // class field arrow method
|
|
450
|
+
}
|
|
451
|
+
else if (ts.isVariableDeclaration(node) &&
|
|
452
|
+
isArrowOrFn(node.initializer) &&
|
|
453
|
+
// only top-level `const x = () => …`, not locals inside a method
|
|
454
|
+
node.parent?.parent?.parent !== undefined &&
|
|
455
|
+
ts.isSourceFile(node.parent.parent.parent)) {
|
|
456
|
+
root = node;
|
|
457
|
+
}
|
|
458
|
+
if (root) {
|
|
459
|
+
const suppressedLine = suppressionLine(root, sf, nested);
|
|
460
|
+
units.push({
|
|
461
|
+
name: unitName(root),
|
|
462
|
+
node: root,
|
|
463
|
+
suppressed: suppressedLine !== null,
|
|
464
|
+
suppressedLine,
|
|
465
|
+
});
|
|
466
|
+
return; // do NOT recurse — inline callbacks belong to this unit
|
|
467
|
+
}
|
|
468
|
+
ts.forEachChild(node, walk);
|
|
469
|
+
};
|
|
470
|
+
walk(sf);
|
|
471
|
+
for (const handler of handlers) {
|
|
472
|
+
const suppressedLine = suppressionLine(handler.node, sf, nested);
|
|
473
|
+
units.push({ ...handler, suppressed: suppressedLine !== null, suppressedLine });
|
|
474
|
+
}
|
|
475
|
+
return { units, nested };
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* The 1-based line of this unit's `command-coverage-ignore`, or `null`.
|
|
479
|
+
*
|
|
480
|
+
* Two things it deliberately does not count (issue #122):
|
|
481
|
+
*
|
|
482
|
+
* - a token inside a **nested** unit — a marker on one route handler is not an
|
|
483
|
+
* exemption for the registration function that holds it, nor for the handler
|
|
484
|
+
* next to it;
|
|
485
|
+
* - a token in trivia that is not the unit's **own** doc comment. Four command
|
|
486
|
+
* files describe their policy in a file header that quotes the token; read
|
|
487
|
+
* from `getFullStart()`, that header exempted whichever declaration happened
|
|
488
|
+
* to come first — and once the staleness sweep landed, reported it as a dead
|
|
489
|
+
* marker nobody had written. Only the **last leading comment block, adjacent
|
|
490
|
+
* to the declaration**, counts: a blank line between the two makes it a file
|
|
491
|
+
* header rather than a doc comment. That is what still lets
|
|
492
|
+
* `_lifecycle/services/presence-load.ts` spell its rationale out in JSDoc
|
|
493
|
+
* above the function it exempts.
|
|
494
|
+
*/
|
|
495
|
+
function suppressionLine(node, sf, nested) {
|
|
496
|
+
const full = sf.getFullText();
|
|
497
|
+
const inNested = (offset) => [...nested].some((n) => n !== node && offset >= n.getStart(sf) && offset < n.getEnd());
|
|
498
|
+
for (let at = full.indexOf(SUPPRESS_TOKEN, node.getStart(sf)); at !== -1 && at < node.getEnd(); at = full.indexOf(SUPPRESS_TOKEN, at + SUPPRESS_TOKEN.length)) {
|
|
499
|
+
if (!inNested(at))
|
|
500
|
+
return sf.getLineAndCharacterOfPosition(at).line + 1;
|
|
501
|
+
}
|
|
502
|
+
const leading = ts.getLeadingCommentRanges(full, node.getFullStart()) ?? [];
|
|
503
|
+
const own = leading.at(-1);
|
|
504
|
+
// Adjacent means "nothing but one line break between the comment and the
|
|
505
|
+
// declaration" — a blank line makes it a file header, not this unit's doc.
|
|
506
|
+
if (own && !/\n\s*\n/.test(full.slice(own.end, node.getStart(sf)))) {
|
|
507
|
+
const at = full.indexOf(SUPPRESS_TOKEN, own.pos);
|
|
508
|
+
if (at !== -1 && at < own.end)
|
|
509
|
+
return sf.getLineAndCharacterOfPosition(at).line + 1;
|
|
510
|
+
}
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
/** Static, dependency-free per-method analysis of a single source file. */
|
|
514
|
+
export function analyzeSource(filePath, source) {
|
|
515
|
+
const sf = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
|
|
516
|
+
const collected = collectUnits(sf);
|
|
517
|
+
const units = collected.units.map((u) => ({ ...u, scan: scanUnit(u.node, sf, collected.nested) }));
|
|
518
|
+
// Delegation targets that are not themselves judged (see collectHelperClosures).
|
|
519
|
+
const helpers = collectHelperClosures(sf).map((h) => ({
|
|
520
|
+
...h,
|
|
521
|
+
scan: scanUnit(h.node, sf, collected.nested),
|
|
522
|
+
}));
|
|
523
|
+
const delegates = [...units, ...helpers];
|
|
524
|
+
// Pass 1 — a "runner" method executes writes through the Command Bus (calls
|
|
525
|
+
// commandBus.run, or defines a Command literal the bus will run). A method that
|
|
526
|
+
// delegates to a runner (`this.<runner>(...)`) is therefore audited too.
|
|
527
|
+
const runnerNames = new Set(delegates.filter((u) => u.scan.runsCommand || u.scan.definesCommand).map((u) => u.name));
|
|
528
|
+
// …and a "recorder" method writes audit by hand (`auditLog.record(...)` /
|
|
529
|
+
// `.recordWithin(...)`). A very common shape is a public write that mutates and
|
|
530
|
+
// then calls a private `this.writeAudit()` helper which records — the mutation
|
|
531
|
+
// and the audit call live in different methods. Recognizing delegation to a
|
|
532
|
+
// recorder (symmetric with runner delegation) clears that legitimate pattern
|
|
533
|
+
// instead of flagging an already-audited write as unaudited.
|
|
534
|
+
const recorderNames = new Set(delegates.filter((u) => u.scan.hasAuditWrite).map((u) => u.name));
|
|
535
|
+
// A unit is directly covered if it runs a Command, defines one, records audit,
|
|
536
|
+
// or forward-delegates to a runner/recorder.
|
|
537
|
+
const isDirectlyCovered = (u) => u.scan.runsCommand ||
|
|
538
|
+
u.scan.definesCommand ||
|
|
539
|
+
u.scan.hasAuditWrite ||
|
|
540
|
+
[...u.scan.calls].some((n) => runnerNames.has(n) || recorderNames.has(n));
|
|
541
|
+
// Reverse delegation — a mutating PRIVATE helper (`applyGlobal`, `applyProduct`)
|
|
542
|
+
// that is invoked by a covered public method is part of that method's audited
|
|
543
|
+
// unit of work: its `em.persist` only flushes when the covered caller flushes,
|
|
544
|
+
// co-transactionally with the caller's audit/Command. Collect every method name
|
|
545
|
+
// called via `this.<name>()` from a covered unit and treat those as covered too.
|
|
546
|
+
const coveredCallees = new Set();
|
|
547
|
+
for (const u of delegates) {
|
|
548
|
+
if (isDirectlyCovered(u))
|
|
549
|
+
for (const n of u.scan.calls)
|
|
550
|
+
coveredCallees.add(n);
|
|
551
|
+
}
|
|
552
|
+
// The staleness half. A unit still writes if it writes itself, or if anything
|
|
553
|
+
// it calls as `this.<name>(…)` in this file does — `reserve()` delegating to
|
|
554
|
+
// `#reserveFlat()`, a reaper delegating to `release()`. Memoised over the
|
|
555
|
+
// recursion so a cycle terminates.
|
|
556
|
+
const byName = new Map(delegates.map((u) => [u.name, u]));
|
|
557
|
+
/** Does this delegate carry a marker of its own? Helpers never do. */
|
|
558
|
+
const carriesMarker = (unit) => 'suppressed' in unit && unit.suppressed === true;
|
|
559
|
+
const reachesWrite = (name, seen = new Set()) => {
|
|
560
|
+
if (seen.has(name))
|
|
561
|
+
return false;
|
|
562
|
+
seen.add(name);
|
|
563
|
+
const unit = byName.get(name);
|
|
564
|
+
if (!unit)
|
|
565
|
+
return false;
|
|
566
|
+
if (unit.scan.writesAnything)
|
|
567
|
+
return true;
|
|
568
|
+
return [...unit.scan.calls].some((callee) => {
|
|
569
|
+
// D-89(c) — a downstream that carries its own marker is already exempted
|
|
570
|
+
// where it writes, so it does not keep this caller's marker alive. Without
|
|
571
|
+
// this the ratchet cannot see the shape it was written for: a marker on
|
|
572
|
+
// two public callers of a private body that carries a third.
|
|
573
|
+
const target = byName.get(callee);
|
|
574
|
+
if (target !== undefined && carriesMarker(target))
|
|
575
|
+
return false;
|
|
576
|
+
return reachesWrite(callee, seen);
|
|
577
|
+
});
|
|
578
|
+
};
|
|
579
|
+
// A route handler is named `POST /api/…`, a method `rename` — only the latter
|
|
580
|
+
// reads as a call site.
|
|
581
|
+
const label = (name) => (name.includes(' ') ? name : `${name}()`);
|
|
582
|
+
const findings = [];
|
|
583
|
+
for (const u of units) {
|
|
584
|
+
if (u.suppressed) {
|
|
585
|
+
if (!reachesWrite(u.name)) {
|
|
586
|
+
findings.push({
|
|
587
|
+
filePath,
|
|
588
|
+
line: u.suppressedLine,
|
|
589
|
+
method: u.name,
|
|
590
|
+
kind: 'stale-ignore',
|
|
591
|
+
message: `${label(u.name)} carries a command-coverage-ignore but writes nothing — ` +
|
|
592
|
+
'the write it exempted has moved or gone. Delete the marker; keep the ' +
|
|
593
|
+
'sentence as an ordinary comment if it still explains something',
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
const s = u.scan;
|
|
599
|
+
const covered = isDirectlyCovered(u) || coveredCallees.has(u.name);
|
|
600
|
+
if (s.hasMutation && !covered) {
|
|
601
|
+
findings.push({
|
|
602
|
+
filePath,
|
|
603
|
+
line: s.mutationLine,
|
|
604
|
+
method: u.name,
|
|
605
|
+
kind: 'unaudited-sensitive-write',
|
|
606
|
+
message: `${label(u.name)} mutates without a Command or an audit entry`,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
if (s.runsCommand && s.hasAuditWrite) {
|
|
610
|
+
findings.push({
|
|
611
|
+
filePath,
|
|
612
|
+
line: s.mutationLine,
|
|
613
|
+
method: u.name,
|
|
614
|
+
kind: 'double-audit',
|
|
615
|
+
message: `${label(u.name)} runs a Command AND records audit by hand (remove the manual record() — FR-010)`,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return findings;
|
|
620
|
+
}
|
|
621
|
+
/** Whether a repo-relative module path belongs to a build-breaking (migrated) module. */
|
|
622
|
+
export function isMigratedModulePath(relPath, migrated) {
|
|
623
|
+
// Not anchored on `/services/` any more: the walk reaches `routes.admin.ts`,
|
|
624
|
+
// `workers/` and `commands/` too, and anchoring there would have made every
|
|
625
|
+
// newly visible file report-only in a module that is fully migrated.
|
|
626
|
+
const m = /modules\/([^/]+)\//.exec(relPath.replaceAll('\\', '/'));
|
|
627
|
+
return m !== null && migrated.includes(m[1]);
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Registered modules {@link collectScannedFiles} excludes wholesale, so the
|
|
631
|
+
* population floor asks for the tree the check actually reads.
|
|
632
|
+
*
|
|
633
|
+
* One entry, and it is the argument in the header rather than a convenience:
|
|
634
|
+
* the audit writer's writes *are* the audit entries. Named here so the walk and
|
|
635
|
+
* the floor cannot disagree about it.
|
|
636
|
+
*/
|
|
637
|
+
export const EXCLUDED_MODULES = ['audit_logs'];
|
|
638
|
+
/**
|
|
639
|
+
* Directory names this rule's walk prunes wherever they occur under its root.
|
|
640
|
+
*
|
|
641
|
+
* `migrations` is the one that is a *rule* rather than a convenience: a
|
|
642
|
+
* migration is schema, not a service write, and judging one would put a finding
|
|
643
|
+
* on every table this platform creates.
|
|
644
|
+
*
|
|
645
|
+
* `test-support` is the second of that kind, and it arrived measured
|
|
646
|
+
* (`specs/134-paid-module-extraction/` T015). A module's test doubles and
|
|
647
|
+
* fixtures moved out of `backend/test/helpers/` into `src/test-support/`, which
|
|
648
|
+
* is inside this walk where the old location was outside it — so
|
|
649
|
+
* `seedXlInstallation()`, a fixture writer that has always existed, became an
|
|
650
|
+
* `unaudited-sensitive-write` on the day it moved and took `master` red in
|
|
651
|
+
* `moved-module-tree.test.ts` rather than in the check anybody had run. A
|
|
652
|
+
* fixture is not a service write for the same reason a migration is not: nothing
|
|
653
|
+
* an operator did is being recorded, and requiring a Command of one would put a
|
|
654
|
+
* finding on every seed in the tree. The layer is excluded exactly as
|
|
655
|
+
* `*.test.ts` is below, which is what it is — the test tree, one directory over.
|
|
656
|
+
*/
|
|
657
|
+
export const PRUNED_DIRECTORIES = [
|
|
658
|
+
'node_modules',
|
|
659
|
+
'dist',
|
|
660
|
+
'migrations',
|
|
661
|
+
'test-support',
|
|
662
|
+
];
|
|
663
|
+
/**
|
|
664
|
+
* Whether a file is in this rule's population — the **one** membership decision,
|
|
665
|
+
* asked by the walk and by anything that needs to know what the walk would open.
|
|
666
|
+
*
|
|
667
|
+
* It is one function called from two places on purpose, in the idiom
|
|
668
|
+
* `check-nul-bytes.ts` argues for in its own header: while the prune the walk
|
|
669
|
+
* does for speed and the rule the population states were two expressions of one
|
|
670
|
+
* predicate, one could go missing without the other noticing. The second caller
|
|
671
|
+
* is `endora check`'s package-scope floor, which has to know that a declared
|
|
672
|
+
* `./migrations` layer is **not** expected of this rule — otherwise a package
|
|
673
|
+
* that publishes migrations reports a short walk for a layer the rule excludes
|
|
674
|
+
* by design.
|
|
675
|
+
*/
|
|
676
|
+
export function isScannedPath(absolutePath, root) {
|
|
677
|
+
const posix = absolutePath.replaceAll('\\', '/');
|
|
678
|
+
const within = posix.slice(root.replaceAll('\\', '/').length).split('/');
|
|
679
|
+
if (within.slice(0, -1).some((segment) => PRUNED_DIRECTORIES.includes(segment)))
|
|
680
|
+
return false;
|
|
681
|
+
if (!posix.endsWith('.ts') || posix.endsWith('.d.ts') || posix.endsWith('.test.ts'))
|
|
682
|
+
return false;
|
|
683
|
+
return !EXCLUDED_MODULES.some((id) => posix.includes(`/${id}/`));
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Every file the check judges, under `root` (`src/modules` or `src/apps`).
|
|
687
|
+
*
|
|
688
|
+
* Exported so the check's own test can assert the **real** tree is clean rather
|
|
689
|
+
* than only that the analyzer can go red on a fixture — the scan scope then has
|
|
690
|
+
* one definition, shared by the CLI and the test.
|
|
691
|
+
*
|
|
692
|
+
* The exclusions are argued in the header; each is a claim that the category
|
|
693
|
+
* cannot hold an operator-visible write, not a convenience.
|
|
694
|
+
*/
|
|
695
|
+
export function collectScannedFiles(root) {
|
|
696
|
+
const files = [];
|
|
697
|
+
const walk = (dir) => {
|
|
698
|
+
for (const name of readdirSync(dir)) {
|
|
699
|
+
const full = join(dir, name);
|
|
700
|
+
const st = statSync(full);
|
|
701
|
+
if (st.isDirectory()) {
|
|
702
|
+
if (PRUNED_DIRECTORIES.includes(name))
|
|
703
|
+
continue;
|
|
704
|
+
walk(full);
|
|
705
|
+
}
|
|
706
|
+
else if (isScannedPath(full, root)) {
|
|
707
|
+
files.push(full);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
walk(root);
|
|
712
|
+
return files;
|
|
713
|
+
}
|
|
714
|
+
//# sourceMappingURL=command-coverage.js.map
|