@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,88 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { machineKit } from './machine-ast.mjs';
|
|
3
|
+
import { treeOf } from './required-files.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* R56 `hooks-are-hooks` (FE_HOOKS_ARE_HOOKS), the repository half of the eslint rule `hooks-folder-holds-hooks-only`, which
|
|
7
|
+
* judges one file at a time and leaves to the machine what needs the whole tree. Per domain folder of hooks
|
|
8
|
+
* (slot fe.hooks, apps/<app>/src/hooks/<domain>):
|
|
9
|
+
*
|
|
10
|
+
* - at most one shared file, named `<domain>.shared.ts`: a second `*.shared.ts`, or one named for another domain, is a
|
|
11
|
+
* second dumping ground for non-hook helpers;
|
|
12
|
+
* - no helper declared twice: a top-level function or arrow-function constant that is not a hook and whose name is declared
|
|
13
|
+
* in two files of the domain is a helper copied instead of shared; it lives once in `<domain>.shared.ts`.
|
|
14
|
+
*/
|
|
15
|
+
export const HOOKS_ARE_HOOKS_RULE_IDS = ['FE_HOOKS_ARE_HOOKS'];
|
|
16
|
+
|
|
17
|
+
const RULE = 'FE_HOOKS_ARE_HOOKS';
|
|
18
|
+
const HOOKS_SLOT = 'fe.hooks';
|
|
19
|
+
const HOOK_NAME = /^use[A-Z0-9]/u;
|
|
20
|
+
const SHARED = /\.shared\.[cm]?tsx?$/u;
|
|
21
|
+
|
|
22
|
+
export function checkHooksAreHooks(input) {
|
|
23
|
+
const { config, graph } = input;
|
|
24
|
+
const kit = machineKit(input);
|
|
25
|
+
const { ts, resolver } = kit;
|
|
26
|
+
const violations = [];
|
|
27
|
+
const domains = new Set();
|
|
28
|
+
|
|
29
|
+
const tree = treeOf(config.root);
|
|
30
|
+
const shared = new Map(); // domain root -> [file]
|
|
31
|
+
for (const rel of [...tree.files].sort()) {
|
|
32
|
+
const classified = resolver.classifyPath(rel);
|
|
33
|
+
if (classified.slot !== HOOKS_SLOT || !classified.root) continue;
|
|
34
|
+
domains.add(classified.root);
|
|
35
|
+
const base = path.posix.basename(rel);
|
|
36
|
+
if (/\.spec\.[cm]?tsx?$/u.test(base) || !SHARED.test(base)) continue;
|
|
37
|
+
if (!shared.has(classified.root)) shared.set(classified.root, []);
|
|
38
|
+
shared.get(classified.root).push({ rel, base });
|
|
39
|
+
}
|
|
40
|
+
for (const [root, list] of shared) {
|
|
41
|
+
const domain = path.posix.basename(root);
|
|
42
|
+
const at = (item, message) => violations.push({ ruleId: RULE, path: item.rel, line: 1, column: 1, domain, message });
|
|
43
|
+
for (const item of list) {
|
|
44
|
+
if (item.base.replace(/\.[cm]?tsx?$/u, '') !== `${domain}.shared` || path.posix.dirname(item.rel) !== root) {
|
|
45
|
+
at(item, `${item.rel} is a shared file that is not ${root}/${domain}.shared.ts; a hooks domain has one shared file for its non-hook helpers, named after the domain. Move the helpers into it.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const item of list.filter(entry => entry.base.replace(/\.[cm]?tsx?$/u, '') === `${domain}.shared` && path.posix.dirname(entry.rel) === root).slice(1)) {
|
|
49
|
+
at(item, `${item.rel} is a second shared file of hooks domain ${domain}; a domain has exactly one, ${domain}.shared.ts. Merge the helpers into it.`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Helpers declared in more than one file of one domain.
|
|
54
|
+
const declared = new Map(); // domain root -> name -> [{file, node}]
|
|
55
|
+
for (const file of graph.files.values()) {
|
|
56
|
+
if (file.slot !== HOOKS_SLOT) continue;
|
|
57
|
+
const root = resolver.classifyPath(file.rel).root;
|
|
58
|
+
for (const statement of file.sourceFile.statements) {
|
|
59
|
+
const found = [];
|
|
60
|
+
if (ts.isFunctionDeclaration(statement) && statement.name) found.push([statement.name.text, statement.name]);
|
|
61
|
+
if (ts.isVariableStatement(statement)) {
|
|
62
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
63
|
+
const init = declaration.initializer;
|
|
64
|
+
if (ts.isIdentifier(declaration.name) && init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) found.push([declaration.name.text, declaration.name]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const [name, node] of found) {
|
|
68
|
+
if (HOOK_NAME.test(name)) continue;
|
|
69
|
+
if (!declared.has(root)) declared.set(root, new Map());
|
|
70
|
+
const byName = declared.get(root);
|
|
71
|
+
if (!byName.has(name)) byName.set(name, []);
|
|
72
|
+
byName.get(name).push({ file, node });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const [root, byName] of declared) {
|
|
77
|
+
const domain = path.posix.basename(root);
|
|
78
|
+
for (const [name, list] of byName) {
|
|
79
|
+
if (list.length < 2) continue;
|
|
80
|
+
for (const item of list.slice(1)) {
|
|
81
|
+
const others = list.map(entry => entry.file.rel).filter(rel => rel !== item.file.rel).sort();
|
|
82
|
+
violations.push({ ruleId: RULE, path: item.file.rel, ...kit.at(item.file.rel, item.file.sourceFile, item.node), domain, helper: name,
|
|
83
|
+
message: `${name} is declared again in hooks domain ${domain} (also in ${others.join(', ')}); a helper the hooks share is written once, in ${domain}.shared.ts.` });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { violations, coverage: { status: 'checked', domains: domains.size } };
|
|
88
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* R106 `i18n-keys` (FE_I18N_KEYS). The catalogs of an app (`apps/<app>/src/modules/i18n/messages/<locale>.json`, slot fe.modules.i18n) and
|
|
6
|
+
* the source that reads them agree, both ways. The key sets of the locales agree with each other (FE_I18N_CATALOG, `hfs check`); this
|
|
7
|
+
* rule holds the source to the catalogs:
|
|
8
|
+
*
|
|
9
|
+
* - a key read with a literal is in every locale: `useTranslations("ns")` / `getTranslations("ns")` (also `{ namespace }`, `await`)
|
|
10
|
+
* imported from `next-intl` or `next-intl/server`, then `t("key")`, `t.rich("key")`, `t.raw("key")`, `t.markup("key")` inside the app's
|
|
11
|
+
* own source (both branches of a conditional key are read). A key no locale's catalog holds is FE_I18N_KEYS, once per call. A computed
|
|
12
|
+
* key (a template with an interpolation, a variable) is not judged here; it counts as a read.
|
|
13
|
+
* - a key nobody reads is dead: a leaf of the app's catalogs that no string in the app's or the shared packages' source can be reading.
|
|
14
|
+
* A leaf counts as read when a string literal or a template pattern (each interpolation a wildcard segment) of that source equals a
|
|
15
|
+
* tail of its dotted path (`"title"`, `"card.title"`, `"orders.card.title"` all read `orders.card.title`); the test is deliberately loose
|
|
16
|
+
* so a key handed through props or a table of keys is never reported, and only a key that appears nowhere is.
|
|
17
|
+
*
|
|
18
|
+
* Reads the source of the program and the JSON catalogs on disk; the wording of a translation is the product owner's.
|
|
19
|
+
*/
|
|
20
|
+
export const I18N_KEYS_RULE_IDS = ['FE_I18N_KEYS'];
|
|
21
|
+
|
|
22
|
+
const RULE = 'FE_I18N_KEYS';
|
|
23
|
+
const MODULES = new Set(['next-intl', 'next-intl/server']);
|
|
24
|
+
const FACTORIES = new Set(['useTranslations', 'getTranslations']);
|
|
25
|
+
const READERS = new Set(['rich', 'raw', 'markup']);
|
|
26
|
+
const APP_SOURCE = /^apps\/([^/]+)\/src\//u;
|
|
27
|
+
|
|
28
|
+
const leavesOf = (value, prefix = '') => (value !== null && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length
|
|
29
|
+
? Object.entries(value).flatMap(([key, child]) => leavesOf(child, prefix ? `${prefix}.${key}` : key))
|
|
30
|
+
: [prefix]);
|
|
31
|
+
|
|
32
|
+
const readCatalog = (root, rel) => {
|
|
33
|
+
try { return new Set(leavesOf(JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'))).filter(Boolean)); } catch { return null; }
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** True when the key `full` is a leaf of `leaves` or a namespace above one (an object read by `rich`, `raw` or a nested `t`). */
|
|
37
|
+
const held = (leaves, full) => leaves.has(full) || [...leaves].some(leaf => leaf.startsWith(`${full}.`));
|
|
38
|
+
|
|
39
|
+
// A pattern that is only wildcards (`${a}`, `${a}.${b}`) names nothing: it would read every key, so it reads none.
|
|
40
|
+
const tailMatches = (pattern, leafSegments) => pattern.some(segment => segment !== '*' && segment !== '') && pattern.length <= leafSegments.length
|
|
41
|
+
&& pattern.every((segment, index) => segment === '*' || segment === leafSegments[leafSegments.length - pattern.length + index]);
|
|
42
|
+
|
|
43
|
+
export function checkI18nKeys({ config, graph, context }) {
|
|
44
|
+
const ts = context.ts;
|
|
45
|
+
const violations = [];
|
|
46
|
+
const report = (file, node, message, extra = {}) => {
|
|
47
|
+
const at = file.sourceFile.getLineAndCharacterOfPosition(node.getStart(file.sourceFile));
|
|
48
|
+
violations.push({ ruleId: RULE, path: file.rel, line: at.line + 1, column: at.character + 1, message, ...extra });
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const catalogsByApp = new Map();
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
for (const file of graph.files.values()) {
|
|
54
|
+
const app = APP_SOURCE.exec(file.rel)?.[1];
|
|
55
|
+
if (!app || seen.has(app)) continue;
|
|
56
|
+
seen.add(app);
|
|
57
|
+
const dir = `apps/${app}/src/modules/i18n/messages/`;
|
|
58
|
+
let names = [];
|
|
59
|
+
try { names = fs.readdirSync(path.join(config.root, dir)).filter(name => name.endsWith('.json')).sort(); } catch { /* the app has no catalog directory: FE_I18N_PLACEMENT's finding */ }
|
|
60
|
+
if (names.length) catalogsByApp.set(dir, names.map(name => ({ rel: `${dir}${name}`, locale: name.slice(0, -5) })));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let apps = 0;
|
|
64
|
+
for (const [dir, catalogs] of catalogsByApp) {
|
|
65
|
+
const app = /^apps\/([^/]+)\//u.exec(dir)?.[1];
|
|
66
|
+
const loaded = catalogs.map(catalog => ({ ...catalog, leaves: readCatalog(config.root, catalog.rel) })).filter(catalog => catalog.leaves);
|
|
67
|
+
if (!app || !loaded.length) continue;
|
|
68
|
+
apps += 1;
|
|
69
|
+
const own = [...graph.files.values()].filter(file => file.rel.startsWith(`apps/${app}/src/`));
|
|
70
|
+
const shared = [...graph.files.values()].filter(file => /^packages\/[^/]+\/src\//u.test(file.rel));
|
|
71
|
+
const patterns = [];
|
|
72
|
+
|
|
73
|
+
for (const file of [...own, ...shared]) {
|
|
74
|
+
const visit = node => {
|
|
75
|
+
if (ts.isStringLiteralLike(node)) patterns.push(node.text.split('.'));
|
|
76
|
+
else if (ts.isTemplateExpression(node)) patterns.push([node.head.text, ...node.templateSpans.map(span => span.literal.text)].join('\u0000').split('\u0000').join('*').split('.'));
|
|
77
|
+
ts.forEachChild(node, visit);
|
|
78
|
+
};
|
|
79
|
+
visit(file.sourceFile);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const file of own) {
|
|
83
|
+
const factories = new Set();
|
|
84
|
+
for (const statement of file.sourceFile.statements) {
|
|
85
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || !MODULES.has(statement.moduleSpecifier.text)) continue;
|
|
86
|
+
const named = statement.importClause?.namedBindings;
|
|
87
|
+
if (!named || !ts.isNamedImports(named)) continue;
|
|
88
|
+
for (const element of named.elements) if (!element.isTypeOnly && FACTORIES.has((element.propertyName ?? element.name).text)) factories.add(element.name.text);
|
|
89
|
+
}
|
|
90
|
+
if (!factories.size) continue;
|
|
91
|
+
const checker = context.checkerFor(file.sourceFile.fileName);
|
|
92
|
+
const namespaces = new Map(); // translator variable declaration -> namespace ('' = none); a computed namespace is left out
|
|
93
|
+
const collect = node => {
|
|
94
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
|
|
95
|
+
let init = node.initializer;
|
|
96
|
+
if (ts.isAwaitExpression(init)) init = init.expression;
|
|
97
|
+
if (ts.isCallExpression(init) && ts.isIdentifier(init.expression) && factories.has(init.expression.text)) {
|
|
98
|
+
const [first] = init.arguments;
|
|
99
|
+
if (first === undefined) namespaces.set(node, '');
|
|
100
|
+
else if (ts.isStringLiteralLike(first)) namespaces.set(node, first.text);
|
|
101
|
+
else if (ts.isObjectLiteralExpression(first)) {
|
|
102
|
+
const property = first.properties.find(candidate => ts.isPropertyAssignment(candidate) && candidate.name.getText(file.sourceFile) === 'namespace');
|
|
103
|
+
if (!property) namespaces.set(node, '');
|
|
104
|
+
else if (ts.isStringLiteralLike(property.initializer)) namespaces.set(node, property.initializer.text);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
ts.forEachChild(node, collect);
|
|
109
|
+
};
|
|
110
|
+
collect(file.sourceFile);
|
|
111
|
+
if (!namespaces.size) continue;
|
|
112
|
+
const keysOf = argument => {
|
|
113
|
+
if (ts.isConditionalExpression(argument)) return [...keysOf(argument.whenTrue), ...keysOf(argument.whenFalse)];
|
|
114
|
+
if (ts.isParenthesizedExpression(argument) || ts.isAsExpression(argument) || ts.isSatisfiesExpression(argument)) return keysOf(argument.expression);
|
|
115
|
+
return ts.isStringLiteralLike(argument) ? [argument] : [];
|
|
116
|
+
};
|
|
117
|
+
const read = node => {
|
|
118
|
+
if (ts.isCallExpression(node)) {
|
|
119
|
+
const callee = node.expression;
|
|
120
|
+
const owner = ts.isIdentifier(callee) ? callee : (ts.isPropertyAccessExpression(callee) && READERS.has(callee.name.text) && ts.isIdentifier(callee.expression) ? callee.expression : null);
|
|
121
|
+
const [first] = node.arguments;
|
|
122
|
+
const declaration = owner ? checker.getSymbolAtLocation(owner)?.valueDeclaration : null;
|
|
123
|
+
if (owner && first !== undefined && namespaces.has(declaration)) {
|
|
124
|
+
for (const literal of keysOf(first)) {
|
|
125
|
+
const namespace = namespaces.get(declaration);
|
|
126
|
+
const full = namespace ? `${namespace}.${literal.text}` : literal.text;
|
|
127
|
+
const lacking = loaded.filter(catalog => !held(catalog.leaves, full)).map(catalog => catalog.locale);
|
|
128
|
+
if (lacking.length) report(file, literal, `${file.rel} reads "${full}" and ${lacking.join(', ')} ${lacking.length === 1 ? 'holds' : 'hold'} no such key; add it to every catalog of ${app} or read a key that exists`, { key: full, locales: lacking });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
ts.forEachChild(node, read);
|
|
133
|
+
};
|
|
134
|
+
read(file.sourceFile);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const [primary] = [...loaded].sort((a, b) => a.locale.localeCompare(b.locale));
|
|
138
|
+
const every = new Set(loaded.flatMap(catalog => [...catalog.leaves]));
|
|
139
|
+
for (const leaf of [...every].sort()) {
|
|
140
|
+
const segments = leaf.split('.');
|
|
141
|
+
if (patterns.some(pattern => tailMatches(pattern, segments))) continue;
|
|
142
|
+
violations.push({ ruleId: RULE, path: primary.rel, line: 1, column: 1, message: `${primary.rel} holds the key "${leaf}" and no source of ${app} or the shared packages reads it; delete the key from every catalog`, key: leaf });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { violations, coverage: { status: 'checked', apps } };
|
|
146
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { canonical, loadArchitectureConfig } from './config.mjs';
|
|
3
|
+
import { sameOrUnder } from '../../lib/path-key.mjs';
|
|
4
|
+
import { buildTypeScriptContext, relativePath } from './typescript.mjs';
|
|
5
|
+
import { checkBackend } from './backend.mjs';
|
|
6
|
+
import { checkBackendContracts, PUBLIC_CONTRACT_RULE_ID, READONLY_BOUNDARY_RULE_ID } from './contracts.mjs';
|
|
7
|
+
import { checkFrontend } from './frontend.mjs';
|
|
8
|
+
import { checkOwners } from './owners.mjs';
|
|
9
|
+
import { checkModuleRegistration, REGISTRATION_RULE_IDS } from './registration.mjs';
|
|
10
|
+
import { checkFrontendDataLifecycle, SWR_DATA_RULE_IDS } from './next-data.mjs';
|
|
11
|
+
import { checkBackendSourceShape, SOURCE_LAYOUT_RULE_ID, SOURCE_NAME_RULE_ID } from './source-names.mjs';
|
|
12
|
+
import { checkHfs, checkHfsWithoutConfig, HFS_RULE_IDS } from './hfs.mjs';
|
|
13
|
+
import { buildHfsGraph } from './hfs-graph.mjs';
|
|
14
|
+
import { checkTiers, TIER_RULE_IDS } from './tiers.mjs';
|
|
15
|
+
import { checkReachability, REACHABILITY_RULE_IDS } from './reachability.mjs';
|
|
16
|
+
import { checkDeadExports, DEAD_EXPORT_RULE_IDS } from './dead-exports.mjs';
|
|
17
|
+
import { checkRequiredFiles, REQUIRED_FILE_RULE_IDS } from './required-files.mjs';
|
|
18
|
+
import { checkSizeGrowth, SIZE_GROWTH_RULE_IDS } from './size-growth.mjs';
|
|
19
|
+
import { checkClones, CLONE_RULE_IDS } from './clones.mjs';
|
|
20
|
+
import { checkSymbols, SYMBOL_RULE_IDS } from './symbols.mjs';
|
|
21
|
+
import { checkConnectionMap, CONNECTION_RULE_IDS } from './connection-map.mjs';
|
|
22
|
+
import { checkSqlOwner, SQL_OWNER_RULE_IDS } from './sql-owner.mjs';
|
|
23
|
+
import { checkRegisterOnce, REGISTER_ONCE_RULE_IDS } from './register-once.mjs';
|
|
24
|
+
import { checkErrorMasked, ERROR_MASKED_RULE_IDS } from './error-masked.mjs';
|
|
25
|
+
import { checkDefaultDeny, DEFAULT_DENY_RULE_IDS } from './default-deny.mjs';
|
|
26
|
+
import { checkEntrypoints, ENTRYPOINT_RULE_IDS } from './entrypoint.mjs';
|
|
27
|
+
import { checkErrorCodes, ERROR_CODE_RULE_IDS } from './error-codes.mjs';
|
|
28
|
+
import { checkConfigUnread, CONFIG_UNREAD_RULE_IDS } from './config-unread.mjs';
|
|
29
|
+
import { checkFeatureShape, FEATURE_SHAPE_RULE_IDS } from './feature-shape.mjs';
|
|
30
|
+
import { checkTestWorldFiles, TEST_WORLD_FILES_RULE_IDS } from './test-world-files.mjs';
|
|
31
|
+
import { checkUnitSpecProviders, UNIT_SPEC_PROVIDERS_RULE_IDS } from './unit-spec-providers.mjs';
|
|
32
|
+
import { checkInjectionTokenExported, INJECTION_TOKEN_EXPORTED_RULE_IDS } from './injection-token-exported.mjs';
|
|
33
|
+
import { checkContractFixtureGuard, CONTRACT_FIXTURE_GUARD_RULE_IDS } from './contract-fixture-guard.mjs';
|
|
34
|
+
import { checkSchemaOwner, SCHEMA_OWNER_RULE_IDS } from './schema-owner.mjs';
|
|
35
|
+
import { checkModulePerTransport, MODULE_PER_TRANSPORT_RULE_IDS } from './module-per-transport.mjs';
|
|
36
|
+
import { checkBackgroundUnowned, BACKGROUND_UNOWNED_RULE_IDS } from './background-unowned.mjs';
|
|
37
|
+
import { checkTransportOwner, TRANSPORT_OWNER_RULE_IDS } from './transport-owner.mjs';
|
|
38
|
+
import { checkRouteFilesThin, ROUTE_FILES_THIN_RULE_IDS } from './route-files-thin.mjs';
|
|
39
|
+
import { checkHooksAreHooks, HOOKS_ARE_HOOKS_RULE_IDS } from './hooks-are-hooks.mjs';
|
|
40
|
+
import { checkClientReachesServer, CLIENT_REACHES_SERVER_RULE_IDS } from './client-reaches-server.mjs';
|
|
41
|
+
import { checkCrossAppDuplicate, CROSS_APP_DUPLICATE_RULE_IDS } from './cross-app-duplicate.mjs';
|
|
42
|
+
import { checkPackageShape, PACKAGE_SHAPE_RULE_IDS } from './package-shape.mjs';
|
|
43
|
+
import { checkFeSlotAllows, FE_SLOT_ALLOWS_RULE_IDS } from './fe-slot-allows.mjs';
|
|
44
|
+
import { checkI18nKeys, I18N_KEYS_RULE_IDS } from './i18n-keys.mjs';
|
|
45
|
+
import { checkDocLanguage, DOC_LANGUAGE_RULE_IDS } from './doc-language.mjs';
|
|
46
|
+
|
|
47
|
+
export { REGISTRATION_RULE_IDS, SWR_DATA_RULE_IDS };
|
|
48
|
+
|
|
49
|
+
const LIMITATIONS = [
|
|
50
|
+
'This is a static TypeScript dependency and source-shape check; it does not prove runtime dependency-injection bindings, global/provider scope, state lifetime, server/client behavior, feature-versus-capability ownership, route behavior, or business correctness.',
|
|
51
|
+
'Dependencies hidden behind constructed aliases, reflection, or calls other than direct import()/require() syntax require separate review.',
|
|
52
|
+
'Protocol surfaces selected through reflection, nonliteral computed properties, or aliases constructed beyond static import/re-export bindings require separate review.',
|
|
53
|
+
'Backend source-shape rules classify only resolved roots, declarations, framework symbols, and static decorator arguments; cohesive capability, error, persistence, and migration ownership still require design and runtime evidence.',
|
|
54
|
+
'Backend contract rules prove selected declaration and readonly field forms only; they do not prove runtime validation, serialization compatibility, provider scope, token identity, or dependency behavior.',
|
|
55
|
+
'Frontend SWR lifecycle rules prove declared key bindings and installed SWR identity only; domain identity completeness, stale-result behavior and mutation effects require target behavior evidence.',
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// The backend composition and data machine (R29, R32, R33, R35, R38, R39, R41, R45, R46, R47, R48, R84, R85, R86): name -> [check, the rule ids it makes truthful].
|
|
59
|
+
const BACKEND_MACHINE = {
|
|
60
|
+
connectionMap: [checkConnectionMap, CONNECTION_RULE_IDS],
|
|
61
|
+
sqlOwner: [checkSqlOwner, SQL_OWNER_RULE_IDS],
|
|
62
|
+
registerOnce: [checkRegisterOnce, REGISTER_ONCE_RULE_IDS],
|
|
63
|
+
errorMasked: [checkErrorMasked, ERROR_MASKED_RULE_IDS],
|
|
64
|
+
defaultDeny: [checkDefaultDeny, DEFAULT_DENY_RULE_IDS],
|
|
65
|
+
entrypoints: [checkEntrypoints, ENTRYPOINT_RULE_IDS],
|
|
66
|
+
errorCodes: [checkErrorCodes, ERROR_CODE_RULE_IDS],
|
|
67
|
+
featureShape: [checkFeatureShape, FEATURE_SHAPE_RULE_IDS],
|
|
68
|
+
unitSpecProviders: [checkUnitSpecProviders, UNIT_SPEC_PROVIDERS_RULE_IDS],
|
|
69
|
+
injectionTokenExported: [checkInjectionTokenExported, INJECTION_TOKEN_EXPORTED_RULE_IDS],
|
|
70
|
+
schemaOwner: [checkSchemaOwner, SCHEMA_OWNER_RULE_IDS],
|
|
71
|
+
modulePerTransport: [checkModulePerTransport, MODULE_PER_TRANSPORT_RULE_IDS],
|
|
72
|
+
backgroundUnowned: [checkBackgroundUnowned, BACKGROUND_UNOWNED_RULE_IDS],
|
|
73
|
+
testWorldFiles: [checkTestWorldFiles, TEST_WORLD_FILES_RULE_IDS],
|
|
74
|
+
contractFixtureGuard: [checkContractFixtureGuard, CONTRACT_FIXTURE_GUARD_RULE_IDS],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// The frontend repository machine (R21, R50, R54, R55, R56, R63, R94, R106): same shape, run for a front-end repository only.
|
|
78
|
+
const FRONTEND_MACHINE = {
|
|
79
|
+
transportOwner: [checkTransportOwner, TRANSPORT_OWNER_RULE_IDS],
|
|
80
|
+
routeFilesThin: [checkRouteFilesThin, ROUTE_FILES_THIN_RULE_IDS],
|
|
81
|
+
hooksAreHooks: [checkHooksAreHooks, HOOKS_ARE_HOOKS_RULE_IDS],
|
|
82
|
+
packageShape: [checkPackageShape, PACKAGE_SHAPE_RULE_IDS],
|
|
83
|
+
clientReachesServer: [checkClientReachesServer, CLIENT_REACHES_SERVER_RULE_IDS],
|
|
84
|
+
crossAppDuplicate: [checkCrossAppDuplicate, CROSS_APP_DUPLICATE_RULE_IDS],
|
|
85
|
+
feSlotAllows: [checkFeSlotAllows, FE_SLOT_ALLOWS_RULE_IDS],
|
|
86
|
+
i18nKeys: [checkI18nKeys, I18N_KEYS_RULE_IDS],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Errors the machine reports when a program cannot be built or an edge cannot be proven: it cannot judge, so it refuses. */
|
|
90
|
+
const COMMON_REFUSAL_RULE_IDS = [
|
|
91
|
+
'ARCH_DYNAMIC_DEPENDENCY_UNPROVEN',
|
|
92
|
+
'ARCH_INTERNAL_IMPORT_OUTSIDE',
|
|
93
|
+
'ARCH_INTERNAL_IMPORT_UNRESOLVED',
|
|
94
|
+
'ARCH_NO_SOURCE',
|
|
95
|
+
'ARCH_SYNTAX_INVALID',
|
|
96
|
+
'ARCH_TSCONFIG_INVALID',
|
|
97
|
+
'ARCH_TSCONFIG_MISSING',
|
|
98
|
+
'ARCH_TSCONFIG_REFERENCE_OUTSIDE',
|
|
99
|
+
];
|
|
100
|
+
const COMMON_RULE_IDS = [
|
|
101
|
+
...COMMON_REFUSAL_RULE_IDS,
|
|
102
|
+
'ARCH_PACKAGE_EXPORT_BYPASS',
|
|
103
|
+
'ARCH_PACKAGE_IMPORTS_APP',
|
|
104
|
+
];
|
|
105
|
+
const BACKEND_RULE_IDS = [
|
|
106
|
+
'BE_APP_BUSINESS_ROLE',
|
|
107
|
+
'BE_APP_COMPOSITION_ONLY',
|
|
108
|
+
'BE_APPLICATION_IMPORTS_TRANSPORT',
|
|
109
|
+
'BE_APPLICATION_TRANSPORT_FRAMEWORK',
|
|
110
|
+
];
|
|
111
|
+
const FRONTEND_RULE_IDS = [
|
|
112
|
+
'FE_BLOCK_PRODUCT_HOOK_DEFINITION',
|
|
113
|
+
'FE_COMPONENT_WORLD_OWNERSHIP',
|
|
114
|
+
'FE_COMPONENT_DEEP_HOOK_IMPORT',
|
|
115
|
+
'FE_CONNECTED_BLOCK_RENDER_PAIR',
|
|
116
|
+
'FE_CUSTOM_HOOK_LOCATION',
|
|
117
|
+
'FE_PURE_REACHES_DATA',
|
|
118
|
+
'FE_PURE_WORLD_HOOK',
|
|
119
|
+
'FE_PURE_WORLD_IMPORT',
|
|
120
|
+
'FE_ROUTE_CLIENT_BOUNDARY',
|
|
121
|
+
'FE_ROUTE_CLIENT_HOOK',
|
|
122
|
+
'FE_ROUTE_DEFAULT_EXPORT',
|
|
123
|
+
'FE_ROUTE_DRAWING_DECISION',
|
|
124
|
+
'FE_ROUTE_ONE_PAGE',
|
|
125
|
+
'FE_SOURCE_LAYOUT_INVALID',
|
|
126
|
+
'FE_WORLD_OWNER_RENDER_BOUNDARY',
|
|
127
|
+
];
|
|
128
|
+
export const OWNER_RULE_IDS = ['ARCH_OWNER_EXPORT_BYPASS', 'ARCH_OWNER_EXPORT_STAR'];
|
|
129
|
+
export const GRAMMAR_RULE_IDS = ['ARCH_GRAMMAR_CONTRACT_INVALID', 'ARCH_GRAMMAR_EXPORT_BYPASS'];
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Every code the machine reports as an error because it cannot judge (a check that cannot run is an error, never a pass).
|
|
133
|
+
* These are infrastructure refusals, not obligations, so no rule of knowledge/hfs/rules.yaml owns them.
|
|
134
|
+
*/
|
|
135
|
+
export const ERROR_RULE_IDS = Object.freeze([
|
|
136
|
+
'ARCH_COMPILER_FAILURE', 'ARCH_CONFIG_INVALID', 'ARCH_EXECUTION_UNAVAILABLE', 'ARCH_KNOWLEDGE_UNAVAILABLE', 'ARCH_TYPESCRIPT_INVALID', 'ARCH_TYPESCRIPT_MISSING',
|
|
137
|
+
...COMMON_REFUSAL_RULE_IDS,
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
const machineIds = () => [
|
|
141
|
+
...TIER_RULE_IDS, ...REACHABILITY_RULE_IDS, ...DEAD_EXPORT_RULE_IDS, ...REQUIRED_FILE_RULE_IDS, ...SIZE_GROWTH_RULE_IDS, ...CLONE_RULE_IDS, ...SYMBOL_RULE_IDS,
|
|
142
|
+
...Object.values(BACKEND_MACHINE).flatMap(([, ids]) => ids), ...Object.values(FRONTEND_MACHINE).flatMap(([, ids]) => ids),
|
|
143
|
+
];
|
|
144
|
+
/**
|
|
145
|
+
* The rules only the HFS machine emits: the tier, reachability, dead-export, required-file, size, clone and symbol checks and the
|
|
146
|
+
* backend composition and data machine and frontend repository machine. A rule id an ordinary check also emits (the composition
|
|
147
|
+
* spec and the app-composition check both report BE_APP_COMPOSITION_ONLY) is not here. A spec about another rule judges its own
|
|
148
|
+
* report without these (they have their own specs); every one of them is in ARCHITECTURE_RULE_IDS.
|
|
149
|
+
*/
|
|
150
|
+
export const HFS_MACHINE_RULE_IDS = Object.freeze((() => {
|
|
151
|
+
const ordinary = new Set([...COMMON_RULE_IDS, ...BACKEND_RULE_IDS, ...FRONTEND_RULE_IDS, ...OWNER_RULE_IDS, ...GRAMMAR_RULE_IDS, ...REGISTRATION_RULE_IDS,
|
|
152
|
+
...SWR_DATA_RULE_IDS, SOURCE_LAYOUT_RULE_ID, SOURCE_NAME_RULE_ID, PUBLIC_CONTRACT_RULE_ID, READONLY_BOUNDARY_RULE_ID]);
|
|
153
|
+
return [...new Set(machineIds().filter((id) => !ordinary.has(id)))].sort();
|
|
154
|
+
})());
|
|
155
|
+
|
|
156
|
+
/** Every code the machine can emit, derived from the rule id lists of its checks (`hfs check` ships exactly these why entries). */
|
|
157
|
+
export const ARCHITECTURE_RULE_IDS = Object.freeze([...new Set([
|
|
158
|
+
...COMMON_RULE_IDS, ...BACKEND_RULE_IDS, ...FRONTEND_RULE_IDS, ...HFS_RULE_IDS, ...TIER_RULE_IDS, ...REACHABILITY_RULE_IDS,
|
|
159
|
+
...DEAD_EXPORT_RULE_IDS, ...DOC_LANGUAGE_RULE_IDS, ...REQUIRED_FILE_RULE_IDS, ...SIZE_GROWTH_RULE_IDS, ...CLONE_RULE_IDS, ...OWNER_RULE_IDS, ...GRAMMAR_RULE_IDS,
|
|
160
|
+
...REGISTRATION_RULE_IDS, ...SWR_DATA_RULE_IDS, ...SYMBOL_RULE_IDS, SOURCE_LAYOUT_RULE_ID, SOURCE_NAME_RULE_ID, PUBLIC_CONTRACT_RULE_ID,
|
|
161
|
+
READONLY_BOUNDARY_RULE_ID, ...ERROR_RULE_IDS, ...CONFIG_UNREAD_RULE_IDS, ...Object.values(BACKEND_MACHINE).flatMap(([, ids]) => ids), ...Object.values(FRONTEND_MACHINE).flatMap(([, ids]) => ids),
|
|
162
|
+
])].sort());
|
|
163
|
+
|
|
164
|
+
function stable(items) {
|
|
165
|
+
return items.sort((a, b) => `${a.path ?? ''}:${a.line ?? 0}:${a.column ?? 0}:${a.ruleId}`.localeCompare(`${b.path ?? ''}:${b.line ?? 0}:${b.column ?? 0}:${b.ruleId}`));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Check a target repository. injectedTypeScript exists only for hermetic rule fixtures. `fast` leaves out the checks
|
|
170
|
+
* that read the whole repository to answer (clones, dead exports, repository-wide symbols); the pre-push check of the changed owners uses it.
|
|
171
|
+
*/
|
|
172
|
+
export function checkArchitecture({ repositoryRoot, injectedTypeScript, paths = [], base, fast = false, hfs: openedHfs } = {}) {
|
|
173
|
+
let config;
|
|
174
|
+
try {
|
|
175
|
+
config = loadArchitectureConfig(repositoryRoot, { hfs: openedHfs });
|
|
176
|
+
} catch (error) {
|
|
177
|
+
const hfs = checkHfsWithoutConfig(repositoryRoot);
|
|
178
|
+
return { schema: 'starci/architecture-check@1', ok: false, repository: String(repositoryRoot ?? ''), kinds: [], files: 0,
|
|
179
|
+
compiler: null, violations: stable(hfs.violations), coverage: { hfs: hfs.coverage },
|
|
180
|
+
errors: [{ ruleId: /^(HFS_[A-Z_]+):/.exec(String(error.message))?.[1] ?? 'ARCH_CONFIG_INVALID', message: String(error.message ?? error).replace(/^HFS_[A-Z_]+:\s*/, '') }], limitations: LIMITATIONS };
|
|
181
|
+
}
|
|
182
|
+
const hfs = checkHfs(config);
|
|
183
|
+
let context;
|
|
184
|
+
try {
|
|
185
|
+
context = buildTypeScriptContext(config, injectedTypeScript, paths);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
const message = String(error.message ?? error);
|
|
188
|
+
const match = /^(ARCH_[A-Z_]+):\s*/.exec(message);
|
|
189
|
+
return { schema: 'starci/architecture-check@1', ok: false, repository: config.root, kinds: config.kinds, files: 0,
|
|
190
|
+
compiler: null, violations: hfs.violations, coverage: { hfs: hfs.coverage },
|
|
191
|
+
errors: [{ ruleId: match?.[1] ?? 'ARCH_COMPILER_FAILURE', message: message.replace(/^(ARCH_[A-Z_]+):\s*/, '') }], limitations: LIMITATIONS };
|
|
192
|
+
}
|
|
193
|
+
const violations = [...hfs.violations];
|
|
194
|
+
const configUnread = checkConfigUnread({ config, context });
|
|
195
|
+
violations.push(...configUnread.violations);
|
|
196
|
+
let moduleRegistration = { status: 'not-applicable' };
|
|
197
|
+
let backendSourceShape = { status: 'not-applicable' };
|
|
198
|
+
let backendContractTypeForm = { publicContracts: { status: 'not-applicable' }, readonlyBoundaries: { status: 'not-applicable' } };
|
|
199
|
+
let frontendDataLifecycle = { status: 'not-applicable' };
|
|
200
|
+
if (context.program) violations.push(...checkOwners(config, context));
|
|
201
|
+
if (context.program && config.kinds.includes('backend')) {
|
|
202
|
+
violations.push(...checkBackend(config, context));
|
|
203
|
+
// A slice may omit every app root. Registration ownership needs the complete
|
|
204
|
+
// composition graph even when the caller asks for findings in only one path.
|
|
205
|
+
const registrationContext = paths.length && config.backend.moduleRegistration
|
|
206
|
+
? buildTypeScriptContext(config, injectedTypeScript) : context;
|
|
207
|
+
const registration = checkModuleRegistration(config, registrationContext);
|
|
208
|
+
violations.push(...registration.violations);
|
|
209
|
+
moduleRegistration = registration.coverage;
|
|
210
|
+
const sourceShape = checkBackendSourceShape(config, context);
|
|
211
|
+
violations.push(...sourceShape.violations);
|
|
212
|
+
backendSourceShape = sourceShape.coverage;
|
|
213
|
+
const contracts = checkBackendContracts(config, context);
|
|
214
|
+
violations.push(...contracts.violations);
|
|
215
|
+
backendContractTypeForm = contracts.coverage;
|
|
216
|
+
}
|
|
217
|
+
let frontendChecked = config.kinds.includes('frontend');
|
|
218
|
+
if (context.program && config.kinds.includes('frontend')) {
|
|
219
|
+
// A broken authored input (the framework-pinned root list in knowledge) is an error, never a
|
|
220
|
+
// silently different contract: the frontend rules are then not reported as checked.
|
|
221
|
+
try {
|
|
222
|
+
violations.push(...checkFrontend(config, context));
|
|
223
|
+
} catch (error) {
|
|
224
|
+
const message = String(error.message ?? error);
|
|
225
|
+
const match = /^(ARCH_[A-Z_]+):\s*/.exec(message);
|
|
226
|
+
if (!match) throw error;
|
|
227
|
+
context.errors.push({ ruleId: match[1], message: message.slice(match[0].length) });
|
|
228
|
+
frontendChecked = false;
|
|
229
|
+
}
|
|
230
|
+
const dataLifecycle = checkFrontendDataLifecycle(config, context);
|
|
231
|
+
violations.push(...dataLifecycle.violations);
|
|
232
|
+
frontendDataLifecycle = dataLifecycle.coverage;
|
|
233
|
+
}
|
|
234
|
+
// HFS machine: the slot-driven graph checks read the whole program, also when the caller asked for one path.
|
|
235
|
+
const hfsContext = paths.length ? buildTypeScriptContext(config, injectedTypeScript) : context;
|
|
236
|
+
const hfsChecks = { tiers: null, reachability: null, deadExports: null, requiredFiles: null, sizeGrowth: null, clones: null, symbols: null, docLanguage: null };
|
|
237
|
+
if (hfsContext.program) {
|
|
238
|
+
const graph = buildHfsGraph(config, hfsContext);
|
|
239
|
+
const input = { config, context: hfsContext, graph, base };
|
|
240
|
+
const runs = { tiers: () => checkTiers(graph), reachability: () => checkReachability(input), deadExports: () => checkDeadExports(input),
|
|
241
|
+
requiredFiles: () => checkRequiredFiles(input), sizeGrowth: () => checkSizeGrowth(input), clones: () => checkClones(input), symbols: () => checkSymbols(input), docLanguage: () => checkDocLanguage(input) };
|
|
242
|
+
if (graph.profile === 'be') for (const [name, [check]] of Object.entries(BACKEND_MACHINE)) runs[name] = () => check(input);
|
|
243
|
+
if (graph.profile === 'fe') for (const [name, [check]] of Object.entries(FRONTEND_MACHINE)) runs[name] = () => check(input);
|
|
244
|
+
if (fast) { delete runs.deadExports; delete runs.clones; delete runs.symbols; }
|
|
245
|
+
for (const [name, run] of Object.entries(runs)) {
|
|
246
|
+
const result = run();
|
|
247
|
+
violations.push(...result.violations);
|
|
248
|
+
hfsChecks[name] = result.coverage;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const inScope = item => !paths.length || (item.path && paths.some(prefix => sameOrUnder(item.path, prefix.replace(/\/$/, ''))));
|
|
252
|
+
const errors = stable(context.errors.filter(item => item.ruleId.startsWith('ARCH_TSCONFIG_') || !item.path || inScope(item)));
|
|
253
|
+
const scopedViolations = stable(violations.filter(inScope));
|
|
254
|
+
const sourceFiles = new Set(context.files.map(file => canonical(file.fileName)));
|
|
255
|
+
const missingOwnerEntries = config.owners?.filter(owner => !sourceFiles.has(canonical(path.resolve(config.root, ...owner.entry.split('/'))))) ?? [];
|
|
256
|
+
const coverage = {
|
|
257
|
+
sourceFiles: context.files.map(file => relativePath(config.root, canonical(file.fileName))).sort(),
|
|
258
|
+
hfs: hfs.coverage,
|
|
259
|
+
configUnread: configUnread.coverage,
|
|
260
|
+
backendContractTypeForm,
|
|
261
|
+
backendSourceShape,
|
|
262
|
+
frontendDataLifecycle,
|
|
263
|
+
moduleRegistration,
|
|
264
|
+
hfsMachine: hfsChecks,
|
|
265
|
+
ownerPublicApi: !config.owners.length
|
|
266
|
+
? { status: 'unavailable', reason: 'no slot owner instance with an entry file exists in the repository' }
|
|
267
|
+
: missingOwnerEntries.length
|
|
268
|
+
? { status: 'unavailable', reason: 'one or more declared owner entries are outside the checked production TypeScript or JavaScript program',
|
|
269
|
+
missingEntries: missingOwnerEntries.map(owner => owner.entry).sort() }
|
|
270
|
+
: { status: 'checked', declarations: config.owners.length },
|
|
271
|
+
grammarContract: !config.kinds.includes('frontend')
|
|
272
|
+
? { status: 'not-applicable' }
|
|
273
|
+
: config.frontend.grammar
|
|
274
|
+
? { status: 'checked', package: config.frontend.grammar.package }
|
|
275
|
+
: { status: 'unavailable', reason: 'no app has a src/app/globals.css to judge against the Grammar style entry' },
|
|
276
|
+
};
|
|
277
|
+
coverage.checkedRuleIds = [...new Set([
|
|
278
|
+
...COMMON_RULE_IDS,
|
|
279
|
+
...(hfs.coverage.status === 'checked' ? HFS_RULE_IDS : []),
|
|
280
|
+
...(config.kinds.includes('backend') ? BACKEND_RULE_IDS : []),
|
|
281
|
+
...(hfsChecks.tiers ? TIER_RULE_IDS : []),
|
|
282
|
+
...(hfsChecks.reachability?.status === 'checked' ? REACHABILITY_RULE_IDS : []),
|
|
283
|
+
...(hfsChecks.deadExports?.status === 'checked' ? DEAD_EXPORT_RULE_IDS : []),
|
|
284
|
+
...(hfsChecks.requiredFiles?.status === 'checked' ? REQUIRED_FILE_RULE_IDS : []),
|
|
285
|
+
...(hfsChecks.sizeGrowth?.status === 'checked' ? SIZE_GROWTH_RULE_IDS : []),
|
|
286
|
+
...(hfsChecks.clones?.status === 'checked' ? CLONE_RULE_IDS : []),
|
|
287
|
+
...(hfsChecks.symbols?.status === 'checked' ? SYMBOL_RULE_IDS : []),
|
|
288
|
+
...(hfsChecks.docLanguage?.status === 'checked' ? DOC_LANGUAGE_RULE_IDS : []),
|
|
289
|
+
...CONFIG_UNREAD_RULE_IDS,
|
|
290
|
+
...Object.entries({ ...BACKEND_MACHINE, ...FRONTEND_MACHINE }).flatMap(([name, [, ids]]) => (hfsChecks[name]?.status === 'checked' ? ids : [])),
|
|
291
|
+
...(frontendChecked ? FRONTEND_RULE_IDS : []),
|
|
292
|
+
...(coverage.ownerPublicApi.status === 'checked' ? OWNER_RULE_IDS : []),
|
|
293
|
+
...(coverage.grammarContract.status === 'checked' ? GRAMMAR_RULE_IDS : []),
|
|
294
|
+
...(coverage.moduleRegistration.status === 'checked' ? REGISTRATION_RULE_IDS : []),
|
|
295
|
+
...(coverage.backendSourceShape.layout?.status === 'checked' ? [SOURCE_LAYOUT_RULE_ID] : []),
|
|
296
|
+
...(coverage.backendSourceShape.naming?.status === 'checked' ? [SOURCE_NAME_RULE_ID] : []),
|
|
297
|
+
...(coverage.backendContractTypeForm.publicContracts?.status === 'checked' ? [PUBLIC_CONTRACT_RULE_ID] : []),
|
|
298
|
+
...(coverage.backendContractTypeForm.readonlyBoundaries?.status === 'checked' ? [READONLY_BOUNDARY_RULE_ID] : []),
|
|
299
|
+
...(config.kinds.includes('frontend') && ['checked', 'not-applicable'].includes(coverage.frontendDataLifecycle.status) ? SWR_DATA_RULE_IDS : []),
|
|
300
|
+
])]
|
|
301
|
+
// A back-end repository checked no FE_ rule and a front-end one no BE_ rule, whatever list a shared check (tiers, required files) carries.
|
|
302
|
+
.filter(id => !(id.startsWith('FE_') && !config.kinds.includes('frontend')) && !(id.startsWith('BE_') && !config.kinds.includes('backend')))
|
|
303
|
+
.sort();
|
|
304
|
+
return {
|
|
305
|
+
schema: 'starci/architecture-check@1',
|
|
306
|
+
ok: errors.length === 0 && scopedViolations.length === 0,
|
|
307
|
+
repository: config.root,
|
|
308
|
+
kinds: config.kinds,
|
|
309
|
+
files: context.files.length,
|
|
310
|
+
compiler: { version: context.loaded.version, resolved: context.loaded.resolved, projects: context.projects.map(item => item.relative) },
|
|
311
|
+
coverage,
|
|
312
|
+
violations: scopedViolations,
|
|
313
|
+
errors,
|
|
314
|
+
limitations: LIMITATIONS,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { machineKit } from './machine-ast.mjs';
|
|
3
|
+
import { constructorDependencies, serviceClasses } from './constructor-deps.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* R85 `injection-token-exported` (BE_RAW_INJECT), the injection law's last obligation: a spec can only provide a token it
|
|
7
|
+
* can import. Every `Inject<Thing>()` decorator that a service constructor uses resolves to a token (the argument of its
|
|
8
|
+
* `injector<T>(TOKEN)`), and that token must be EXPORTED, by name, from the file of the decorator (`<cap>.decorators.ts`),
|
|
9
|
+
* from the `<cap>.decorators.ts` that declares it, or from the index.ts of the capability that owns either file. A token
|
|
10
|
+
* that is a private (non-exported) const, or one only a sibling file of the capability can reach, cannot be provided by a
|
|
11
|
+
* unit spec and is a finding. A token that a package declares (a Nest options token, say) is the package's export.
|
|
12
|
+
* The finding sits on the decorator's `injector(...)` call, once per decorator, however many services use it.
|
|
13
|
+
*/
|
|
14
|
+
export const INJECTION_TOKEN_EXPORTED_RULE_IDS = ['BE_RAW_INJECT'];
|
|
15
|
+
|
|
16
|
+
const RULE = 'BE_RAW_INJECT';
|
|
17
|
+
|
|
18
|
+
export function checkInjectionTokenExported(input) {
|
|
19
|
+
const { graph } = input;
|
|
20
|
+
const kit = machineKit(input);
|
|
21
|
+
const { ts } = kit;
|
|
22
|
+
const violations = [];
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
let decorators = 0;
|
|
25
|
+
|
|
26
|
+
const exportsName = (file, name) => {
|
|
27
|
+
if (!file) return false;
|
|
28
|
+
for (const statement of file.sourceFile.statements) {
|
|
29
|
+
if (ts.isVariableStatement(statement) && kit.isExported(statement)) {
|
|
30
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
31
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === name) return true;
|
|
32
|
+
if (ts.isObjectBindingPattern(declaration.name) && declaration.name.elements.some(element => ts.isIdentifier(element.name) && element.name.text === name)) return true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)
|
|
36
|
+
&& statement.exportClause.elements.some(element => element.name.text === name)) return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
};
|
|
40
|
+
const indexOf = file => (file?.owner ? graph.files.get(`${file.owner.root}/index.ts`) : null);
|
|
41
|
+
|
|
42
|
+
for (const { file, declaration } of serviceClasses(kit, graph)) {
|
|
43
|
+
for (const dependency of constructorDependencies(kit, file, declaration)) {
|
|
44
|
+
if (dependency.external || !dependency.decoratorDeclaration || !dependency.injectorCall || seen.has(dependency.injectorCall)) continue;
|
|
45
|
+
seen.add(dependency.injectorCall);
|
|
46
|
+
decorators += 1;
|
|
47
|
+
const decoratorFile = graph.files.get(kit.graphPath(dependency.decoratorDeclaration));
|
|
48
|
+
if (dependency.kind === 'unresolved') {
|
|
49
|
+
if (decoratorFile) violations.push({ ruleId: RULE, path: decoratorFile.rel, ...kit.at(decoratorFile.rel, decoratorFile.sourceFile, dependency.injectorCall), decorator: dependency.decorator,
|
|
50
|
+
message: `${dependency.decorator}() cannot be followed to its token (${dependency.reason}); name an exported \`unique symbol\` const so a unit spec can provide it.` });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const tokenFile = dependency.declaration ? graph.files.get(kit.graphPath(dependency.declaration)) : null;
|
|
54
|
+
const homes = [decoratorFile, tokenFile && path.posix.basename(tokenFile.rel).endsWith('.decorators.ts') ? tokenFile : null, indexOf(decoratorFile), indexOf(tokenFile)];
|
|
55
|
+
if (homes.some(home => exportsName(home, dependency.name))) continue;
|
|
56
|
+
if (!decoratorFile) continue;
|
|
57
|
+
violations.push({ ruleId: RULE, path: decoratorFile.rel, ...kit.at(decoratorFile.rel, decoratorFile.sourceFile, dependency.injectorCall), decorator: dependency.decorator, token: dependency.name,
|
|
58
|
+
message: `${dependency.decorator}() injects ${dependency.name}, which is not exported from ${path.posix.basename(decoratorFile.rel)} nor from the capability index.ts; export the token so a unit spec can provide it (\`{ provide: ${dependency.name}, useValue: double }\`).` });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { violations, coverage: { status: 'checked', decorators, files: graph.files.size } };
|
|
62
|
+
}
|