@starci/hfs 4.0.9 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -1
- package/LICENSE +21 -0
- package/emit/operations.mjs +1 -1
- package/emit/type-schema.mjs +2 -1
- package/lint/run.mjs +82 -8
- package/package.json +3 -2
- package/report/order.mjs +2 -0
- package/runtime/config.example.yaml +187 -0
- package/runtime/engine/by-code-unit.mjs +12 -0
- package/runtime/engine/config.mjs +247 -273
- package/runtime/engine/invalid-config.mjs +5 -5
- package/runtime/engine/model-config.mjs +90 -0
- package/runtime/engine/orca-config.mjs +3 -1
- package/runtime/engine/removed-vocabulary.mjs +28 -0
- package/runtime/engine/resources-config.mjs +52 -0
- package/runtime/engine/runtime-root.mjs +17 -0
- package/runtime/engine/secrets.mjs +134 -0
- package/runtime/engine/sonar-config.mjs +21 -0
- package/runtime/engine/temp-root.mjs +32 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +18 -18
- package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
- package/runtime/knowledge/hfs/rules.yaml +102 -69
- package/runtime/knowledge/hfs/slots.yaml +11 -14
- package/runtime/knowledge/patterns/be/api.yaml +7 -24
- package/runtime/knowledge/patterns/be/cli.yaml +3 -12
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
- package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
- package/runtime/modules/kernel/failure-codes.yaml +1 -1
- package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
- package/runtime/modules/models/registry.yaml +25 -397
- package/runtime/modules/models/runtimes.yaml +92 -262
- package/runtime/modules/models/tiers.yaml +65 -0
- package/runtime/scripts/api/fs/claim-file.mjs +30 -0
- package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
- package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
- package/runtime/scripts/api/fs/lib.mjs +6 -0
- package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
- package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
- package/runtime/scripts/api/git/lib.mjs +36 -1
- package/runtime/scripts/api/process/resolve-real-tool.mjs +52 -0
- package/runtime/scripts/api/process/run-program.mjs +8 -0
- package/runtime/scripts/api/sops/decrypt.mjs +9 -16
- package/runtime/scripts/api/sops/lib.mjs +309 -11
- package/runtime/scripts/api/sops/seal.mjs +8 -4
- package/runtime/scripts/hfs/allows.mjs +3 -3
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
- package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
- package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
- package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
- package/runtime/scripts/hfs/architecture/config.mjs +95 -80
- package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
- package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
- package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
- package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
- package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
- package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
- package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
- package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
- package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
- package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
- package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
- package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
- package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
- package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
- package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
- package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +3 -2
- package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
- package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
- package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
- package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
- package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
- package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
- package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
- package/runtime/scripts/hfs/architecture/index.mjs +158 -102
- package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
- package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
- package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
- package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
- package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
- package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
- package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
- package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
- package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
- package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
- package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
- package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
- package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
- package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
- package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
- package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
- package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
- package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
- package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
- package/runtime/scripts/hfs/architecture.mjs +3 -4
- package/runtime/scripts/hfs/check.mjs +61 -70
- package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
- package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
- package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
- package/runtime/scripts/hfs/edition-slots.mjs +5 -3
- package/runtime/scripts/hfs/linear-text.mjs +30 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
- package/runtime/scripts/hfs/path-findings.mjs +58 -38
- package/runtime/scripts/hfs/pin-findings.mjs +31 -0
- package/runtime/scripts/hfs/repo-identity.mjs +5 -2
- package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
- package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
- package/runtime/scripts/hfs/rules/cli.mjs +5 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
- package/runtime/scripts/hfs/rules/contract.mjs +35 -27
- package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
- package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
- package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
- package/runtime/scripts/hfs/rules/database.mjs +36 -23
- package/runtime/scripts/hfs/rules/deps.mjs +64 -32
- package/runtime/scripts/hfs/rules/docker.mjs +113 -65
- package/runtime/scripts/hfs/rules/edition.mjs +118 -111
- package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
- package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
- package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
- package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
- package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
- package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
- package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
- package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
- package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
- package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
- package/runtime/scripts/hfs/rules/saga.mjs +128 -61
- package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
- package/runtime/scripts/hfs/rules/services.mjs +34 -16
- package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
- package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
- package/runtime/scripts/hfs/secret.mjs +90 -45
- package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
- package/runtime/scripts/hfs/slot-classify.mjs +65 -0
- package/runtime/scripts/hfs/slot-errors.mjs +11 -0
- package/runtime/scripts/hfs/slot-imports.mjs +76 -0
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
- package/runtime/scripts/hfs/slot-match.mjs +153 -0
- package/runtime/scripts/hfs/slot-path.mjs +8 -0
- package/runtime/scripts/hfs/slot-required.mjs +76 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
- package/runtime/scripts/hfs/slots.mjs +72 -693
- package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
- package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
- package/runtime/scripts/hfs/tree.mjs +19 -14
- package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
- package/runtime/scripts/hfs/view.mjs +1 -1
- package/runtime/scripts/lib/dockerfile.mjs +56 -33
- package/runtime/scripts/lib/event-contract.mjs +17 -13
- package/runtime/scripts/lib/fs-kind.mjs +14 -4
- package/runtime/scripts/lib/git.mjs +2 -2
- package/runtime/scripts/lib/graphql-contract.mjs +158 -322
- package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
- package/runtime/scripts/lib/i18n.mjs +3 -2
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +3 -3
- package/runtime/scripts/lib/list.mjs +8 -1
- package/runtime/scripts/lib/mutation-fence.mjs +15 -0
- package/runtime/scripts/lib/path-key.mjs +42 -9
- package/runtime/scripts/lib/pid-alive.mjs +7 -0
- package/runtime/scripts/lib/regex.mjs +1 -1
- package/runtime/scripts/lib/same-text.mjs +1 -1
- package/runtime/scripts/lib/secret-patterns.mjs +6 -2
- package/runtime/scripts/lib/sleep-sync.mjs +2 -2
- package/runtime/scripts/lib/sops-envelope.mjs +103 -2
- package/runtime/scripts/lib/stack-services.mjs +1 -2
- package/runtime/scripts/lib/ts-ast.mjs +9 -0
- package/runtime/scripts/lib/walk.mjs +6 -1
- package/scaffold/add-cli-lite.mjs +2 -1
- package/scaffold/add-table.mjs +2 -2
- package/scaffold/add.mjs +3 -2
- package/scaffold/app.mjs +4 -2
- package/scaffold/edition-gate.mjs +25 -25
- package/scaffold/lite-exports.mjs +2 -1
- package/scaffold/service.mjs +5 -4
- package/sync/hygiene.mjs +15 -9
- package/sync/index.mjs +7 -3
- package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
- package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
- package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
- package/templates/app/ci-workflows/sonar-steps.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
- package/templates/app/starciwork.gitignore +1 -1
- package/templates/be/image/api/Dockerfile +1 -1
- package/templates/be/image/cli/Dockerfile +1 -1
- package/templates/be/image/worker/Dockerfile +1 -1
- package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
- package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
- package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
- package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
- package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
- package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
- package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
- package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
- package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
- package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
- package/templates/fe/image/next/Dockerfile +2 -1
- package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
- package/upgrade/index.mjs +9 -2
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { machineKit } from './machine-ast.mjs';
|
|
3
|
+
import { callName } from './supabase-ast.mjs';
|
|
3
4
|
import { isTestWorldSlot } from '../test-world-slot.mjs';
|
|
5
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* R45 `register-once` (BE_MODULE_SHAPE), the module graph read from the app roots (owner rule of 2026-09-30, the
|
|
@@ -21,19 +23,7 @@ export const REGISTER_ONCE_RULE_IDS = ['BE_MODULE_SHAPE'];
|
|
|
21
23
|
const RULE = 'BE_MODULE_SHAPE';
|
|
22
24
|
const CAPABILITY_TIERS = new Set(['domain', 'platform', 'integrations']);
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
const { config, graph } = input;
|
|
26
|
-
const kit = machineKit(input);
|
|
27
|
-
const { ts } = kit;
|
|
28
|
-
const violations = [];
|
|
29
|
-
const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
|
|
30
|
-
const isAppRoot = file => Boolean(file.slot?.startsWith('be.app.')) && path.posix.basename(file.rel) === 'app.module.ts';
|
|
31
|
-
// The test world (slot be.tests.world) is the test composition root: useTestWorld registers capability modules with
|
|
32
|
-
// `isGlobal: true` the way an app root does. A spec (integration, contract, e2e) is not a composition root.
|
|
33
|
-
const isWorld = file => isTestWorldSlot(file.slot);
|
|
34
|
-
const appOf = file => config.apps.find(app => file.rel === `apps/${app.name}/src/app.module.ts`)?.name ?? null;
|
|
35
|
-
|
|
36
|
-
// Every @Module class of the program.
|
|
26
|
+
function moduleDeclarations(graph, kit, ts) {
|
|
37
27
|
const modules = new Map(); // declaration -> {name, file, capability}
|
|
38
28
|
for (const file of graph.files.values()) {
|
|
39
29
|
const checker = kit.checkerOf(file.sourceFile);
|
|
@@ -47,57 +37,81 @@ export function checkRegisterOnce(input) {
|
|
|
47
37
|
if (isModule) modules.set(statement, { name: statement.name.text, file, capability: CAPABILITY_TIERS.has(file.tier) });
|
|
48
38
|
}
|
|
49
39
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
40
|
+
return modules;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function targetOf(kit, modules, checker, node) {
|
|
44
|
+
for (const declaration of kit.declarationsOf(checker, node)) {
|
|
45
|
+
if (modules.has(declaration)) return { declaration, ...modules.get(declaration) };
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function enclosingModule(ts, modules, node) {
|
|
51
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
52
|
+
if (ts.isClassDeclaration(current) && modules.has(current)) return current;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Records the reference an element of an imports list, or a register call, makes to a module. */
|
|
58
|
+
function noteReference(scan, element, viaImports) {
|
|
59
|
+
const { ts, kit, modules, checker, file, seen, references, isAppRoot } = scan;
|
|
60
|
+
const call = callName(ts, element) === 'register';
|
|
61
|
+
if (ts.isCallExpression(element) && !call) return;
|
|
62
|
+
const target = targetOf(kit, modules, checker, call ? element.expression.expression : element);
|
|
63
|
+
if (!target) return;
|
|
64
|
+
seen.add(element);
|
|
65
|
+
const owner = enclosingModule(ts, modules, element);
|
|
66
|
+
references.push({ file, node: element, target, register: call, viaImports, importer: owner ?? file.rel, importerOwner: file.owner?.root ?? null,
|
|
67
|
+
options: call ? element.arguments[0] : null, root: isAppRoot(file) });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const isImportsList = (scan, node) => scan.ts.isPropertyAssignment(node) && scan.kit.propertyNameText(node.name) === 'imports' && scan.ts.isArrayLiteralExpression(node.initializer);
|
|
71
|
+
|
|
72
|
+
const isStrayGlobal = (scan, node) => scan.ts.isPropertyAssignment(node) && scan.kit.propertyNameText(node.name) === 'isGlobal'
|
|
73
|
+
&& node.initializer.kind === scan.ts.SyntaxKind.TrueKeyword && !scan.isAppRoot(scan.file) && !scan.isWorld(scan.file);
|
|
58
74
|
|
|
75
|
+
function visitReferenceNode(scan, node) {
|
|
76
|
+
if (isImportsList(scan, node)) {
|
|
77
|
+
for (const element of node.initializer.elements) noteReference(scan, element, true);
|
|
78
|
+
} else if (callName(scan.ts, node) === 'register' && !scan.seen.has(node)) {
|
|
79
|
+
noteReference(scan, node, false);
|
|
80
|
+
}
|
|
81
|
+
if (isStrayGlobal(scan, node)) {
|
|
82
|
+
scan.report(scan.file, node, '`isGlobal: true` appears only in the app root (apps/<app>/src/app.module.ts), where the representative module of a capability is registered; a module never decides its own globality.');
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function moduleReferences(graph, kit, ts, modules, isWorld, isAppRoot, report) {
|
|
59
88
|
// The references to modules: importer (the enclosing module class, else the file), target, node, whether it is a register call.
|
|
60
89
|
const references = [];
|
|
61
90
|
for (const file of graph.files.values()) {
|
|
62
91
|
if (isWorld(file)) continue; // the test composition root assembles modules; it is not a module importer
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
const note = (element, viaImports) => {
|
|
66
|
-
const call = ts.isCallExpression(element) && ts.isPropertyAccessExpression(element.expression) && element.expression.name.text === 'register';
|
|
67
|
-
if (ts.isCallExpression(element) && !call) return;
|
|
68
|
-
const target = targetOf(checker, call ? element.expression.expression : element);
|
|
69
|
-
if (!target) return;
|
|
70
|
-
seen.add(element);
|
|
71
|
-
const owner = enclosingModule(element);
|
|
72
|
-
references.push({ file, node: element, target, register: call, viaImports, importer: owner ?? file.rel, importerOwner: file.owner?.root ?? null,
|
|
73
|
-
options: call ? element.arguments[0] : null, root: isAppRoot(file) });
|
|
74
|
-
};
|
|
75
|
-
kit.walk(file.sourceFile, node => {
|
|
76
|
-
if (ts.isPropertyAssignment(node) && kit.propertyNameText(node.name) === 'imports' && ts.isArrayLiteralExpression(node.initializer)) {
|
|
77
|
-
for (const element of node.initializer.elements) note(element, true);
|
|
78
|
-
} else if (ts.isCallExpression(node) && !seen.has(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register') {
|
|
79
|
-
note(node, false);
|
|
80
|
-
}
|
|
81
|
-
if (ts.isPropertyAssignment(node) && kit.propertyNameText(node.name) === 'isGlobal' && node.initializer.kind === ts.SyntaxKind.TrueKeyword && !isAppRoot(file) && !isWorld(file)) {
|
|
82
|
-
report(file, node, '`isGlobal: true` appears only in the app root (apps/<app>/src/app.module.ts), where the representative module of a capability is registered; a module never decides its own globality.');
|
|
83
|
-
}
|
|
84
|
-
return true;
|
|
85
|
-
});
|
|
92
|
+
const scan = { ts, kit, modules, file, isWorld, isAppRoot, report, references, checker: kit.checkerOf(file.sourceFile), seen: new Set() };
|
|
93
|
+
kit.walk(file.sourceFile, node => visitReferenceNode(scan, node));
|
|
86
94
|
}
|
|
95
|
+
return references;
|
|
96
|
+
}
|
|
87
97
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
98
|
+
function isTrueGlobal(kit, ts, options) {
|
|
99
|
+
if (!options || !ts.isObjectLiteralExpression(options)) return false;
|
|
100
|
+
const property = kit.propertyOf(options, 'isGlobal');
|
|
101
|
+
return Boolean(property) && ts.isPropertyAssignment(property) && property.initializer.kind === ts.SyntaxKind.TrueKeyword;
|
|
102
|
+
}
|
|
92
103
|
|
|
93
|
-
|
|
94
|
-
const rootReferences = references.filter(item => item.root);
|
|
104
|
+
function appRegistrations(rootReferences, appOf) {
|
|
95
105
|
const inApps = new Map(); // module declaration -> Set(app names)
|
|
96
106
|
for (const item of rootReferences) {
|
|
97
107
|
const app = appOf(item.file) ?? item.file.rel;
|
|
98
108
|
if (!inApps.has(item.target.declaration)) inApps.set(item.target.declaration, new Set());
|
|
99
109
|
inApps.get(item.target.declaration).add(app);
|
|
100
110
|
}
|
|
111
|
+
return inApps;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function reportDuplicateAppModules(config, rootReferences, appOf, report) {
|
|
101
115
|
for (const app of config.apps) {
|
|
102
116
|
const seenInApp = new Map();
|
|
103
117
|
for (const item of rootReferences.filter(entry => appOf(entry.file) === app.name)) {
|
|
@@ -106,35 +120,70 @@ export function checkRegisterOnce(input) {
|
|
|
106
120
|
} else seenInApp.set(item.target.declaration, item.node);
|
|
107
121
|
}
|
|
108
122
|
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function reportCapabilityRegistrations(rootReferences, kit, ts, report) {
|
|
109
126
|
for (const item of rootReferences) {
|
|
110
127
|
if (!item.target.capability || !item.viaImports && !item.register) continue;
|
|
111
128
|
if (!item.register) report(item.file, item.node, `${item.target.name} is listed in imports as a bare class; the representative module of a capability is registered in the app root as ${item.target.name}.register({ isGlobal: true, ...options }).`, { module: item.target.name });
|
|
112
|
-
else if (!
|
|
129
|
+
else if (!isTrueGlobal(kit, ts, item.options)) report(item.file, item.node, `${item.target.name} is registered in the app root without the literal \`isGlobal: true\`; write ${item.target.name}.register({ isGlobal: true, ...options }).`, { module: item.target.name });
|
|
113
130
|
}
|
|
131
|
+
}
|
|
114
132
|
|
|
115
|
-
|
|
133
|
+
/** The references that import a module from a non-app module, grouped by the imported module declaration. */
|
|
134
|
+
function importersByModule(references) {
|
|
116
135
|
const importersOf = new Map(); // module declaration -> [reference]
|
|
117
136
|
for (const item of references.filter(entry => !entry.root && entry.viaImports)) {
|
|
118
137
|
if (!importersOf.has(item.target.declaration)) importersOf.set(item.target.declaration, []);
|
|
119
138
|
importersOf.get(item.target.declaration).push(item);
|
|
120
139
|
}
|
|
121
|
-
|
|
140
|
+
return importersOf;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function reportSharedSubModule(target, list, report) {
|
|
144
|
+
const distinct = [...new Set(list.map(item => item.importer))];
|
|
145
|
+
const featureCase = target.file.tier === 'feature' && target.file.owner && list.every(item => item.importerOwner === target.file.owner.root);
|
|
146
|
+
if (distinct.length <= 1 || featureCase) return;
|
|
147
|
+
for (const item of list.filter(entry => entry.importer !== list[0].importer)) {
|
|
148
|
+
report(item.file, item.node, `${target.name} is imported by ${distinct.length} modules; a sub-module has exactly one importer, its parent. Another capability's module is consumed through its Inject*() decorators, never imported.`, { module: target.name });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function reportNonAppImporters(references, modules, inApps, report) {
|
|
153
|
+
// Non-app importers: no importer of an app-registered module, exactly one importer otherwise (the feature case excepted).
|
|
154
|
+
for (const [declaration, list] of importersByModule(references)) {
|
|
122
155
|
const target = modules.get(declaration);
|
|
123
156
|
const apps = inApps.get(declaration);
|
|
124
|
-
if (apps) {
|
|
125
|
-
|
|
126
|
-
report(item.file, item.node, `${target.name} is registered in the root of app ${[...apps].sort().join(', ')}, so no other module imports it; consume it through its Inject*() decorators.`, { module: target.name });
|
|
127
|
-
}
|
|
157
|
+
if (!apps) {
|
|
158
|
+
reportSharedSubModule(target, list, report);
|
|
128
159
|
continue;
|
|
129
160
|
}
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
if (distinct.length > 1 && !featureCase) {
|
|
133
|
-
for (const item of list.filter(entry => entry.importer !== list[0].importer)) {
|
|
134
|
-
report(item.file, item.node, `${target.name} is imported by ${distinct.length} modules; a sub-module has exactly one importer, its parent. Another capability's module is consumed through its Inject*() decorators, never imported.`, { module: target.name });
|
|
135
|
-
}
|
|
161
|
+
for (const item of list) {
|
|
162
|
+
report(item.file, item.node, `${target.name} is registered in the root of app ${[...apps].sort(byCodeUnit).join(', ')}, so no other module imports it; consume it through its Inject*() decorators.`, { module: target.name });
|
|
136
163
|
}
|
|
137
164
|
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function checkRegisterOnce(input) {
|
|
168
|
+
const { config, graph } = input;
|
|
169
|
+
const kit = machineKit(input);
|
|
170
|
+
const { ts } = kit;
|
|
171
|
+
const violations = [];
|
|
172
|
+
const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
|
|
173
|
+
const isAppRoot = file => Boolean(file.slot?.startsWith('be.app.')) && path.posix.basename(file.rel) === 'app.module.ts';
|
|
174
|
+
// The test world (slot be.tests.world) is the test composition root: useTestWorld registers capability modules with
|
|
175
|
+
// `isGlobal: true` the way an app root does. A spec (integration, contract, e2e) is not a composition root.
|
|
176
|
+
const isWorld = file => isTestWorldSlot(file.slot);
|
|
177
|
+
const appOf = file => config.apps.find(app => file.rel === `apps/${app.name}/src/app.module.ts`)?.name ?? null;
|
|
178
|
+
const modules = moduleDeclarations(graph, kit, ts);
|
|
179
|
+
const references = moduleReferences(graph, kit, ts, modules, isWorld, isAppRoot, report);
|
|
180
|
+
const rootReferences = references.filter(item => item.root);
|
|
181
|
+
const inApps = appRegistrations(rootReferences, appOf);
|
|
182
|
+
|
|
183
|
+
// App roots: each module is listed once, capability modules are registered with isGlobal true.
|
|
184
|
+
reportDuplicateAppModules(config, rootReferences, appOf, report);
|
|
185
|
+
reportCapabilityRegistrations(rootReferences, kit, ts, report);
|
|
186
|
+
reportNonAppImporters(references, modules, inApps, report);
|
|
138
187
|
|
|
139
188
|
return { violations, coverage: { status: 'checked', apps: config.apps.filter(app => kit.appRoot(app.name)).length, modules: modules.size,
|
|
140
189
|
capabilityModules: [...modules.values()].filter(item => item.capability).length, registrations: references.filter(item => item.register).length,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { canonical } from './config.mjs';
|
|
3
3
|
import { referencedExports, relativePath, unwrapExpression } from './typescript.mjs';
|
|
4
|
-
import { commonJsRequireReasons, decoratorCallee, moduleExportsOf, mutableDecoratorKind,
|
|
4
|
+
import { commonJsRequireReasons, decoratorCallee, moduleExportsOf, mutableDecoratorKind, programSourcesOf, selectedNode, valueSymbol } from './ast-walks.mjs';
|
|
5
5
|
import { sourceLocation } from '../../lib/ts-ast.mjs';
|
|
6
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
6
7
|
|
|
7
8
|
const REGISTRATION_RULE_IDS = ['BE_MODULE_HANDLER_REGISTRATION', 'BE_MODULE_PROVIDER_REREGISTRATION'];
|
|
8
9
|
const FRAMEWORK = new Map([
|
|
@@ -27,28 +28,33 @@ const mutableFrameworkAlias = (ts, checker, decorator, targets) => {
|
|
|
27
28
|
return mutableDecoratorKind(ts, checker, decorator, bySymbol);
|
|
28
29
|
};
|
|
29
30
|
|
|
31
|
+
function collectFrameworkStatement(config, context, checker, sourceFile, statement, targets, reasons) {
|
|
32
|
+
const bound = moduleExportsOf(context.ts, checker, statement);
|
|
33
|
+
if (!bound || ![...FRAMEWORK.values()].includes(bound.specifier)) return;
|
|
34
|
+
const expected = new Set([...FRAMEWORK].filter(([, packageName]) => packageName === bound.specifier).map(([name]) => name));
|
|
35
|
+
const selected = referencedExports(context.ts, statement, expected);
|
|
36
|
+
if (!selected.length) return;
|
|
37
|
+
if (!bound.symbol) {
|
|
38
|
+
reasons.push(`${relativePath(config.root, sourceFile.fileName)} cannot resolve ${bound.specifier}`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
for (const name of selected) {
|
|
42
|
+
const target = bound.exports.get(name);
|
|
43
|
+
if (!target) {
|
|
44
|
+
reasons.push(`${relativePath(config.root, sourceFile.fileName)} cannot resolve ${name} from ${bound.specifier}`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
targets.set(name, target);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
30
51
|
function frameworkTargets(config, context, checker, localFiles) {
|
|
31
52
|
const targets = new Map();
|
|
32
53
|
const reasons = [];
|
|
33
54
|
const files = programSourcesOf(context, checker, localFiles);
|
|
34
55
|
if (!files) return { targets, reasons: ['a TypeScript program checker could not be associated with its source'] };
|
|
35
56
|
for (const sourceFile of files) {
|
|
36
|
-
for (const statement of sourceFile.statements)
|
|
37
|
-
const bound = moduleExportsOf(context.ts, checker, statement);
|
|
38
|
-
if (!bound || ![...FRAMEWORK.values()].includes(bound.specifier)) continue;
|
|
39
|
-
const expected = new Set([...FRAMEWORK].filter(([, packageName]) => packageName === bound.specifier).map(([name]) => name));
|
|
40
|
-
const selected = referencedExports(context.ts, statement, expected);
|
|
41
|
-
if (!selected.length) continue;
|
|
42
|
-
if (!bound.symbol) {
|
|
43
|
-
reasons.push(`${relativePath(config.root, sourceFile.fileName)} cannot resolve ${bound.specifier}`);
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
for (const name of selected) {
|
|
47
|
-
const target = bound.exports.get(name);
|
|
48
|
-
if (target) targets.set(name, target);
|
|
49
|
-
else reasons.push(`${relativePath(config.root, sourceFile.fileName)} cannot resolve ${name} from ${bound.specifier}`);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
57
|
+
for (const statement of sourceFile.statements) collectFrameworkStatement(config, context, checker, sourceFile, statement, targets, reasons);
|
|
52
58
|
commonJsRequireReasons(context.ts, checker, sourceFile, (specifier) => [...FRAMEWORK.values()].includes(specifier),
|
|
53
59
|
reasons, relativePath(config.root, sourceFile.fileName), 'whose decorator identity cannot be proved', 'Nest framework binding');
|
|
54
60
|
}
|
|
@@ -73,28 +79,35 @@ function propertyName(ts, property) {
|
|
|
73
79
|
return null;
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
function inspectTokenElement(ts, checker, element, localFiles, field, tokens) {
|
|
83
|
+
if (ts.isSpreadElement(element)) return true;
|
|
84
|
+
let key;
|
|
85
|
+
let node = element;
|
|
86
|
+
if (ts.isObjectLiteralExpression(element)) {
|
|
87
|
+
if (element.properties.some(property => ts.isSpreadAssignment(property))) return true;
|
|
88
|
+
const providers = element.properties.filter(property => propertyName(ts, property) === 'provide');
|
|
89
|
+
if (providers.length !== 1 || !ts.isPropertyAssignment(providers[0])) return true;
|
|
90
|
+
node = providers[0].initializer;
|
|
91
|
+
key = localClassKey(ts, checker, node, localFiles);
|
|
92
|
+
} else {
|
|
93
|
+
const token = classToken(ts, checker, element, localFiles);
|
|
94
|
+
key = token.key;
|
|
95
|
+
if (!key) return field === 'providers' && !token.external;
|
|
96
|
+
}
|
|
97
|
+
if (key) {
|
|
98
|
+
if (!tokens.has(key)) tokens.set(key, []);
|
|
99
|
+
tokens.get(key).push(node);
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
76
104
|
function tokenEntries(ts, checker, initializer, localFiles, field) {
|
|
77
105
|
initializer = unwrapExpression(ts, initializer);
|
|
78
106
|
if (!ts.isArrayLiteralExpression(initializer)) return { tokens: new Map(), unknown: true };
|
|
79
107
|
const tokens = new Map();
|
|
80
108
|
let unknown = false;
|
|
81
|
-
const add = (key, node) => {
|
|
82
|
-
if (!tokens.has(key)) tokens.set(key, []);
|
|
83
|
-
tokens.get(key).push(node);
|
|
84
|
-
};
|
|
85
109
|
for (const element of initializer.elements) {
|
|
86
|
-
if (ts
|
|
87
|
-
if (ts.isObjectLiteralExpression(element)) {
|
|
88
|
-
if (element.properties.some(property => ts.isSpreadAssignment(property))) { unknown = true; continue; }
|
|
89
|
-
const providers = element.properties.filter(property => propertyName(ts, property) === 'provide');
|
|
90
|
-
if (providers.length !== 1 || !ts.isPropertyAssignment(providers[0])) { unknown = true; continue; }
|
|
91
|
-
const key = localClassKey(ts, checker, providers[0].initializer, localFiles);
|
|
92
|
-
if (key) add(key, providers[0].initializer);
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
const token = classToken(ts, checker, element, localFiles);
|
|
96
|
-
if (token.key) add(token.key, element);
|
|
97
|
-
else if (field === 'providers' && !token.external) unknown = true;
|
|
110
|
+
if (inspectTokenElement(ts, checker, element, localFiles, field, tokens)) unknown = true;
|
|
98
111
|
}
|
|
99
112
|
return { tokens, unknown };
|
|
100
113
|
}
|
|
@@ -131,114 +144,161 @@ function registrationFinding(module, node, ruleId, message, extra = {}) {
|
|
|
131
144
|
return { ruleId, path: module.path, ...sourceLocation(node.getSourceFile(), node), module: module.name, message, ...extra };
|
|
132
145
|
}
|
|
133
146
|
|
|
134
|
-
|
|
135
|
-
function applicationGraphs(config, context, modules) {
|
|
136
|
-
const roots = modules.filter(module => /^apps\/[^/]+\/src\/app\.module\.ts$/.test(module.path))
|
|
137
|
-
.sort((a, b) => a.path.localeCompare(b.path));
|
|
138
|
-
// Small architecture fixtures without app composition retain the original single-graph relation.
|
|
139
|
-
if (!roots.length) return [modules];
|
|
147
|
+
const modulesByFileOf = (config, modules) => {
|
|
140
148
|
const byFile = new Map();
|
|
141
149
|
for (const module of modules) {
|
|
142
150
|
const file = canonical(path.resolve(config.root, ...module.path.split('/')));
|
|
143
151
|
if (!byFile.has(file)) byFile.set(file, []);
|
|
144
152
|
byFile.get(file).push(module);
|
|
145
153
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
154
|
+
return byFile;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** The modules whose file the runtime import edges reach from `rootFile`, in first-visit order; each is added to `bound`. */
|
|
158
|
+
const reachableModules = (context, byFile, rootFile, bound) => {
|
|
159
|
+
const queue = [rootFile];
|
|
160
|
+
const seen = new Set();
|
|
161
|
+
const reachable = [];
|
|
162
|
+
for (const file of queue) {
|
|
163
|
+
if (seen.has(file)) continue;
|
|
164
|
+
seen.add(file);
|
|
165
|
+
for (const module of byFile.get(file) ?? []) {
|
|
166
|
+
reachable.push(module);
|
|
167
|
+
bound.add(module);
|
|
160
168
|
}
|
|
161
|
-
|
|
162
|
-
}
|
|
169
|
+
for (const edge of context.edges.get(file) ?? []) if (edge.runtime && !seen.has(edge.to)) queue.push(edge.to);
|
|
170
|
+
}
|
|
171
|
+
return reachable;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** A module belongs to an app only when its runtime source is reachable from that app's composition root. */
|
|
175
|
+
function applicationGraphs(config, context, modules) {
|
|
176
|
+
const roots = modules.filter(module => /^apps\/[^/]+\/src\/app\.module\.ts$/.test(module.path))
|
|
177
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
178
|
+
// Small architecture fixtures without app composition retain the original single-graph relation.
|
|
179
|
+
if (!roots.length) return [modules];
|
|
180
|
+
const byFile = modulesByFileOf(config, modules);
|
|
181
|
+
const bound = new Set();
|
|
182
|
+
const graphs = roots.map(root => reachableModules(context, byFile, canonical(path.resolve(config.root, ...root.path.split('/'))), bound));
|
|
163
183
|
// A module outside every proven app graph can still contain a duplicate in its own metadata.
|
|
164
184
|
return [...graphs, ...modules.filter(module => !bound.has(module)).map(module => [module])];
|
|
165
185
|
}
|
|
166
186
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
187
|
+
function decoratorKinds(ts, checker, node, framework) {
|
|
188
|
+
const decorators = ts.canHaveDecorators(node) ? ts.getDecorators(node) ?? [] : node.decorators ?? [];
|
|
189
|
+
return decorators.map(decorator => ({ decorator, kind: frameworkDecorator(ts, checker, decorator, framework.targets) }));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function recordMutableDecoratorReasons(config, ts, checker, sourceFile, kinds, framework, reasons) {
|
|
193
|
+
for (const { decorator, kind } of kinds) {
|
|
194
|
+
const dynamic = kind ? null : mutableFrameworkAlias(ts, checker, decorator, framework.targets);
|
|
195
|
+
if (dynamic) reasons.push(`${relativePath(config.root, sourceFile.fileName)} uses mutable ${dynamic} decorator identity`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function recordModuleDecorator(site, node, kinds, found) {
|
|
200
|
+
const { config, context, sourceFile, checker, localFiles } = site;
|
|
201
|
+
const { modules, reasons } = found;
|
|
202
|
+
const moduleDecorators = kinds.filter(item => item.kind === 'Module');
|
|
203
|
+
if (moduleDecorators.length === 1) modules.push(moduleRecord(config, context, sourceFile, node, moduleDecorators[0].decorator, checker, localFiles));
|
|
204
|
+
else if (moduleDecorators.length > 1) reasons.push(`${relativePath(config.root, sourceFile.fileName)} has multiple resolved @Module decorators`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function recordHandlerDecorator(site, node, kinds, found) {
|
|
208
|
+
const { config, context, sourceFile, checker, localFiles } = site;
|
|
209
|
+
const { handlers, reasons } = found;
|
|
210
|
+
const { ts } = context;
|
|
211
|
+
const handlerDecorators = kinds.filter(item => item.kind === 'CommandHandler' || item.kind === 'QueryHandler');
|
|
212
|
+
if (handlerDecorators.length === 1) handlers.push({
|
|
213
|
+
key: node.name ? localClassKey(ts, checker, node.name, localFiles) : null,
|
|
214
|
+
name: node.name?.text ?? '(anonymous)',
|
|
215
|
+
kind: handlerDecorators[0].kind,
|
|
216
|
+
path: relativePath(config.root, sourceFile.fileName),
|
|
217
|
+
...sourceLocation(sourceFile, node.name ?? node),
|
|
218
|
+
});
|
|
219
|
+
else if (handlerDecorators.length > 1) reasons.push(`${relativePath(config.root, sourceFile.fileName)} has multiple resolved CQRS handler decorators`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function inspectRegistrationClass(site, node, found) {
|
|
223
|
+
const { config, context, sourceFile, checker, framework } = site;
|
|
224
|
+
const { ts } = context;
|
|
225
|
+
if (!ts.isClassDeclaration(node)) return;
|
|
226
|
+
const kinds = decoratorKinds(ts, checker, node, framework);
|
|
227
|
+
recordMutableDecoratorReasons(config, ts, checker, sourceFile, kinds, framework, found.reasons);
|
|
228
|
+
recordModuleDecorator(site, node, kinds, found);
|
|
229
|
+
recordHandlerDecorator(site, node, kinds, found);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function discoverRegistrations(config, context, localFiles) {
|
|
173
233
|
const modules = [];
|
|
174
234
|
const handlers = [];
|
|
175
235
|
const reasons = [];
|
|
236
|
+
const found = { modules, handlers, reasons };
|
|
176
237
|
const frameworkByChecker = new Map();
|
|
177
238
|
for (const sourceFile of context.files) {
|
|
178
239
|
const checker = context.checkerFor(sourceFile.fileName);
|
|
179
240
|
if (!frameworkByChecker.has(checker)) frameworkByChecker.set(checker, frameworkTargets(config, context, checker, localFiles));
|
|
180
241
|
const framework = frameworkByChecker.get(checker);
|
|
181
242
|
reasons.push(...framework.reasons);
|
|
243
|
+
const site = { config, context, sourceFile, checker, localFiles, framework };
|
|
182
244
|
const visit = node => {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const kinds = decorators.map(decorator => ({ decorator, kind: frameworkDecorator(ts, checker, decorator, framework.targets) }));
|
|
186
|
-
const recognized = kinds.filter(item => item.kind);
|
|
187
|
-
for (const { decorator, kind } of kinds) {
|
|
188
|
-
const dynamic = kind ? null : mutableFrameworkAlias(ts, checker, decorator, framework.targets);
|
|
189
|
-
if (dynamic) reasons.push(`${relativePath(config.root, sourceFile.fileName)} uses mutable ${dynamic} decorator identity`);
|
|
190
|
-
}
|
|
191
|
-
const moduleDecorators = recognized.filter(item => item.kind === 'Module');
|
|
192
|
-
if (moduleDecorators.length === 1) modules.push(moduleRecord(config, context, sourceFile, node, moduleDecorators[0].decorator, checker, localFiles));
|
|
193
|
-
else if (moduleDecorators.length > 1) reasons.push(`${relativePath(config.root, sourceFile.fileName)} has multiple resolved @Module decorators`);
|
|
194
|
-
const handlerDecorators = recognized.filter(item => item.kind === 'CommandHandler' || item.kind === 'QueryHandler');
|
|
195
|
-
if (handlerDecorators.length === 1) handlers.push({
|
|
196
|
-
key: node.name ? localClassKey(ts, checker, node.name, localFiles) : null,
|
|
197
|
-
name: node.name?.text ?? '(anonymous)',
|
|
198
|
-
kind: handlerDecorators[0].kind,
|
|
199
|
-
path: relativePath(config.root, sourceFile.fileName),
|
|
200
|
-
...sourceLocation(sourceFile, node.name ?? node),
|
|
201
|
-
});
|
|
202
|
-
else if (handlerDecorators.length > 1) reasons.push(`${relativePath(config.root, sourceFile.fileName)} has multiple resolved CQRS handler decorators`);
|
|
203
|
-
}
|
|
204
|
-
ts.forEachChild(node, visit);
|
|
245
|
+
inspectRegistrationClass(site, node, found);
|
|
246
|
+
context.ts.forEachChild(node, visit);
|
|
205
247
|
};
|
|
206
248
|
visit(sourceFile);
|
|
207
249
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
const violations = [];
|
|
250
|
+
return { modules, handlers, reasons };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function providerOwnersOf(modules) {
|
|
214
254
|
const providerOwners = new Map();
|
|
215
|
-
for (const module of modules)
|
|
216
|
-
|
|
217
|
-
|
|
255
|
+
for (const module of modules) {
|
|
256
|
+
for (const key of module.providers.keys()) {
|
|
257
|
+
if (!module.exports.has(key)) continue;
|
|
258
|
+
if (!providerOwners.has(key)) providerOwners.set(key, []);
|
|
259
|
+
providerOwners.get(key).push(module);
|
|
260
|
+
}
|
|
218
261
|
}
|
|
262
|
+
return providerOwners;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const byModulePath = (a, b) => a.module.path.localeCompare(b.module.path) || a.node.getStart() - b.node.getStart();
|
|
266
|
+
|
|
267
|
+
/** The registrations of `key` in a graph that follow the first one of its owner module: the owner registers it once, every other is a duplicate. */
|
|
268
|
+
const duplicateRegistrations = (graph, key, owner) => {
|
|
269
|
+
const registrations = graph.flatMap(module => (module.providers.get(key) ?? []).map(node => ({ module, node })))
|
|
270
|
+
.sort(byModulePath);
|
|
271
|
+
let keptOwnerRegistration = false;
|
|
272
|
+
return registrations.filter(registration => {
|
|
273
|
+
if (registration.module !== owner || keptOwnerRegistration) return true;
|
|
274
|
+
keptOwnerRegistration = true;
|
|
275
|
+
return false;
|
|
276
|
+
});
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
function reregistrationViolations(config, context, modules, providerOwners) {
|
|
280
|
+
const violations = [];
|
|
219
281
|
const reportedRegistrations = new Set();
|
|
282
|
+
const report = (key, owner, duplicate) => {
|
|
283
|
+
const identity = `${key}\0${duplicate.module.path}\0${duplicate.node.getStart()}`;
|
|
284
|
+
if (reportedRegistrations.has(identity)) return;
|
|
285
|
+
reportedRegistrations.add(identity);
|
|
286
|
+
violations.push(registrationFinding(duplicate.module, duplicate.node,
|
|
287
|
+
'BE_MODULE_PROVIDER_REREGISTRATION',
|
|
288
|
+
`Class-token provider exported by ${owner.name} is registered again by ${duplicate.module.name} in one application graph; import the owning module and preserve one class-token registration.`,
|
|
289
|
+
{ ownerModule: owner.name, ownerPath: owner.path }));
|
|
290
|
+
};
|
|
220
291
|
for (const graph of applicationGraphs(config, context, modules)) for (const [key] of providerOwners) {
|
|
221
292
|
const owners = graph.filter(module => module.providers.has(key) && module.exports.has(key))
|
|
222
293
|
.sort((a, b) => a.path.localeCompare(b.path));
|
|
223
294
|
if (!owners.length) continue;
|
|
224
|
-
const
|
|
225
|
-
const registrations = graph.flatMap(module => (module.providers.get(key) ?? []).map(node => ({ module, node })))
|
|
226
|
-
.sort((a, b) => a.module.path.localeCompare(b.module.path) || a.node.getStart() - b.node.getStart());
|
|
227
|
-
let keptOwnerRegistration = false;
|
|
228
|
-
for (const duplicate of registrations.filter(registration => {
|
|
229
|
-
if (registration.module !== owner || keptOwnerRegistration) return true;
|
|
230
|
-
keptOwnerRegistration = true;
|
|
231
|
-
return false;
|
|
232
|
-
})) {
|
|
233
|
-
const identity = `${key}\0${duplicate.module.path}\0${duplicate.node.getStart()}`;
|
|
234
|
-
if (reportedRegistrations.has(identity)) continue;
|
|
235
|
-
reportedRegistrations.add(identity);
|
|
236
|
-
violations.push(registrationFinding(duplicate.module, duplicate.node,
|
|
237
|
-
'BE_MODULE_PROVIDER_REREGISTRATION',
|
|
238
|
-
`Class-token provider exported by ${owner.name} is registered again by ${duplicate.module.name} in one application graph; import the owning module and preserve one class-token registration.`,
|
|
239
|
-
{ ownerModule: owner.name, ownerPath: owner.path }));
|
|
240
|
-
}
|
|
295
|
+
for (const duplicate of duplicateRegistrations(graph, key, owners[0])) report(key, owners[0], duplicate);
|
|
241
296
|
}
|
|
297
|
+
return violations;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function handlerRegistrationViolations(modules, handlers, selected) {
|
|
301
|
+
const violations = [];
|
|
242
302
|
for (const handler of handlers.filter(item => item.key && selected.handlerDecorators.includes(item.kind))) {
|
|
243
303
|
const registrations = modules.flatMap(module => (module.providers.get(handler.key) ?? []).map(node => ({ module, node })));
|
|
244
304
|
if (registrations.length !== 1) violations.push({
|
|
@@ -251,8 +311,25 @@ export function checkModuleRegistration(config, context) {
|
|
|
251
311
|
...sourceLocation(registration.node.getSourceFile(), registration.node) })),
|
|
252
312
|
});
|
|
253
313
|
}
|
|
314
|
+
return violations;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Prove only static exported class-token identity and selected CQRS handler registration. */
|
|
318
|
+
export function checkModuleRegistration(config, context) {
|
|
319
|
+
const selected = config.backend.moduleRegistration;
|
|
320
|
+
if (!selected) return { violations: [], coverage: { status: 'unavailable', reason: 'hfs.json does not select Nest module-registration identity checks' } };
|
|
321
|
+
const localFiles = new Set(context.files.map(file => canonical(file.fileName)));
|
|
322
|
+
const { modules, handlers, reasons } = discoverRegistrations(config, context, localFiles);
|
|
323
|
+
for (const module of modules) if (module.unknown) reasons.push(`${module.path} has dynamic or unresolvable @Module providers/exports metadata`);
|
|
324
|
+
for (const handler of handlers) {
|
|
325
|
+
if (!handler.key) reasons.push(`${handler.path} handler class identity is not resolvable`);
|
|
326
|
+
if (!selected.handlerDecorators.includes(handler.kind)) reasons.push(`${handler.path} uses unselected ${handler.kind}`);
|
|
327
|
+
}
|
|
328
|
+
const providerOwners = providerOwnersOf(modules);
|
|
329
|
+
const violations = reregistrationViolations(config, context, modules, providerOwners);
|
|
330
|
+
violations.push(...handlerRegistrationViolations(modules, handlers, selected));
|
|
254
331
|
const coverage = reasons.length
|
|
255
|
-
? { status: 'unavailable', reason: 'one or more Nest module-registration relations are not statically provable', details: [...new Set(reasons)].sort() }
|
|
332
|
+
? { status: 'unavailable', reason: 'one or more Nest module-registration relations are not statically provable', details: [...new Set(reasons)].sort(byCodeUnit) }
|
|
256
333
|
: { status: 'checked', modules: modules.length, exportedClassTokenProviders: providerOwners.size, handlers: handlers.length,
|
|
257
334
|
selectedHandlerDecorators: selected.handlerDecorators };
|
|
258
335
|
return { violations, coverage };
|