@starci/hfs 1.0.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +110 -12
- package/bin/hfs.mjs +119 -15
- package/emit/compiler.mjs +35 -0
- package/emit/contracts.mjs +97 -0
- package/emit/operations-worker.mjs +24 -0
- package/emit/operations.mjs +126 -0
- package/emit/schema-worker.mjs +117 -0
- package/emit/static-graph.mjs +670 -0
- package/emit/type-schema.mjs +145 -0
- package/package.json +4 -1
- package/report/sonar.mjs +180 -0
- package/runtime/engine/admission.mjs +284 -0
- package/runtime/engine/digest.mjs +10 -0
- package/runtime/engine/ledger-db.mjs +1245 -0
- package/runtime/engine/machine-db.mjs +1484 -0
- package/runtime/engine/migrations/machine/0001-init.sql +887 -0
- package/runtime/engine/migrations/runtime/0001-init.sql +1072 -0
- package/runtime/engine/migrations/runtime/0003-usage-unavailable.sql +13 -0
- package/runtime/engine/migrations/runtime/0004-attempt-why.sql +43 -0
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +16 -58
- package/runtime/knowledge/hfs/slots.yaml +405 -137
- package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
- package/runtime/knowledge/sonar-gate.yaml +85 -0
- package/runtime/modules/kernel/failure-codes.yaml +1480 -16
- package/runtime/scripts/checks/architecture/backend.mjs +350 -0
- package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
- package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
- package/runtime/scripts/checks/architecture/clones.mjs +200 -0
- package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
- package/runtime/scripts/checks/architecture/config.mjs +310 -0
- package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
- package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
- package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
- package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
- package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
- package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
- package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
- package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
- package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
- package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
- package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
- package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
- package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
- package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
- package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
- package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
- package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
- package/runtime/scripts/checks/architecture/index.mjs +316 -0
- package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
- package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
- package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
- package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
- package/runtime/scripts/checks/architecture/owners.mjs +89 -0
- package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
- package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
- package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
- package/runtime/scripts/checks/architecture/registration.mjs +319 -0
- package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
- package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
- package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
- package/runtime/scripts/checks/architecture/size-growth.mjs +73 -0
- package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
- package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
- package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
- package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
- package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
- package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
- package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
- package/runtime/scripts/checks/architecture/typescript.mjs +500 -0
- package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
- package/runtime/scripts/checks/architecture.mjs +41 -0
- package/runtime/scripts/checks/common.mjs +37 -0
- package/runtime/scripts/checks/typescript-programs.mjs +82 -0
- package/runtime/scripts/lib/artifact-hold.mjs +89 -0
- package/runtime/scripts/lib/artifact-store.mjs +103 -0
- package/runtime/scripts/lib/fs-kind.mjs +10 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/hfs-allows.mjs +57 -0
- package/runtime/scripts/lib/hfs-check.mjs +254 -28
- package/runtime/scripts/lib/hfs-rules/contract.mjs +126 -0
- package/runtime/scripts/lib/hfs-rules/deps.mjs +63 -0
- package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
- package/runtime/scripts/lib/hfs-rules/frontend.mjs +124 -0
- package/runtime/scripts/lib/hfs-rules/lint-suppression.mjs +34 -0
- package/runtime/scripts/lib/hfs-rules/pipeline.mjs +51 -0
- package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +64 -0
- package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
- package/runtime/scripts/lib/hfs-rules/repo-local-checks.mjs +32 -0
- package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
- package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +31 -0
- package/runtime/scripts/lib/hfs-rules/stacks.mjs +54 -0
- package/runtime/scripts/lib/hfs-rules/test-topology.mjs +31 -0
- package/runtime/scripts/lib/hfs-slots.mjs +88 -40
- package/runtime/scripts/lib/hfs-tree.mjs +80 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/json.mjs +22 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/path-key.mjs +2 -0
- package/runtime/scripts/lib/redact.mjs +148 -0
- package/runtime/scripts/lib/repo-identity.mjs +50 -0
- package/runtime/scripts/lib/safe-remove.mjs +179 -0
- package/runtime/scripts/lib/secret-patterns.mjs +44 -0
- package/runtime/scripts/lib/sleep-sync.mjs +17 -0
- package/runtime/scripts/lib/stack-declaration.mjs +52 -0
- package/runtime/scripts/lib/stack-services.mjs +217 -0
- package/runtime/scripts/lib/test-secrets.mjs +120 -0
- package/scaffold/service.mjs +333 -0
- package/sync/format.mjs +46 -0
- package/sync/hygiene.mjs +56 -24
- package/sync/index.mjs +126 -41
- package/sync/managed.mjs +170 -0
- package/sync/skeleton.mjs +32 -10
- package/sync/sonar-key.mjs +13 -0
- package/sync/ts-strict.mjs +48 -0
- package/templates/{common → be/ci-workflows/github/workflows}/ci.yml +13 -13
- package/templates/be/{e2e.yml → ci-workflows/github/workflows/e2e.yml} +1 -0
- package/templates/be/hooks/husky/pre-commit +13 -0
- package/templates/be/hooks/husky/pre-push +7 -0
- package/templates/be/package-scripts/package.json +21 -0
- package/templates/be/{sonar-project.properties → quality-config/sonar-project.properties} +2 -3
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +14 -4
- package/templates/be/skeleton/apps/__app__/src/main.ts +9 -6
- package/templates/be/skeleton/scripts/.gitkeep +0 -0
- package/templates/be/skeleton/src/features/system-health/application/check-liveness.contracts.ts +2 -0
- package/templates/be/skeleton/src/features/system-health/application/check-liveness.handler.ts +22 -0
- package/templates/be/skeleton/src/features/system-health/application/check-liveness.query.ts +11 -0
- package/templates/be/skeleton/src/features/system-health/index.ts +1 -1
- package/templates/be/skeleton/src/features/system-health/system-health.module.ts +3 -3
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +8 -9
- package/templates/be/skeleton/src/features/system-health/transport/http/system-health-http.module.ts +3 -6
- package/templates/be/skeleton/src/modules/domain/liveness/index.ts +3 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.contracts.ts +9 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.module.ts +14 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.options.ts +2 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.spec.ts +40 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.ts +26 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.module.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.port.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/clock/index.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.spec.ts +20 -0
- package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -0
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/config/index.ts +2 -3
- package/templates/be/skeleton/src/modules/platform/config/server.config.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/config/server.options.ts +0 -3
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.handler.ts +27 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.log-events.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module.ts +18 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/index.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +8 -12
- package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/errors/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/logging/index.ts +4 -4
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +99 -0
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +38 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +23 -10
- package/templates/be/skeleton/src/modules/platform/logging/logging.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +15 -0
- package/templates/be/tool-config/eslint.config.mjs +3 -0
- package/templates/be/tool-config/jest.config.js +1 -0
- package/templates/be/tool-config/prettierignore +8 -0
- package/templates/be/tool-config/prettierrc +1 -0
- package/templates/be/tool-config/src/tests/tsconfig.json +5 -0
- package/templates/be/tool-config/tsconfig.build.json +5 -0
- package/templates/be/tool-config/tsconfig.json +11 -0
- package/templates/common/gitignore.base +1 -1
- package/templates/fe/ci-workflows/github/workflows/ci.yml +54 -0
- package/templates/fe/hooks/husky/pre-commit +16 -0
- package/templates/fe/hooks/husky/pre-push +6 -0
- package/templates/fe/package-scripts/package.json +17 -0
- package/templates/fe/parts/api-client.ts +44 -0
- package/templates/fe/parts/api-outcome.ts +7 -0
- package/templates/fe/quality-config/sonar-project.properties +8 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +1 -1
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +1 -1
- package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +1 -1
- package/templates/fe/skeleton/scripts/.gitkeep +0 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +1 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +3 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +1 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +4 -0
- package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +12 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +2 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +9 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +5 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +5 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/package.json +12 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +1 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +3 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +1 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +5 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +18 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +19 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +2 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +12 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +15 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +5 -0
- package/templates/fe/tool-config/eslint.config.mjs +3 -0
- package/templates/fe/tool-config/prettierignore +10 -0
- package/templates/fe/tool-config/prettierrc +1 -0
- package/templates/fe/tool-config/stylelint.config.mjs +3 -0
- package/templates/fe/tool-config/tsconfig.json +4 -0
- package/templates/be/pre-commit +0 -8
- package/templates/be/skeleton/apps/__app__/src/__app__.composition.spec.ts +0 -31
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.spec.ts +0 -13
- package/templates/be/skeleton/src/modules/platform/config/env-source.spec.ts +0 -36
- package/templates/be/skeleton/src/modules/platform/config/server.config.spec.ts +0 -19
- package/templates/be/skeleton/src/modules/platform/errors/domain-error.spec.ts +0 -15
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.spec.ts +0 -38
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.spec.ts +0 -33
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.ts +0 -35
- package/templates/be/skeleton/src/modules/platform/logging/log-id.ts +0 -9
- package/templates/be/skeleton/src/modules/platform/logging/logger.port.ts +0 -19
- package/templates/common/codecov.yml +0 -13
- package/templates/common/pre-push +0 -5
- package/templates/fe/e2e.yml +0 -22
- package/templates/fe/pre-commit +0 -7
- package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.spec.ts +0 -10
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages.spec.ts +0 -27
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.spec.ts +0 -10
- package/templates/fe/sonar-project.properties +0 -11
- /package/templates/be/skeleton/src/modules/platform/config/{env-source.ts → env-source.config.ts} +0 -0
- /package/templates/be/skeleton/src/modules/platform/errors/{domain-error.ts → domain.error.ts} +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/next.config.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/config.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/navigation.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/request.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/routing.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/proxy.ts +0 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { canonical, isInside } from './config.mjs';
|
|
3
|
+
import { relativePath, sourceLocation } from './typescript.mjs';
|
|
4
|
+
|
|
5
|
+
function absolute(root, relative) {
|
|
6
|
+
return canonical(path.resolve(root, ...relative.split('/')));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function ownerDeclarations(config) {
|
|
10
|
+
return config.owners.map(owner => ({ ...owner, root: absolute(config.root, owner.root), entry: absolute(config.root, owner.entry) }))
|
|
11
|
+
.sort((a, b) => b.root.length - a.root.length);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ownerOf(owners, file) {
|
|
15
|
+
return owners.find(owner => isInside(owner.root, file)) ?? null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function privateOwnerChain(context, owners, edge) {
|
|
19
|
+
const sourceOwner = ownerOf(owners, edge.from);
|
|
20
|
+
const queue = [{ file: edge.to, chain: [edge.from, edge.to] }];
|
|
21
|
+
const visited = new Set();
|
|
22
|
+
while (queue.length) {
|
|
23
|
+
const current = queue.shift();
|
|
24
|
+
if (visited.has(current.file)) continue;
|
|
25
|
+
visited.add(current.file);
|
|
26
|
+
const owner = ownerOf(owners, current.file);
|
|
27
|
+
if (owner && sourceOwner?.id !== owner.id) return current.file === owner.entry ? null : { owner, chain: current.chain };
|
|
28
|
+
for (const candidate of context.edges.get(current.file) ?? []) if (candidate.reexport) {
|
|
29
|
+
queue.push({ file: candidate.to, chain: [...current.chain, candidate.to] });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A `*.builder.ts` of slot be.tests.fixtures.builders arranges data at the schema level: it may deep-import the persistence
|
|
37
|
+
* entity classes and connection entity lists (slot be.persistence) of any capability. No other file may.
|
|
38
|
+
*/
|
|
39
|
+
function arrangesSchema(config, from, to) {
|
|
40
|
+
const resolver = config.hfs;
|
|
41
|
+
if (!resolver?.classifyPath) return false;
|
|
42
|
+
return resolver.classifyPath(relativePath(config.root, from)).slot === 'be.tests.fixtures.builders'
|
|
43
|
+
&& resolver.classifyPath(relativePath(config.root, to)).slot === 'be.persistence';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Enforce explicit same-source owner entries without constraining imports inside one owner. */
|
|
47
|
+
export function checkOwners(config, context) {
|
|
48
|
+
if (!config.owners?.length) return [];
|
|
49
|
+
const owners = ownerDeclarations(config);
|
|
50
|
+
const violations = [];
|
|
51
|
+
const sourceFiles = new Map(context.files.map(file => [canonical(file.fileName), file]));
|
|
52
|
+
for (const owner of owners) {
|
|
53
|
+
const sourceFile = sourceFiles.get(owner.entry);
|
|
54
|
+
if (!sourceFile) {
|
|
55
|
+
violations.push({ ruleId: 'ARCH_OWNER_EXPORT_BYPASS', path: relativePath(config.root, owner.entry), line: 1, column: 1, owner: owner.id,
|
|
56
|
+
message: `Owner ${owner.id} public entry is outside the configured TypeScript programs, so its boundary cannot be checked.` });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
for (const statement of sourceFile.statements) if (context.ts.isExportDeclaration(statement) && !statement.exportClause) {
|
|
60
|
+
violations.push({
|
|
61
|
+
ruleId: 'ARCH_OWNER_EXPORT_STAR',
|
|
62
|
+
path: relativePath(config.root, owner.entry),
|
|
63
|
+
...sourceLocation(sourceFile, statement),
|
|
64
|
+
owner: owner.id,
|
|
65
|
+
message: `Owner ${owner.id} public entry must use explicit named exports rather than export *.`
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const sourceFile of context.files) {
|
|
70
|
+
const from = canonical(sourceFile.fileName);
|
|
71
|
+
for (const edge of context.edges.get(from) ?? []) {
|
|
72
|
+
const bypass = privateOwnerChain(context, owners, edge);
|
|
73
|
+
if (!bypass) continue;
|
|
74
|
+
if (arrangesSchema(config, from, bypass.chain.at(-1))) continue;
|
|
75
|
+
violations.push({
|
|
76
|
+
ruleId: 'ARCH_OWNER_EXPORT_BYPASS',
|
|
77
|
+
path: relativePath(config.root, from),
|
|
78
|
+
line: edge.line,
|
|
79
|
+
column: edge.column,
|
|
80
|
+
owner: bypass.owner.id,
|
|
81
|
+
specifier: edge.specifier,
|
|
82
|
+
resolvedPath: relativePath(config.root, bypass.chain.at(-1)),
|
|
83
|
+
dependencyChain: bypass.chain.map(file => relativePath(config.root, file)),
|
|
84
|
+
message: `Cross-owner dependency must enter ${bypass.owner.id} through ${relativePath(config.root, bypass.owner.entry)}.`
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return violations;
|
|
89
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { treeOf } from './required-files.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* R63 `package-shape` (FE_PACKAGE_SHAPE, knowledge/hfs/README.md 6.2). A shared package is built to `dist` and says what it
|
|
7
|
+
* exports. The package manifest of every package owner (slots repo.packages and fe.package.ui, packages/<pkg>/package.json)
|
|
8
|
+
* must have:
|
|
9
|
+
*
|
|
10
|
+
* - a `scripts.build`;
|
|
11
|
+
* - an explicit `exports` map: present, no `*` pattern in a subpath, no folder mapping;
|
|
12
|
+
* - every export target, and `main`, `module`, `types` and `typings` when present, inside `./dist/`: a target into `src/`
|
|
13
|
+
* ships source that was never built.
|
|
14
|
+
*
|
|
15
|
+
* `export *` in the package entry is ARCH_OWNER_EXPORT_STAR, a dead unit is HFS_UNUSED_EXPORT and the package tiers are the
|
|
16
|
+
* eslint rule `monorepo-tier-belongs-to-its-side`; none is judged a second time here.
|
|
17
|
+
*/
|
|
18
|
+
export const PACKAGE_SHAPE_RULE_IDS = ['FE_PACKAGE_SHAPE'];
|
|
19
|
+
|
|
20
|
+
const RULE = 'FE_PACKAGE_SHAPE';
|
|
21
|
+
|
|
22
|
+
/** Every string target of an exports value (a string, a condition map, an array of either), with the condition path. */
|
|
23
|
+
function targetsOf(value, trail = []) {
|
|
24
|
+
if (typeof value === 'string') return [{ target: value, trail }];
|
|
25
|
+
if (Array.isArray(value)) return value.flatMap((item, index) => targetsOf(item, [...trail, String(index)]));
|
|
26
|
+
if (value && typeof value === 'object') return Object.entries(value).flatMap(([key, item]) => targetsOf(item, [...trail, key]));
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const insideDist = target => path.posix.normalize(target).replace(/^\.\//u, '').startsWith('dist/');
|
|
31
|
+
|
|
32
|
+
export function checkPackageShape({ config, graph }) {
|
|
33
|
+
const resolver = graph.resolver;
|
|
34
|
+
const violations = [];
|
|
35
|
+
let packages = 0;
|
|
36
|
+
const roots = new Set();
|
|
37
|
+
for (const rel of treeOf(config.root).files) {
|
|
38
|
+
const owner = resolver.ownerOf(rel);
|
|
39
|
+
if (owner && resolver.slot(owner.slot)?.tier === 'package') roots.add(owner.root);
|
|
40
|
+
}
|
|
41
|
+
for (const root of [...roots].sort()) {
|
|
42
|
+
const manifestRel = `${root}/package.json`;
|
|
43
|
+
const report = (message, extra = {}) => violations.push({ ruleId: RULE, path: manifestRel, line: 1, column: 1, package: root, message, ...extra });
|
|
44
|
+
let manifest;
|
|
45
|
+
try { manifest = JSON.parse(fs.readFileSync(path.join(config.root, ...manifestRel.split('/')), 'utf8')); } catch { continue; }
|
|
46
|
+
packages += 1;
|
|
47
|
+
if (typeof manifest.scripts?.build !== 'string' || !manifest.scripts.build.trim()) report(`${manifestRel} has no scripts.build; a shared package is built to dist before an app consumes it.`);
|
|
48
|
+
if (manifest.exports === undefined) {
|
|
49
|
+
report(`${manifestRel} declares no exports map; a package lists what it exports, subpath by subpath, each pointing into ./dist/.`);
|
|
50
|
+
} else {
|
|
51
|
+
if (typeof manifest.exports === 'object' && !Array.isArray(manifest.exports)) {
|
|
52
|
+
for (const key of Object.keys(manifest.exports)) if (key.includes('*') || key.endsWith('/')) report(`exports key "${key}" of ${manifestRel} is a pattern or folder mapping; list each exported subpath explicitly.`, { export: key });
|
|
53
|
+
}
|
|
54
|
+
for (const { target, trail } of targetsOf(manifest.exports)) {
|
|
55
|
+
if (!insideDist(target)) report(`exports target "${target}" (${trail.join(' > ') || '.'}) of ${manifestRel} is not inside ./dist/; a package exports what it built, never its source.`, { export: target });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const field of ['main', 'module', 'types', 'typings']) {
|
|
59
|
+
if (typeof manifest[field] === 'string' && !insideDist(manifest[field])) report(`${field} "${manifest[field]}" of ${manifestRel} is not inside ./dist/; a package points at what it built.`, { field });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { violations, coverage: { status: 'checked', packages } };
|
|
63
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HFS check 3, reachability (knowledge/hfs/slots.yaml rules BE_FEATURE_NOT_COMPOSED, FE_OWNER_REACHABLE):
|
|
3
|
+
* BE every feature owner (be.feature) and every capability owner (be.domain, be.integrations, be.platform) must be
|
|
4
|
+
* composed into an app root: reachable from an app entry file (apps/<app>/src, the be.app.* owners) through runtime
|
|
5
|
+
* imports and re-exports. A type-only import composes nothing. A module reached only through a reached module
|
|
6
|
+
* is reached. An owner no root reaches is dead code: no process ever serves it.
|
|
7
|
+
* FE every page/layout/overlay feature (fe.feature) must be mounted: reachable by runtime imports from a file of
|
|
8
|
+
* its app's route tree (apps/<app>/src/app, slot fe.route); and every string-literal href/redirect/router target
|
|
9
|
+
* must resolve to a route of that app (page.tsx / route.ts under src/app, route groups and [locale] transparent).
|
|
10
|
+
* Only literals are judged: a computed href is counted as skipped, never guessed at.
|
|
11
|
+
*/
|
|
12
|
+
export const REACHABILITY_RULE_IDS = ['BE_FEATURE_NOT_COMPOSED', 'FE_OWNER_REACHABLE', 'FE_HREF_RESOLVES'];
|
|
13
|
+
|
|
14
|
+
const BE_CAPABILITY_SLOTS = new Set(['be.domain', 'be.integrations', 'be.platform']);
|
|
15
|
+
const TEST_FILE = /(?:\.(?:spec|test|stories|e2e-spec)\.[cm]?[jt]sx?$)|(?:(?:^|\/)__tests__\/)/;
|
|
16
|
+
const ROUTE_FILE = /^(?:page|route)\.(?:tsx|ts|jsx|js)$/;
|
|
17
|
+
const PLACEHOLDER = '\u0000';
|
|
18
|
+
|
|
19
|
+
/** Breadth-first reach over runtime, non-type-only edges (re-exports included) from `roots`. */
|
|
20
|
+
function reachFrom(graph, roots) {
|
|
21
|
+
const forward = new Map();
|
|
22
|
+
for (const edge of graph.edges) {
|
|
23
|
+
if (!edge.runtime) continue;
|
|
24
|
+
if (!forward.has(edge.from)) forward.set(edge.from, []);
|
|
25
|
+
forward.get(edge.from).push(edge.to);
|
|
26
|
+
}
|
|
27
|
+
const reached = new Set(roots);
|
|
28
|
+
const queue = [...roots];
|
|
29
|
+
while (queue.length) {
|
|
30
|
+
for (const next of forward.get(queue.shift()) ?? []) {
|
|
31
|
+
if (reached.has(next)) continue;
|
|
32
|
+
reached.add(next);
|
|
33
|
+
queue.push(next);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return reached;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ownerFiles(graph) {
|
|
40
|
+
const byUnit = new Map();
|
|
41
|
+
for (const [rel, node] of graph.files) {
|
|
42
|
+
if (!node.owner) continue;
|
|
43
|
+
const key = `${node.owner.slot}:${node.owner.root}`;
|
|
44
|
+
if (!byUnit.has(key)) byUnit.set(key, []);
|
|
45
|
+
byUnit.get(key).push(rel);
|
|
46
|
+
}
|
|
47
|
+
return byUnit;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The file that stands for an owner in a finding: its public entry when the graph holds one, else its first file. */
|
|
51
|
+
function entryOf(root, files) {
|
|
52
|
+
const base = root.replace(/\/$/, '');
|
|
53
|
+
return files.find(rel => new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/index\\.[tj]sx?$`).test(rel)) ?? [...files].sort()[0];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function checkBackend(graph) {
|
|
57
|
+
const byUnit = ownerFiles(graph);
|
|
58
|
+
const rootFiles = [];
|
|
59
|
+
const appRoots = new Set();
|
|
60
|
+
for (const [key, files] of byUnit) {
|
|
61
|
+
const slot = key.slice(0, key.indexOf(':'));
|
|
62
|
+
if (!slot.startsWith('be.app.')) continue;
|
|
63
|
+
appRoots.add(key.slice(key.indexOf(':') + 1));
|
|
64
|
+
for (const rel of files) if (!TEST_FILE.test(rel)) rootFiles.push(rel);
|
|
65
|
+
}
|
|
66
|
+
const roots = [...appRoots].sort();
|
|
67
|
+
const counts = { features: 0, modules: 0, appRoots: roots.length, notComposed: 0 };
|
|
68
|
+
if (!roots.length) return { violations: [], coverage: { status: 'unavailable', reason: 'no be.app.* owner instance holds a source file, so there is no app root to compose into', ...counts } };
|
|
69
|
+
const reached = reachFrom(graph, rootFiles);
|
|
70
|
+
const violations = [];
|
|
71
|
+
for (const [key, files] of [...byUnit].sort(([a], [b]) => a.localeCompare(b))) {
|
|
72
|
+
const slot = key.slice(0, key.indexOf(':'));
|
|
73
|
+
const root = key.slice(key.indexOf(':') + 1);
|
|
74
|
+
const feature = slot === 'be.feature';
|
|
75
|
+
if (!feature && !BE_CAPABILITY_SLOTS.has(slot)) continue;
|
|
76
|
+
counts[feature ? 'features' : 'modules'] += 1;
|
|
77
|
+
if (files.some(rel => reached.has(rel))) continue;
|
|
78
|
+
counts.notComposed += 1;
|
|
79
|
+
const label = root.replace(/\/$/, '');
|
|
80
|
+
violations.push({
|
|
81
|
+
ruleId: 'BE_FEATURE_NOT_COMPOSED',
|
|
82
|
+
path: entryOf(root, files), owner: label, slot, appRoots: roots,
|
|
83
|
+
message: `${feature ? 'Feature' : 'Module'} ${label} is not composed into any app: no runtime import path leads from ${roots.join(', ')} to it (a type-only import composes nothing), so it is dead code that no process serves.`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return { violations, coverage: { status: 'checked', ...counts } };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Route table of one app: arrays of segments, each {kind:'static'|'dyn'|'catch'|'optcatch', text}. */
|
|
90
|
+
function routeTable(graph, app) {
|
|
91
|
+
const prefix = `apps/${app}/src/app/`;
|
|
92
|
+
const routes = [];
|
|
93
|
+
for (const rel of graph.files.keys()) {
|
|
94
|
+
if (!rel.startsWith(prefix)) continue;
|
|
95
|
+
const parts = rel.slice(prefix.length).split('/');
|
|
96
|
+
if (!ROUTE_FILE.test(parts.at(-1))) continue;
|
|
97
|
+
const segments = [];
|
|
98
|
+
let usable = true;
|
|
99
|
+
for (const part of parts.slice(0, -1)) {
|
|
100
|
+
if (/^\([^.)][^)]*\)$/.test(part)) continue; // route group
|
|
101
|
+
if (part.startsWith('@')) continue; // parallel slot: no URL segment
|
|
102
|
+
if (part.startsWith('_') || /^\(\.+\)/.test(part)) { usable = false; break; } // private folder, intercepting route
|
|
103
|
+
segments.push(part);
|
|
104
|
+
}
|
|
105
|
+
if (!usable) continue;
|
|
106
|
+
if (segments[0] === '[locale]') segments.shift();
|
|
107
|
+
routes.push(segments.map(part => {
|
|
108
|
+
if (/^\[\[\.\.\..+\]\]$/.test(part)) return { kind: 'optcatch', text: part };
|
|
109
|
+
if (/^\[\.\.\..+\]$/.test(part)) return { kind: 'catch', text: part };
|
|
110
|
+
if (/^\[.+\]$/.test(part)) return { kind: 'dyn', text: part };
|
|
111
|
+
return { kind: 'static', text: part };
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
return routes;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** One href segment (may contain PLACEHOLDER for a template expression) against one route segment. */
|
|
118
|
+
function segmentMatches(hrefSegment, routeSegment) {
|
|
119
|
+
if (!hrefSegment.includes(PLACEHOLDER)) return routeSegment.kind === 'dyn' || routeSegment.text === hrefSegment;
|
|
120
|
+
if (routeSegment.kind === 'dyn' || routeSegment.kind === 'catch' || routeSegment.kind === 'optcatch') return true;
|
|
121
|
+
const parts = hrefSegment.split(PLACEHOLDER).map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
122
|
+
return new RegExp(`^${parts.join('.*')}$`).test(routeSegment.text);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function matchRoute(hrefSegments, route, at = 0, from = 0) {
|
|
126
|
+
if (from === route.length) return at === hrefSegments.length;
|
|
127
|
+
const routeSegment = route[from];
|
|
128
|
+
if (routeSegment.kind === 'optcatch' && matchRoute(hrefSegments, route, at, from + 1)) return true;
|
|
129
|
+
if (routeSegment.kind === 'catch' || routeSegment.kind === 'optcatch') {
|
|
130
|
+
for (let take = 1; at + take <= hrefSegments.length; take += 1) if (matchRoute(hrefSegments, route, at + take, from + 1)) return true;
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
if (at >= hrefSegments.length || !segmentMatches(hrefSegments[at], routeSegment)) return false;
|
|
134
|
+
return matchRoute(hrefSegments, route, at + 1, from + 1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function hrefResolves(href, routes) {
|
|
138
|
+
const cut = href.search(/[?#]/);
|
|
139
|
+
const pathOnly = cut === -1 ? href : href.slice(0, cut);
|
|
140
|
+
const segments = pathOnly.split('/').slice(1).filter(segment => segment !== '');
|
|
141
|
+
const candidates = [segments];
|
|
142
|
+
// A leading locale (a literal `en`/`vi-VN` or a bare template expression) is what [locale] carries: try without it too.
|
|
143
|
+
if (segments.length && (/^[a-z]{2}(?:-[a-zA-Z]{2})?$/.test(segments[0]) || segments[0] === PLACEHOLDER)) candidates.push(segments.slice(1));
|
|
144
|
+
return candidates.some(candidate => routes.some(route => matchRoute(candidate, route)));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The text of a string-ish expression with PLACEHOLDER for each ${...}; null when it is not a literal we can read. */
|
|
148
|
+
function literalText(ts, node) {
|
|
149
|
+
if (!node) return null;
|
|
150
|
+
while (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || (ts.isNonNullExpression && ts.isNonNullExpression(node))) node = node.expression;
|
|
151
|
+
if (ts.isStringLiteralLike(node)) return node.text; // includes NoSubstitutionTemplateLiteral
|
|
152
|
+
if (ts.isTemplateExpression(node)) return node.head.text + node.templateSpans.map(span => PLACEHOLDER + span.literal.text).join('');
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const NAVIGATION_CALLS = new Set(['redirect', 'permanentRedirect']);
|
|
157
|
+
const ROUTER_METHODS = new Set(['push', 'replace', 'prefetch']);
|
|
158
|
+
|
|
159
|
+
/** Every href-like target in a source file: {node, text|null (null = computed)}. */
|
|
160
|
+
function hrefTargets(ts, sourceFile) {
|
|
161
|
+
const found = [];
|
|
162
|
+
const visit = node => {
|
|
163
|
+
if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && node.name.text === 'href' && node.initializer) {
|
|
164
|
+
const value = ts.isJsxExpression(node.initializer) ? node.initializer.expression : node.initializer;
|
|
165
|
+
found.push({ node: value ?? node, text: value ? literalText(ts, value) : null });
|
|
166
|
+
} else if (ts.isCallExpression(node) && node.arguments.length) {
|
|
167
|
+
const callee = node.expression;
|
|
168
|
+
const navigates = (ts.isIdentifier(callee) && NAVIGATION_CALLS.has(callee.text))
|
|
169
|
+
|| (ts.isPropertyAccessExpression(callee) && ROUTER_METHODS.has(callee.name.text)
|
|
170
|
+
&& /router$/i.test(ts.isIdentifier(callee.expression) ? callee.expression.text : ts.isPropertyAccessExpression(callee.expression) ? callee.expression.name.text : ''));
|
|
171
|
+
if (navigates) found.push({ node: node.arguments[0], text: literalText(ts, node.arguments[0]) });
|
|
172
|
+
}
|
|
173
|
+
ts.forEachChild(node, visit);
|
|
174
|
+
};
|
|
175
|
+
visit(sourceFile);
|
|
176
|
+
return found;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function checkFrontend(config, context, graph) {
|
|
180
|
+
const ts = context.ts ?? context.loaded.ts;
|
|
181
|
+
const byUnit = ownerFiles(graph);
|
|
182
|
+
const apps = new Map();
|
|
183
|
+
for (const [rel, node] of graph.files) {
|
|
184
|
+
if (node.slot !== 'fe.route') continue;
|
|
185
|
+
const app = /^apps\/([^/]+)\/src\/app\//.exec(rel)?.[1];
|
|
186
|
+
if (!app) continue;
|
|
187
|
+
if (!apps.has(app)) apps.set(app, []);
|
|
188
|
+
apps.get(app).push(rel);
|
|
189
|
+
}
|
|
190
|
+
const counts = { pages: 0, mounted: 0, hrefs: 0, hrefsResolved: 0, hrefsSkipped: 0, routes: 0 };
|
|
191
|
+
const violations = [];
|
|
192
|
+
const reachedByApp = new Map([...apps].map(([app, roots]) => [app, reachFrom(graph, roots)]));
|
|
193
|
+
for (const [key, files] of [...byUnit].sort(([a], [b]) => a.localeCompare(b))) {
|
|
194
|
+
if (!key.startsWith('fe.feature:')) continue;
|
|
195
|
+
const root = key.slice('fe.feature:'.length);
|
|
196
|
+
const app = /^apps\/([^/]+)\//.exec(root)?.[1];
|
|
197
|
+
counts.pages += 1;
|
|
198
|
+
const reached = reachedByApp.get(app);
|
|
199
|
+
if (reached && files.some(rel => reached.has(rel))) { counts.mounted += 1; continue; }
|
|
200
|
+
const label = root.replace(/\/$/, '');
|
|
201
|
+
violations.push({
|
|
202
|
+
ruleId: 'FE_OWNER_REACHABLE', path: entryOf(root, files), owner: label, app,
|
|
203
|
+
message: `${label} is not mounted: no runtime import path leads from a route file under apps/${app}/src/app to it (a type-only import mounts nothing), so the page is never shown.`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
for (const app of [...new Set([...apps.keys(), ...[...graph.files.keys()].map(rel => /^apps\/([^/]+)\/src\//.exec(rel)?.[1]).filter(Boolean)])].sort()) {
|
|
207
|
+
const routes = routeTable(graph, app);
|
|
208
|
+
counts.routes += routes.length;
|
|
209
|
+
const prefix = `apps/${app}/src/`;
|
|
210
|
+
for (const [rel, node] of graph.files) {
|
|
211
|
+
if (!rel.startsWith(prefix) || TEST_FILE.test(rel)) continue;
|
|
212
|
+
for (const target of hrefTargets(ts, node.sourceFile)) {
|
|
213
|
+
counts.hrefs += 1;
|
|
214
|
+
const text = target.text;
|
|
215
|
+
if (text === null || !text.startsWith('/') || text.startsWith('//') || !routes.length) { counts.hrefsSkipped += 1; continue; }
|
|
216
|
+
const hrefPath = text.split(/[?#]/)[0];
|
|
217
|
+
if (/\.[A-Za-z0-9]{1,6}$/.test(hrefPath.split('/').at(-1))) { counts.hrefsSkipped += 1; continue; } // static asset
|
|
218
|
+
if (hrefResolves(text, routes)) { counts.hrefsResolved += 1; continue; }
|
|
219
|
+
const point = node.sourceFile.getLineAndCharacterOfPosition(target.node.getStart(node.sourceFile));
|
|
220
|
+
const shown = text.split(PLACEHOLDER).join('${…}');
|
|
221
|
+
violations.push({
|
|
222
|
+
ruleId: 'FE_HREF_RESOLVES', path: rel, line: point.line + 1, column: point.character + 1, app, href: shown,
|
|
223
|
+
message: `href "${shown}" matches no route of app ${app}: no page.tsx or route.ts under ${prefix}app serves that path.`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { violations, coverage: { status: 'checked', ...counts } };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function checkReachability({ config, context, graph } = {}) {
|
|
232
|
+
return graph.profile === 'be' ? checkBackend(graph) : checkFrontend(config, context, graph);
|
|
233
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { machineKit } from './machine-ast.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* R45 `register-once` (BE_MODULE_SHAPE), the module graph read from the app roots (owner rule of 2026-09-30, the
|
|
6
|
+
* reference's `CodingModule imports DeviceModule` shape). Each capability has one representative module: the only module
|
|
7
|
+
* of the capability an app registers, once, in `apps/<app>/src/app.module.ts` as `X.register({ isGlobal: true, ... })`.
|
|
8
|
+
* It may split itself into sub-modules imported as plain `imports: [Sub]`; a sub-module is never in an app and has one
|
|
9
|
+
* importer, its parent. Over every `@Module` class of the program:
|
|
10
|
+
*
|
|
11
|
+
* - a module in an app root's imports (a `X.register(...)` call or a listed class, helpers of the root included) is
|
|
12
|
+
* listed once there, is imported by no other module, and, when it is a capability module of src/modules, is registered
|
|
13
|
+
* with the literal `isGlobal: true` (a bare capability class is not a registration);
|
|
14
|
+
* - a module imported by a non-app module has exactly one importer and appears in no app root; the one exception is
|
|
15
|
+
* the feature case: a feature's application module is imported by each protocol module of the same feature;
|
|
16
|
+
* - `isGlobal: true` appears only in an app root.
|
|
17
|
+
*/
|
|
18
|
+
export const REGISTER_ONCE_RULE_IDS = ['BE_MODULE_SHAPE'];
|
|
19
|
+
|
|
20
|
+
const RULE = 'BE_MODULE_SHAPE';
|
|
21
|
+
const CAPABILITY_TIERS = new Set(['domain', 'platform', 'integrations']);
|
|
22
|
+
|
|
23
|
+
export function checkRegisterOnce(input) {
|
|
24
|
+
const { config, graph } = input;
|
|
25
|
+
const kit = machineKit(input);
|
|
26
|
+
const { ts } = kit;
|
|
27
|
+
const violations = [];
|
|
28
|
+
const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
|
|
29
|
+
const isAppRoot = file => Boolean(file.slot?.startsWith('be.app.')) && path.posix.basename(file.rel) === 'app.module.ts';
|
|
30
|
+
// The test world (slot be.tests.world) is the test composition root: useTestWorld registers capability modules with
|
|
31
|
+
// `isGlobal: true` the way an app root does. A spec (integration, contract, e2e) is not a composition root.
|
|
32
|
+
const isWorld = file => file.slot === 'be.tests.world';
|
|
33
|
+
const appOf = file => config.apps.find(app => file.rel === `apps/${app.name}/src/app.module.ts`)?.name ?? null;
|
|
34
|
+
|
|
35
|
+
// Every @Module class of the program.
|
|
36
|
+
const modules = new Map(); // declaration -> {name, file, capability}
|
|
37
|
+
for (const file of graph.files.values()) {
|
|
38
|
+
const checker = kit.checkerOf(file.sourceFile);
|
|
39
|
+
for (const statement of file.sourceFile.statements) {
|
|
40
|
+
if (!ts.isClassDeclaration(statement) || !statement.name) continue;
|
|
41
|
+
const isModule = kit.decorators(statement).some(decorator => {
|
|
42
|
+
const call = ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression;
|
|
43
|
+
const binding = kit.importBinding(checker, call);
|
|
44
|
+
return binding?.module === '@nestjs/common' && binding.name === 'Module';
|
|
45
|
+
});
|
|
46
|
+
if (isModule) modules.set(statement, { name: statement.name.text, file, capability: CAPABILITY_TIERS.has(file.tier) });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const targetOf = (checker, node) => {
|
|
50
|
+
for (const declaration of kit.declarationsOf(checker, node)) if (modules.has(declaration)) return { declaration, ...modules.get(declaration) };
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
const enclosingModule = node => {
|
|
54
|
+
for (let current = node.parent; current; current = current.parent) if (ts.isClassDeclaration(current) && modules.has(current)) return current;
|
|
55
|
+
return null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// The references to modules: importer (the enclosing module class, else the file), target, node, whether it is a register call.
|
|
59
|
+
const references = [];
|
|
60
|
+
for (const file of graph.files.values()) {
|
|
61
|
+
if (isWorld(file)) continue; // the test composition root assembles modules; it is not a module importer
|
|
62
|
+
const checker = kit.checkerOf(file.sourceFile);
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
const note = (element, viaImports) => {
|
|
65
|
+
const call = ts.isCallExpression(element) && ts.isPropertyAccessExpression(element.expression) && element.expression.name.text === 'register';
|
|
66
|
+
if (ts.isCallExpression(element) && !call) return;
|
|
67
|
+
const target = targetOf(checker, call ? element.expression.expression : element);
|
|
68
|
+
if (!target) return;
|
|
69
|
+
seen.add(element);
|
|
70
|
+
const owner = enclosingModule(element);
|
|
71
|
+
references.push({ file, node: element, target, register: call, viaImports, importer: owner ?? file.rel, importerOwner: file.owner?.root ?? null,
|
|
72
|
+
options: call ? element.arguments[0] : null, root: isAppRoot(file) });
|
|
73
|
+
};
|
|
74
|
+
kit.walk(file.sourceFile, node => {
|
|
75
|
+
if (ts.isPropertyAssignment(node) && kit.propertyNameText(node.name) === 'imports' && ts.isArrayLiteralExpression(node.initializer)) {
|
|
76
|
+
for (const element of node.initializer.elements) note(element, true);
|
|
77
|
+
} else if (ts.isCallExpression(node) && !seen.has(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register') {
|
|
78
|
+
note(node, false);
|
|
79
|
+
}
|
|
80
|
+
if (ts.isPropertyAssignment(node) && kit.propertyNameText(node.name) === 'isGlobal' && node.initializer.kind === ts.SyntaxKind.TrueKeyword && !isAppRoot(file) && !isWorld(file)) {
|
|
81
|
+
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.');
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const trueGlobal = options => Boolean(options) && ts.isObjectLiteralExpression(options) && (() => {
|
|
88
|
+
const property = kit.propertyOf(options, 'isGlobal');
|
|
89
|
+
return Boolean(property) && ts.isPropertyAssignment(property) && property.initializer.kind === ts.SyntaxKind.TrueKeyword;
|
|
90
|
+
})();
|
|
91
|
+
|
|
92
|
+
// App roots: each module is listed once, capability modules are registered with isGlobal true.
|
|
93
|
+
const rootReferences = references.filter(item => item.root);
|
|
94
|
+
const inApps = new Map(); // module declaration -> Set(app names)
|
|
95
|
+
for (const item of rootReferences) {
|
|
96
|
+
const app = appOf(item.file) ?? item.file.rel;
|
|
97
|
+
if (!inApps.has(item.target.declaration)) inApps.set(item.target.declaration, new Set());
|
|
98
|
+
inApps.get(item.target.declaration).add(app);
|
|
99
|
+
}
|
|
100
|
+
for (const app of config.apps) {
|
|
101
|
+
const seenInApp = new Map();
|
|
102
|
+
for (const item of rootReferences.filter(entry => appOf(entry.file) === app.name)) {
|
|
103
|
+
if (seenInApp.has(item.target.declaration)) {
|
|
104
|
+
report(item.file, item.node, `${item.target.name} is registered more than once in the root of app ${app.name}; a module is listed once per app.`, { module: item.target.name, app: app.name });
|
|
105
|
+
} else seenInApp.set(item.target.declaration, item.node);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const item of rootReferences) {
|
|
109
|
+
if (!item.target.capability || !item.viaImports && !item.register) continue;
|
|
110
|
+
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 });
|
|
111
|
+
else if (!trueGlobal(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 });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Non-app importers: no importer of an app-registered module, exactly one importer otherwise (the feature case excepted).
|
|
115
|
+
const importersOf = new Map(); // module declaration -> [reference]
|
|
116
|
+
for (const item of references.filter(entry => !entry.root && entry.viaImports)) {
|
|
117
|
+
if (!importersOf.has(item.target.declaration)) importersOf.set(item.target.declaration, []);
|
|
118
|
+
importersOf.get(item.target.declaration).push(item);
|
|
119
|
+
}
|
|
120
|
+
for (const [declaration, list] of importersOf) {
|
|
121
|
+
const target = modules.get(declaration);
|
|
122
|
+
const apps = inApps.get(declaration);
|
|
123
|
+
if (apps) {
|
|
124
|
+
for (const item of list) {
|
|
125
|
+
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 });
|
|
126
|
+
}
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const distinct = [...new Set(list.map(item => item.importer))];
|
|
130
|
+
const featureCase = target.file.tier === 'feature' && target.file.owner && list.every(item => item.importerOwner === target.file.owner.root);
|
|
131
|
+
if (distinct.length > 1 && !featureCase) {
|
|
132
|
+
for (const item of list.filter(entry => entry.importer !== list[0].importer)) {
|
|
133
|
+
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 });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { violations, coverage: { status: 'checked', apps: config.apps.filter(app => kit.appRoot(app.name)).length, modules: modules.size,
|
|
139
|
+
capabilityModules: [...modules.values()].filter(item => item.capability).length, registrations: references.filter(item => item.register).length,
|
|
140
|
+
rootImports: rootReferences.length } };
|
|
141
|
+
}
|