@starci/hfs 4.0.10 → 4.2.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 +5 -1
- package/emit/operations.mjs +1 -1
- package/emit/type-schema.mjs +2 -1
- package/lint/run.mjs +65 -24
- package/package.json +1 -1
- package/report/order.mjs +2 -0
- package/runtime/config.example.yaml +194 -0
- package/runtime/engine/by-code-unit.mjs +12 -0
- package/runtime/engine/config.mjs +222 -239
- package/runtime/engine/invalid-config.mjs +5 -5
- package/runtime/engine/model-config.mjs +90 -0
- package/runtime/engine/orca-config.mjs +3 -1
- package/runtime/engine/release-config.mjs +28 -0
- package/runtime/engine/removed-vocabulary.mjs +28 -0
- package/runtime/engine/resources-config.mjs +52 -0
- package/runtime/engine/runtime-root.mjs +17 -0
- package/runtime/engine/secrets.mjs +35 -31
- package/runtime/engine/sonar-config.mjs +21 -0
- package/runtime/engine/temp-root.mjs +32 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
- package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
- package/runtime/knowledge/hfs/rules.yaml +161 -65
- package/runtime/knowledge/hfs/slots.yaml +11 -14
- package/runtime/knowledge/patterns/be/api.yaml +7 -24
- package/runtime/knowledge/patterns/be/cli.yaml +3 -12
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
- package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
- package/runtime/modules/kernel/failure-codes.yaml +1 -41
- package/runtime/modules/kernel/removed-vocabulary.yaml +268 -0
- package/runtime/modules/models/registry.yaml +25 -359
- package/runtime/modules/models/runtimes.yaml +126 -265
- package/runtime/modules/models/tiers.yaml +66 -0
- package/runtime/scripts/api/fs/claim-file.mjs +30 -0
- package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
- package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
- package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
- package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
- package/runtime/scripts/api/git/lib.mjs +34 -1
- package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
- package/runtime/scripts/api/process/run-program.mjs +2 -1
- package/runtime/scripts/api/sops/lib.mjs +198 -120
- package/runtime/scripts/hfs/allows.mjs +3 -3
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
- package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
- package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
- package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
- package/runtime/scripts/hfs/architecture/config.mjs +95 -80
- package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
- package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
- package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
- package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
- package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
- package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
- package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
- package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
- package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
- package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
- package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
- package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
- package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
- package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
- package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
- package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
- package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
- package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
- package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
- package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
- package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
- package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
- package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
- package/runtime/scripts/hfs/architecture/index.mjs +164 -104
- package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
- package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
- package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
- package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
- package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
- package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
- package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
- package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
- package/runtime/scripts/hfs/architecture/required-files.mjs +121 -70
- package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
- package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
- package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
- package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
- package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
- package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
- package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
- package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
- package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
- package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
- package/runtime/scripts/hfs/architecture.mjs +3 -4
- package/runtime/scripts/hfs/catalog-once.mjs +15 -0
- package/runtime/scripts/hfs/check.mjs +63 -71
- package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
- package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
- package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
- package/runtime/scripts/hfs/edition-slots.mjs +5 -3
- package/runtime/scripts/hfs/linear-text.mjs +30 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
- package/runtime/scripts/hfs/path-findings.mjs +58 -38
- package/runtime/scripts/hfs/pin-findings.mjs +31 -0
- package/runtime/scripts/hfs/repo-identity.mjs +21 -4
- package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
- package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
- package/runtime/scripts/hfs/rules/cli.mjs +5 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
- package/runtime/scripts/hfs/rules/contract.mjs +35 -27
- package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
- package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
- package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
- package/runtime/scripts/hfs/rules/database.mjs +36 -23
- package/runtime/scripts/hfs/rules/deps.mjs +64 -32
- package/runtime/scripts/hfs/rules/docker.mjs +113 -65
- package/runtime/scripts/hfs/rules/edition.mjs +118 -111
- package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
- package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
- package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
- package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
- package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
- package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
- package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
- package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
- package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
- package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
- package/runtime/scripts/hfs/rules/saga.mjs +128 -61
- package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
- package/runtime/scripts/hfs/rules/services.mjs +34 -16
- package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
- package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
- package/runtime/scripts/hfs/secret.mjs +59 -48
- package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
- package/runtime/scripts/hfs/slot-classify.mjs +65 -0
- package/runtime/scripts/hfs/slot-errors.mjs +11 -0
- package/runtime/scripts/hfs/slot-imports.mjs +76 -0
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
- package/runtime/scripts/hfs/slot-match.mjs +153 -0
- package/runtime/scripts/hfs/slot-path.mjs +8 -0
- package/runtime/scripts/hfs/slot-required.mjs +76 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
- package/runtime/scripts/hfs/slots.mjs +74 -695
- package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
- package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
- package/runtime/scripts/hfs/tree.mjs +19 -14
- package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
- package/runtime/scripts/hfs/view.mjs +1 -1
- package/runtime/scripts/lib/dockerfile.mjs +56 -33
- package/runtime/scripts/lib/env.mjs +10 -0
- package/runtime/scripts/lib/event-contract.mjs +17 -13
- package/runtime/scripts/lib/git.mjs +2 -2
- package/runtime/scripts/lib/graphql-contract.mjs +158 -322
- package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
- package/runtime/scripts/lib/i18n.mjs +3 -2
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +3 -3
- package/runtime/scripts/lib/list.mjs +8 -1
- package/runtime/scripts/lib/path-key.mjs +38 -5
- package/runtime/scripts/lib/pid-alive.mjs +7 -0
- package/runtime/scripts/lib/regex.mjs +1 -1
- package/runtime/scripts/lib/same-text.mjs +1 -1
- package/runtime/scripts/lib/secret-patterns.mjs +6 -2
- package/runtime/scripts/lib/sleep-sync.mjs +2 -2
- package/runtime/scripts/lib/sops-envelope.mjs +95 -48
- package/runtime/scripts/lib/stack-services.mjs +1 -2
- package/runtime/scripts/lib/ts-ast.mjs +9 -0
- package/runtime/scripts/lib/walk.mjs +6 -1
- package/runtime/scripts/lib/yaml-cached.mjs +14 -0
- package/scaffold/add-cli-lite.mjs +2 -1
- package/scaffold/add-table.mjs +2 -2
- package/scaffold/add.mjs +3 -2
- package/scaffold/app.mjs +4 -2
- package/scaffold/edition-gate.mjs +25 -25
- package/scaffold/lite-exports.mjs +2 -1
- package/scaffold/service.mjs +5 -4
- package/sync/hygiene.mjs +15 -9
- package/sync/index.mjs +7 -3
- package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
- package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
- package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
- package/templates/app/ci-workflows/sonar-steps.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
- package/templates/app/starciwork.gitignore +1 -1
- package/templates/be/image/api/Dockerfile +1 -1
- package/templates/be/image/cli/Dockerfile +1 -1
- package/templates/be/image/worker/Dockerfile +1 -1
- package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
- package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
- package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
- package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
- package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
- package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
- package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
- package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
- package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
- package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
- package/templates/fe/image/next/Dockerfile +2 -1
- package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
- package/upgrade/index.mjs +9 -2
- package/runtime/engine/admission.mjs +0 -308
- package/runtime/engine/canonical-json.mjs +0 -10
- package/runtime/engine/db/blob.mjs +0 -315
- package/runtime/engine/db/ledger-paths.mjs +0 -83
- package/runtime/engine/db/ledger.mjs +0 -1118
- package/runtime/engine/db/machine-connection.mjs +0 -135
- package/runtime/engine/db/machine-schema.mjs +0 -84
- package/runtime/engine/db/machine.mjs +0 -1367
- package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
- package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
- package/runtime/engine/db/provider-reservations.mjs +0 -101
- package/runtime/engine/digest.mjs +0 -16
- package/runtime/engine/refuse.mjs +0 -11
- package/runtime/modules/ops/_labels.yaml +0 -53
- package/runtime/scripts/api/node/lib.mjs +0 -14
- package/runtime/scripts/api/node/spawn-node.mjs +0 -6
- package/runtime/scripts/api/process/lib.mjs +0 -111
- package/runtime/scripts/api/process/owned-process.mjs +0 -78
- package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
- package/runtime/scripts/connectors/lib.mjs +0 -488
- package/runtime/scripts/lib/clip.mjs +0 -19
- package/runtime/scripts/lib/display-names.mjs +0 -259
- package/runtime/scripts/lib/example-refs.mjs +0 -158
- package/runtime/scripts/lib/json-schema.mjs +0 -52
- package/runtime/scripts/lib/process-identity.mjs +0 -6
- package/runtime/scripts/lib/read-yaml.mjs +0 -18
- package/runtime/scripts/lib/redact.mjs +0 -161
- package/runtime/scripts/lib/sleep.mjs +0 -7
- package/runtime/scripts/lib/source-phrases.mjs +0 -34
- package/runtime/scripts/lib/sqlite.mjs +0 -21
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { machineKit, pascal, upperSnake } from './machine-ast.mjs';
|
|
4
4
|
import { locateDeclaration } from '../slots.mjs';
|
|
5
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* R84 `connection-map` (BE_CONNECTION_DUPLICATE), folding RED01 and RED02. One physical database is one connection, one
|
|
@@ -33,191 +34,282 @@ const SOURCE_CALLS = new Set(['getDataSourceToken', 'InjectDataSource']);
|
|
|
33
34
|
const ENV_KEY = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/u;
|
|
34
35
|
const INJECTOR_NAME = /^Inject[A-Za-z0-9]*EntityManager$/u;
|
|
35
36
|
|
|
37
|
+
const exportedInjectorName = (ts, kit, node) => {
|
|
38
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && ts.isVariableStatement(node.parent?.parent) && kit.isExported(node.parent.parent)) return node.name.text;
|
|
39
|
+
if (ts.isFunctionDeclaration(node) && kit.isExported(node) && node.name) return node.name.text;
|
|
40
|
+
return null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const ENV_ASSIGNMENT = /^(?:export\s+)?([A-Za-z_]\w*)\s*=/u;
|
|
44
|
+
const LINE_BREAK = /[\n\r\u2028\u2029]/u;
|
|
45
|
+
|
|
46
|
+
// One `KEY=value` line: a value holding a line break after its first character is not an assignment.
|
|
47
|
+
function parseEnvLine(raw) {
|
|
48
|
+
const line = raw.trim();
|
|
49
|
+
if (!line || line.startsWith('#')) return null;
|
|
50
|
+
const match = ENV_ASSIGNMENT.exec(line);
|
|
51
|
+
if (!match) return null;
|
|
52
|
+
let value = line.slice(match[0].length).trim();
|
|
53
|
+
if (LINE_BREAK.test(value)) return null;
|
|
54
|
+
if (/^(['"]).*\1$/u.test(value)) value = value.slice(1, -1);
|
|
55
|
+
return [match[1], value.trim()];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readEnvFile(file, merged) {
|
|
59
|
+
let text;
|
|
60
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return; }
|
|
61
|
+
for (const raw of text.split(/\r?\n/u)) {
|
|
62
|
+
const parsed = parseEnvLine(raw);
|
|
63
|
+
if (parsed) merged.set(parsed[0], parsed[1]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
36
67
|
function readEnvFiles(target) {
|
|
37
68
|
const merged = new Map();
|
|
38
|
-
const readFile = file => {
|
|
39
|
-
let text;
|
|
40
|
-
try { text = fs.readFileSync(file, 'utf8'); } catch { return; }
|
|
41
|
-
for (const raw of text.split(/\r?\n/u)) {
|
|
42
|
-
const line = raw.trim();
|
|
43
|
-
if (!line || line.startsWith('#')) continue;
|
|
44
|
-
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/u.exec(line);
|
|
45
|
-
if (!match) continue;
|
|
46
|
-
let value = match[2].trim();
|
|
47
|
-
if (/^(['"]).*\1$/u.test(value)) value = value.slice(1, -1);
|
|
48
|
-
merged.set(match[1], value.trim());
|
|
49
|
-
}
|
|
50
|
-
};
|
|
51
69
|
let stat;
|
|
52
70
|
try { stat = fs.statSync(target); } catch { return null; }
|
|
53
|
-
if (stat.isFile())
|
|
71
|
+
if (stat.isFile()) readEnvFile(target, merged);
|
|
54
72
|
else for (const entry of fs.readdirSync(target, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
55
|
-
if (entry.isFile() && !entry.name.toLowerCase().endsWith('.md'))
|
|
73
|
+
if (entry.isFile() && !entry.name.toLowerCase().endsWith('.md')) readEnvFile(path.join(target, entry.name), merged);
|
|
56
74
|
}
|
|
57
75
|
return merged;
|
|
58
76
|
}
|
|
59
77
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const kit = machineKit(input);
|
|
63
|
-
const { ts } = kit;
|
|
64
|
-
const declared = config.hfs.repo.connections ?? [];
|
|
65
|
-
const byName = new Map(declared.map(connection => [connection.name, connection]));
|
|
66
|
-
const violations = [];
|
|
67
|
-
const report = (rel, message, extra = {}) => violations.push({ ruleId: RULE, path: rel, message, ...extra });
|
|
68
|
-
const decoratorsFile = name => `${DATABASE_DIR}/${name}.decorators.ts`;
|
|
69
|
-
const databaseFiles = [...graph.files.values()].filter(file => file.rel.startsWith(`${DATABASE_DIR}/`));
|
|
78
|
+
const exportsVariable = (ts, kit, item, name) => ts.isVariableStatement(item) && kit.isExported(item)
|
|
79
|
+
&& item.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name);
|
|
70
80
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
const checkConnectionFile = (connection, kit, ts, report) => {
|
|
82
|
+
const connectionRel = `${DATABASE_DIR}/${connection.name}.connection.ts`;
|
|
83
|
+
const constant = `${upperSnake(connection.name)}_CONNECTION`;
|
|
84
|
+
const connectionFile = kit.graphFile(connectionRel);
|
|
85
|
+
if (!connectionFile) {
|
|
86
|
+
report(connectionRel, `hfs.json declares connection ${connection.name} but ${connectionRel} does not exist; declare \`export const ${constant} = "${connection.name}"\` there.`, { connection: connection.name });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const checker = kit.checkerOf(connectionFile.sourceFile);
|
|
90
|
+
const statement = connectionFile.sourceFile.statements.find(item => exportsVariable(ts, kit, item, constant));
|
|
91
|
+
const initializer = statement?.declarationList.declarations.find(declaration => declaration.name.text === constant)?.initializer;
|
|
92
|
+
if (!statement || kit.stringValue(checker, initializer) !== connection.name) {
|
|
93
|
+
report(connectionRel, `${connectionRel} must export \`${constant} = "${connection.name}"\` (the connection name declared in hfs.json).`, { connection: connection.name });
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const checkInjectorFile = (connection, kit, ts, report, decoratorsFile) => {
|
|
98
|
+
const decorators = kit.graphFile(decoratorsFile(connection.name));
|
|
99
|
+
const injector = `Inject${pascal(connection.name)}EntityManager`;
|
|
100
|
+
const exported = decorators?.sourceFile.statements.some(item => exportsVariable(ts, kit, item, injector)
|
|
101
|
+
|| (ts.isFunctionDeclaration(item) && kit.isExported(item) && item.name?.text === injector));
|
|
102
|
+
if (!exported) report(decoratorsFile(connection.name), `Connection ${connection.name} needs ${decoratorsFile(connection.name)} exporting ${injector}, the one injector of its shared EntityManager.`, { connection: connection.name });
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const isProcessEnvAccess = (ts, node) => ts.isPropertyAccessExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'env'
|
|
106
|
+
&& ts.isIdentifier(node.expression.expression) && node.expression.expression.text === 'process';
|
|
107
|
+
|
|
108
|
+
const configEnvKey = (ts, node) => {
|
|
109
|
+
if (ts.isStringLiteralLike(node) && ENV_KEY.test(node.text)) return node.text;
|
|
110
|
+
if (isProcessEnvAccess(ts, node) && ENV_KEY.test(node.name.text)) return node.name.text;
|
|
111
|
+
return null;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const checkConfigFile = (connection, declared, kit, ts, report, configRel, configFile) => {
|
|
115
|
+
let readsSchema = false;
|
|
116
|
+
kit.walk(configFile.sourceFile, node => {
|
|
117
|
+
const key = configEnvKey(ts, node);
|
|
118
|
+
if (key === null) return true;
|
|
119
|
+
if (key === `${connection.envPrefix}_SCHEMA`) readsSchema = true;
|
|
120
|
+
const own = key.startsWith(`${connection.envPrefix}_`);
|
|
121
|
+
const foreign = declared.some(other => other !== connection && key.startsWith(`${other.envPrefix}_`));
|
|
122
|
+
if (!own || foreign) {
|
|
123
|
+
report(configRel, `${configRel} reads ${key}; the config of connection ${connection.name} reads only ${connection.envPrefix}_* keys.`, { ...kit.at(configRel, configFile.sourceFile, node), connection: connection.name });
|
|
85
124
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
125
|
+
return false;
|
|
126
|
+
});
|
|
127
|
+
if (connection.isolation === 'schema' && !readsSchema) report(configRel, `Connection ${connection.name} is isolated as a schema of a shared database (hfs.json isolation), so ${configRel} must read ${connection.envPrefix}_SCHEMA and hand it to the data source; splitting the context out into its own database is then an env change only.`, { connection: connection.name });
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const checkDeclaredConnections = (declared, kit, ts, report, decoratorsFile) => {
|
|
131
|
+
for (const connection of declared) {
|
|
132
|
+
checkConnectionFile(connection, kit, ts, report);
|
|
133
|
+
checkInjectorFile(connection, kit, ts, report, decoratorsFile);
|
|
92
134
|
const configRel = `${DATABASE_DIR}/${connection.name}.config.ts`;
|
|
93
135
|
const configFile = kit.graphFile(configRel);
|
|
94
136
|
if (!configFile) {
|
|
95
137
|
report(configRel, `Connection ${connection.name} needs ${configRel}, the one place its ${connection.envPrefix}_* keys are read.`, { connection: connection.name });
|
|
96
138
|
continue;
|
|
97
139
|
}
|
|
98
|
-
|
|
99
|
-
kit.walk(configFile.sourceFile, node => {
|
|
100
|
-
let key = null;
|
|
101
|
-
if (ts.isStringLiteralLike(node) && ENV_KEY.test(node.text)) key = node.text;
|
|
102
|
-
else if (ts.isPropertyAccessExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'env'
|
|
103
|
-
&& ts.isIdentifier(node.expression.expression) && node.expression.expression.text === 'process' && ENV_KEY.test(node.name.text)) key = node.name.text;
|
|
104
|
-
if (key === null) return true;
|
|
105
|
-
if (key === `${connection.envPrefix}_SCHEMA`) readsSchema = true;
|
|
106
|
-
const own = key.startsWith(`${connection.envPrefix}_`);
|
|
107
|
-
const foreign = declared.find(other => other !== connection && key.startsWith(`${other.envPrefix}_`));
|
|
108
|
-
if (!own || foreign) {
|
|
109
|
-
report(configRel, `${configRel} reads ${key}; the config of connection ${connection.name} reads only ${connection.envPrefix}_* keys.`, { ...kit.at(configRel, configFile.sourceFile, node), connection: connection.name });
|
|
110
|
-
}
|
|
111
|
-
return false;
|
|
112
|
-
});
|
|
113
|
-
if (connection.isolation === 'schema' && !readsSchema) report(configRel, `Connection ${connection.name} is isolated as a schema of a shared database (hfs.json isolation), so ${configRel} must read ${connection.envPrefix}_SCHEMA and hand it to the data source; splitting the context out into its own database is then an env change only.`, { connection: connection.name });
|
|
140
|
+
checkConfigFile(connection, declared, kit, ts, report, configRel, configFile);
|
|
114
141
|
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const reportUndeclaredDatabaseFiles = (databaseFiles, byName, report) => {
|
|
115
145
|
for (const file of databaseFiles) {
|
|
116
146
|
const match = /^(.+)\.(connection|decorators|config)\.ts$/u.exec(path.posix.basename(file.rel));
|
|
117
147
|
if (match && path.posix.dirname(file.rel) === DATABASE_DIR && !byName.has(match[1])) {
|
|
118
148
|
report(file.rel, `${file.rel} belongs to connection ${match[1]}, which hfs.json does not declare; declare the database in hfs.json connections or delete the file.`, { connection: match[1] });
|
|
119
149
|
}
|
|
120
150
|
}
|
|
151
|
+
};
|
|
121
152
|
|
|
122
|
-
|
|
153
|
+
const inspectManagerToken = (file, checker, node, binding, state) => {
|
|
154
|
+
const { kit, byName, decoratorsFile, report, perDecorators } = state;
|
|
155
|
+
const value = kit.stringValue(checker, node.arguments[0]);
|
|
156
|
+
const home = value === null ? null : decoratorsFile(value);
|
|
157
|
+
if (value === null || !byName.has(value)) {
|
|
158
|
+
const target = value === null ? 'a connection that cannot be resolved to a constant' : `connection ${value}, which hfs.json does not declare`;
|
|
159
|
+
report(file.rel, `${binding.name}() names ${target}; only the declared connections have an entity manager token.`, kit.at(file.rel, file.sourceFile, node));
|
|
160
|
+
} else if (file.rel !== home) {
|
|
161
|
+
report(file.rel, `${binding.name}(${value}) belongs in ${home} only; inject the shared manager with Inject${pascal(value)}EntityManager() instead of a second path to connection ${value}.`, { ...kit.at(file.rel, file.sourceFile, node), connection: value });
|
|
162
|
+
} else {
|
|
163
|
+
perDecorators.set(value, (perDecorators.get(value) ?? 0) + 1);
|
|
164
|
+
if (perDecorators.get(value) > 1) report(file.rel, `${home} resolves connection ${value} more than once; one connection has one injector.`, { ...kit.at(file.rel, file.sourceFile, node), connection: value });
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const inspectDataSourceCall = (file, node, binding, state) => {
|
|
169
|
+
const allowed = file.rel.startsWith(`${DATABASE_DIR}/`) || state.slotIs(file, 'be.app.cli') || state.slotIs(file, 'be.cli') || state.slotIs(file, 'be.tests.fixtures');
|
|
170
|
+
if (!allowed) state.report(file.rel, `${binding.name}() reaches the DataSource outside platform/database and the cli; inject the shared EntityManager of the connection instead.`, state.kit.at(file.rel, file.sourceFile, node));
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const inspectTypeormCall = (file, checker, node, state) => {
|
|
174
|
+
const binding = state.kit.importBinding(checker, node.expression);
|
|
175
|
+
if (binding?.module === TYPEORM && MANAGER_CALLS.has(binding.name)) inspectManagerToken(file, checker, node, binding, state);
|
|
176
|
+
else if (binding?.module === TYPEORM && SOURCE_CALLS.has(binding.name)) inspectDataSourceCall(file, node, binding, state);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const inspectInjectorExport = (file, node, state) => {
|
|
180
|
+
const { ts, kit, byName, decoratorsFile, report } = state;
|
|
181
|
+
const exportedName = exportedInjectorName(ts, kit, node);
|
|
182
|
+
if (!exportedName || !INJECTOR_NAME.test(exportedName)) return;
|
|
183
|
+
const home = [...byName.keys()].find(name => `Inject${pascal(name)}EntityManager` === exportedName);
|
|
184
|
+
if (!home || file.rel !== decoratorsFile(home)) {
|
|
185
|
+
report(file.rel, `${exportedName} is exported here, but the only injector of an entity manager is Inject<Conn>EntityManager in src/modules/platform/database/<conn>.decorators.ts, one per declared connection.`, kit.at(file.rel, file.sourceFile, node));
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const inspectManagerNode = (file, checker, node, state) => {
|
|
190
|
+
if (state.ts.isCallExpression(node)) inspectTypeormCall(file, checker, node, state);
|
|
191
|
+
inspectInjectorExport(file, node, state);
|
|
192
|
+
return true;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const checkManagerCalls = (graph, state) => {
|
|
123
196
|
const perDecorators = new Map();
|
|
197
|
+
const walkState = { ...state, perDecorators, slotIs: (file, id) => file.slot === id };
|
|
124
198
|
for (const file of graph.files.values()) {
|
|
125
|
-
const checker = kit.checkerOf(file.sourceFile);
|
|
126
|
-
|
|
127
|
-
kit.walk(file.sourceFile, node => {
|
|
128
|
-
if (ts.isCallExpression(node)) {
|
|
129
|
-
const binding = kit.importBinding(checker, node.expression);
|
|
130
|
-
if (binding?.module === TYPEORM && MANAGER_CALLS.has(binding.name)) {
|
|
131
|
-
const value = kit.stringValue(checker, node.arguments[0]);
|
|
132
|
-
const home = value === null ? null : decoratorsFile(value);
|
|
133
|
-
if (value === null || !byName.has(value)) {
|
|
134
|
-
report(file.rel, `${binding.name}() names ${value === null ? 'a connection that cannot be resolved to a constant' : `connection ${value}, which hfs.json does not declare`}; only the declared connections have an entity manager token.`, kit.at(file.rel, file.sourceFile, node));
|
|
135
|
-
} else if (file.rel !== home) {
|
|
136
|
-
report(file.rel, `${binding.name}(${value}) belongs in ${home} only; inject the shared manager with Inject${pascal(value)}EntityManager() instead of a second path to connection ${value}.`, { ...kit.at(file.rel, file.sourceFile, node), connection: value });
|
|
137
|
-
} else {
|
|
138
|
-
perDecorators.set(value, (perDecorators.get(value) ?? 0) + 1);
|
|
139
|
-
if (perDecorators.get(value) > 1) report(file.rel, `${home} resolves connection ${value} more than once; one connection has one injector.`, { ...kit.at(file.rel, file.sourceFile, node), connection: value });
|
|
140
|
-
}
|
|
141
|
-
} else if (binding?.module === TYPEORM && SOURCE_CALLS.has(binding.name)) {
|
|
142
|
-
const allowed = file.rel.startsWith(`${DATABASE_DIR}/`) || slotIs('be.app.cli') || slotIs('be.cli') || slotIs('be.tests.fixtures');
|
|
143
|
-
if (!allowed) report(file.rel, `${binding.name}() reaches the DataSource outside platform/database and the cli; inject the shared EntityManager of the connection instead.`, kit.at(file.rel, file.sourceFile, node));
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
const exportedName = (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && ts.isVariableStatement(node.parent?.parent) && kit.isExported(node.parent.parent)) ? node.name.text
|
|
147
|
-
: (ts.isFunctionDeclaration(node) && kit.isExported(node) && node.name ? node.name.text : null);
|
|
148
|
-
if (exportedName && INJECTOR_NAME.test(exportedName)) {
|
|
149
|
-
const home = [...byName.keys()].find(name => `Inject${pascal(name)}EntityManager` === exportedName);
|
|
150
|
-
if (!home || file.rel !== decoratorsFile(home)) {
|
|
151
|
-
report(file.rel, `${exportedName} is exported here, but the only injector of an entity manager is Inject<Conn>EntityManager in src/modules/platform/database/<conn>.decorators.ts, one per declared connection.`, kit.at(file.rel, file.sourceFile, node));
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
return true;
|
|
155
|
-
});
|
|
199
|
+
const checker = state.kit.checkerOf(file.sourceFile);
|
|
200
|
+
state.kit.walk(file.sourceFile, node => inspectManagerNode(file, checker, node, walkState));
|
|
156
201
|
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** The receiver of a `<receiver>.register(...)` call that `node` is, or null. */
|
|
205
|
+
const registerReceiver = (ts, node) => {
|
|
206
|
+
if (!ts.isCallExpression(node)) return null;
|
|
207
|
+
const callee = node.expression;
|
|
208
|
+
return ts.isPropertyAccessExpression(callee) && callee.name.text === 'register' ? callee.expression : null;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const isPropertyName = (ts, node) => (ts.isPropertyAssignment(node.parent ?? {}) || ts.isPropertyAccessExpression(node.parent ?? {})) && node.parent.name === node;
|
|
212
|
+
|
|
213
|
+
const passConnections = (argument, scope) => {
|
|
214
|
+
const { ts, kit, checker, byName, passed, root, app, report } = scope;
|
|
215
|
+
kit.walk(argument, inner => {
|
|
216
|
+
if (isPropertyName(ts, inner)) return true;
|
|
217
|
+
if (!(ts.isIdentifier(inner) || ts.isStringLiteralLike(inner) || ts.isPropertyAccessExpression(inner))) return true;
|
|
218
|
+
const value = kit.stringValue(checker, inner);
|
|
219
|
+
if (value === null || !byName.has(value)) return true;
|
|
220
|
+
passed.set(value, (passed.get(value) ?? 0) + 1);
|
|
221
|
+
if (passed.get(value) > 1) report(root.rel, `App ${app.name} passes connection ${value} to the database module registration more than once; one connection is registered once per app.`, { ...kit.at(root.rel, root.sourceFile, inner), connection: value, app: app.name });
|
|
222
|
+
return false;
|
|
223
|
+
});
|
|
224
|
+
};
|
|
157
225
|
|
|
158
|
-
|
|
226
|
+
const checkAppRegistrations = (config, kit, ts, byName, report) => {
|
|
159
227
|
let registrations = 0;
|
|
160
228
|
for (const app of config.apps) {
|
|
161
229
|
const root = kit.appRoot(app.name);
|
|
162
230
|
if (!root) continue;
|
|
163
231
|
const checker = kit.checkerOf(root.sourceFile);
|
|
164
|
-
const
|
|
232
|
+
const scope = { ts, kit, checker, byName, passed: new Map(), root, app, report };
|
|
165
233
|
kit.walk(root.sourceFile, node => {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
234
|
+
const receiver = registerReceiver(ts, node);
|
|
235
|
+
if (!receiver) return;
|
|
236
|
+
const owner = kit.declarationsOf(checker, receiver).map(kit.ownerOfDeclaration).find(Boolean);
|
|
237
|
+
if (owner?.tier !== 'platform' || owner.name !== 'database') return;
|
|
169
238
|
registrations += 1;
|
|
170
|
-
for (const argument of node.arguments)
|
|
171
|
-
kit.walk(argument, inner => {
|
|
172
|
-
if (ts.isPropertyAssignment(inner.parent ?? {}) && inner.parent.name === inner) return true;
|
|
173
|
-
if (ts.isPropertyAccessExpression(inner.parent ?? {}) && inner.parent.name === inner) return true;
|
|
174
|
-
if (!(ts.isIdentifier(inner) || ts.isStringLiteralLike(inner) || ts.isPropertyAccessExpression(inner))) return true;
|
|
175
|
-
const value = kit.stringValue(checker, inner);
|
|
176
|
-
if (value === null || !byName.has(value)) return true;
|
|
177
|
-
passed.set(value, (passed.get(value) ?? 0) + 1);
|
|
178
|
-
if (passed.get(value) > 1) report(root.rel, `App ${app.name} passes connection ${value} to the database module registration more than once; one connection is registered once per app.`, { ...kit.at(root.rel, root.sourceFile, inner), connection: value, app: app.name });
|
|
179
|
-
return false;
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
return true;
|
|
239
|
+
for (const argument of node.arguments) passConnections(argument, scope);
|
|
183
240
|
});
|
|
184
241
|
}
|
|
242
|
+
return registrations;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const listStackEnvs = stacksRoot => {
|
|
246
|
+
try { return fs.readdirSync(stacksRoot, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => entry.name).sort(byCodeUnit); } catch { return []; }
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// The resolved database of one connection in one stack env, or null while its host, port or name is unset.
|
|
250
|
+
const resolveStackConnection = (kit, connection, values) => {
|
|
251
|
+
const configFile = kit.graphFile(`${DATABASE_DIR}/${connection.name}.config.ts`);
|
|
252
|
+
const nameKey = configFile?.sourceFile.text.includes(`${connection.envPrefix}_DATABASE`) ? `${connection.envPrefix}_DATABASE` : `${connection.envPrefix}_NAME`;
|
|
253
|
+
const host = values.get(`${connection.envPrefix}_HOST`);
|
|
254
|
+
const port = values.get(`${connection.envPrefix}_PORT`);
|
|
255
|
+
const database = values.get(nameKey);
|
|
256
|
+
if (!host || !port || !database) return null;
|
|
257
|
+
const schema = connection.isolation === 'schema' ? (values.get(`${connection.envPrefix}_SCHEMA`) ?? '') : null;
|
|
258
|
+
return { host, port, database, triple: `${host.toLowerCase()}|${port}|${database}`, schema };
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// True when the env resolves at least one connection; false when it is skipped.
|
|
262
|
+
const checkStackEnv = (config, kit, declared, report, stacksRoot, env) => {
|
|
263
|
+
// Relative to the side folder the machine judges (`../.starcistacks/...` for a side): the app-relative path once the side is prefixed.
|
|
264
|
+
const rel = path.relative(config.root, path.join(stacksRoot, env, 'runtime', 'env')).split(path.sep).join('/');
|
|
265
|
+
const values = readEnvFiles(path.join(stacksRoot, env, 'runtime', 'env'));
|
|
266
|
+
if (!values || values.size === 0) return false;
|
|
267
|
+
const seen = new Map(); // host|port|database -> [{name, isolation, schema}]
|
|
268
|
+
let resolved = 0;
|
|
269
|
+
for (const connection of declared) {
|
|
270
|
+
const found = resolveStackConnection(kit, connection, values);
|
|
271
|
+
if (!found) continue;
|
|
272
|
+
resolved += 1;
|
|
273
|
+
const { host, port, database, triple, schema } = found;
|
|
274
|
+
const sharing = seen.get(triple) ?? [];
|
|
275
|
+
// Two contexts may share one database only as distinct, named schemas; a context that is its own database shares it with nobody.
|
|
276
|
+
const clash = sharing.find(other => schema === null || other.schema === null || other.schema === schema);
|
|
277
|
+
if (clash) {
|
|
278
|
+
const schemaDetail = schema !== null && clash.schema === schema ? `, schema ${schema || '(none)'}` : '';
|
|
279
|
+
report(rel, `Connections ${clash.name} and ${connection.name} resolve to the same database (${host}:${port}/${database}${schemaDetail}) in stack ${env}; contexts share a database only as distinct schemas (isolation schema, a different ${connection.envPrefix}_SCHEMA), otherwise one physical database is one connection.`, { connection: connection.name, env });
|
|
280
|
+
}
|
|
281
|
+
sharing.push({ name: connection.name, schema });
|
|
282
|
+
seen.set(triple, sharing);
|
|
283
|
+
}
|
|
284
|
+
return resolved > 0;
|
|
285
|
+
};
|
|
185
286
|
|
|
186
|
-
|
|
287
|
+
const checkStackConnections = (config, kit, declared, report) => {
|
|
187
288
|
let stacksChecked = 0;
|
|
188
289
|
let stacksSkipped = 0;
|
|
189
290
|
// .starcistacks sits at the app root: the side folder the machine judges reads its app's tree (locateDeclaration).
|
|
190
291
|
const stacksRoot = path.join(locateDeclaration(config.root).appRoot, '.starcistacks');
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
for (const env of envs) {
|
|
194
|
-
// Relative to the side folder the machine judges (`../.starcistacks/...` for a side): the app-relative path once the side is prefixed.
|
|
195
|
-
const rel = path.relative(config.root, path.join(stacksRoot, env, 'runtime', 'env')).split(path.sep).join('/');
|
|
196
|
-
const values = readEnvFiles(path.join(stacksRoot, env, 'runtime', 'env'));
|
|
197
|
-
if (!values || values.size === 0) { stacksSkipped += 1; continue; }
|
|
198
|
-
const seen = new Map(); // host|port|database -> [{name, isolation, schema}]
|
|
199
|
-
let resolved = 0;
|
|
200
|
-
for (const connection of declared) {
|
|
201
|
-
const configFile = kit.graphFile(`${DATABASE_DIR}/${connection.name}.config.ts`);
|
|
202
|
-
const nameKey = configFile?.sourceFile.text.includes(`${connection.envPrefix}_DATABASE`) ? `${connection.envPrefix}_DATABASE` : `${connection.envPrefix}_NAME`;
|
|
203
|
-
const host = values.get(`${connection.envPrefix}_HOST`);
|
|
204
|
-
const port = values.get(`${connection.envPrefix}_PORT`);
|
|
205
|
-
const database = values.get(nameKey);
|
|
206
|
-
if (!host || !port || !database) continue;
|
|
207
|
-
resolved += 1;
|
|
208
|
-
const triple = `${host.toLowerCase()}|${port}|${database}`;
|
|
209
|
-
const schema = connection.isolation === 'schema' ? (values.get(`${connection.envPrefix}_SCHEMA`) ?? '') : null;
|
|
210
|
-
const sharing = seen.get(triple) ?? [];
|
|
211
|
-
// Two contexts may share one database only as distinct, named schemas; a context that is its own database shares it with nobody.
|
|
212
|
-
const clash = sharing.find(other => schema === null || other.schema === null || other.schema === schema);
|
|
213
|
-
if (clash) {
|
|
214
|
-
report(rel, `Connections ${clash.name} and ${connection.name} resolve to the same database (${host}:${port}/${database}${schema !== null && clash.schema === schema ? `, schema ${schema || '(none)'}` : ''}) in stack ${env}; contexts share a database only as distinct schemas (isolation schema, a different ${connection.envPrefix}_SCHEMA), otherwise one physical database is one connection.`, { connection: connection.name, env });
|
|
215
|
-
}
|
|
216
|
-
sharing.push({ name: connection.name, schema });
|
|
217
|
-
seen.set(triple, sharing);
|
|
218
|
-
}
|
|
219
|
-
if (resolved) stacksChecked += 1; else stacksSkipped += 1;
|
|
292
|
+
for (const env of listStackEnvs(stacksRoot)) {
|
|
293
|
+
if (checkStackEnv(config, kit, declared, report, stacksRoot, env)) stacksChecked += 1; else stacksSkipped += 1;
|
|
220
294
|
}
|
|
295
|
+
return { stacksChecked, stacksSkipped };
|
|
296
|
+
};
|
|
221
297
|
|
|
298
|
+
export function checkConnectionMap(input) {
|
|
299
|
+
const { config, graph } = input;
|
|
300
|
+
const kit = machineKit(input);
|
|
301
|
+
const { ts } = kit;
|
|
302
|
+
const declared = config.hfs.repo.connections ?? [];
|
|
303
|
+
const byName = new Map(declared.map(connection => [connection.name, connection]));
|
|
304
|
+
const violations = [];
|
|
305
|
+
const report = (rel, message, extra = {}) => violations.push({ ruleId: RULE, path: rel, message, ...extra });
|
|
306
|
+
const decoratorsFile = name => `${DATABASE_DIR}/${name}.decorators.ts`;
|
|
307
|
+
const databaseFiles = [...graph.files.values()].filter(file => file.rel.startsWith(`${DATABASE_DIR}/`));
|
|
308
|
+
const state = { config, graph, kit, ts, declared, byName, violations, report, decoratorsFile };
|
|
309
|
+
checkDeclaredConnections(declared, kit, ts, report, decoratorsFile);
|
|
310
|
+
reportUndeclaredDatabaseFiles(databaseFiles, byName, report);
|
|
311
|
+
checkManagerCalls(graph, state);
|
|
312
|
+
const registrations = checkAppRegistrations(config, kit, ts, byName, report);
|
|
313
|
+
const { stacksChecked, stacksSkipped } = checkStackConnections(config, kit, declared, report);
|
|
222
314
|
return { violations, coverage: { status: 'checked', connections: declared.length, registrations, stacksChecked, stacksSkipped } };
|
|
223
315
|
}
|
|
@@ -35,24 +35,30 @@ function tokenOfArgument(kit, checker, argument) {
|
|
|
35
35
|
return { kind: 'unresolved', reason: `the token ${argument.text} does not resolve to a declaration`, node: argument };
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/** The injector
|
|
39
|
-
function
|
|
38
|
+
/** The first `injector(...)` call under a decorator declaration, or null. */
|
|
39
|
+
function injectorCallOf(kit, declaration) {
|
|
40
40
|
const { ts } = kit;
|
|
41
|
-
const head = decoratorCallee(ts, decorator);
|
|
42
|
-
if (!head || !INJECT_NAME.test(head.callee.text)) return null;
|
|
43
|
-
if (head.callee.text === 'Inject' && head.call?.arguments.length) return { ...tokenOfArgument(kit, checker, head.call.arguments[0]), decorator: head.callee.text, decoratorDeclaration: null, decoratorFile: null };
|
|
44
|
-
const declaration = kit.declarationsOf(checker, head.callee).find(item => ts.isVariableDeclaration(item) || ts.isFunctionDeclaration(item));
|
|
45
|
-
if (!declaration) return { kind: 'unresolved', reason: `${head.callee.text}() does not resolve to a declaration`, node: parameter, decorator: head.callee.text };
|
|
46
41
|
let injectorCall = null;
|
|
47
42
|
kit.walk(declaration, node => {
|
|
48
43
|
if (injectorCall) return false;
|
|
49
44
|
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
50
|
-
const
|
|
51
|
-
const binding = kit.importBinding(declarationChecker, node.expression);
|
|
45
|
+
const binding = kit.importBinding(kit.checkerOf(declaration.getSourceFile()), node.expression);
|
|
52
46
|
if ((binding?.name ?? node.expression.text) === 'injector') injectorCall = node;
|
|
53
47
|
}
|
|
54
48
|
return true;
|
|
55
49
|
});
|
|
50
|
+
return injectorCall;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The injector token of a custom `Inject<Thing>()` decorator, or an unresolved reason. */
|
|
54
|
+
function tokenOfInjectDecorator(kit, checker, parameter, decorator) {
|
|
55
|
+
const { ts } = kit;
|
|
56
|
+
const head = decoratorCallee(ts, decorator);
|
|
57
|
+
if (!head || !INJECT_NAME.test(head.callee.text)) return null;
|
|
58
|
+
if (head.callee.text === 'Inject' && head.call?.arguments.length) return { ...tokenOfArgument(kit, checker, head.call.arguments[0]), decorator: head.callee.text, decoratorDeclaration: null, decoratorFile: null };
|
|
59
|
+
const declaration = kit.declarationsOf(checker, head.callee).find(item => ts.isVariableDeclaration(item) || ts.isFunctionDeclaration(item));
|
|
60
|
+
if (!declaration) return { kind: 'unresolved', reason: `${head.callee.text}() does not resolve to a declaration`, node: parameter, decorator: head.callee.text };
|
|
61
|
+
const injectorCall = injectorCallOf(kit, declaration);
|
|
56
62
|
if (!injectorCall) return { kind: 'unresolved', reason: `${head.callee.text}() is not built with injector<T>(TOKEN)`, node: parameter, decorator: head.callee.text };
|
|
57
63
|
const declarationChecker = kit.checkerOf(declaration.getSourceFile());
|
|
58
64
|
return { ...tokenOfArgument(kit, declarationChecker, injectorCall.arguments[0]), decorator: head.callee.text, decoratorDeclaration: declaration, injectorCall };
|
|
@@ -19,12 +19,83 @@ const RULE = 'BE_CONTEXT_COUPLING';
|
|
|
19
19
|
const TYPEORM = 'typeorm';
|
|
20
20
|
const RELATIONS = new Set(['OneToOne', 'OneToMany', 'ManyToOne', 'ManyToMany']);
|
|
21
21
|
// The table identifier after the SQL keyword REFERENCES, optionally schema-qualified and quoted.
|
|
22
|
-
const REFERENCES = /\bREFERENCES\s+(?:"
|
|
22
|
+
const REFERENCES = /\bREFERENCES\s+(?:"?\w+"?\s*\.\s*)?"?(\w+)"?/giu;
|
|
23
|
+
|
|
24
|
+
const checkCrossContextImports = (graph, model, covered, counts, once) => {
|
|
25
|
+
for (const edge of graph.edges) {
|
|
26
|
+
const from = graph.files.get(edge.from);
|
|
27
|
+
const to = graph.files.get(edge.to);
|
|
28
|
+
if (!from?.owner || !to?.owner || from.owner.root === to.owner.root) continue;
|
|
29
|
+
const fromContext = model.contextOfFile(edge.from);
|
|
30
|
+
const toContext = model.contextOfFile(edge.to);
|
|
31
|
+
if (!fromContext || !toContext || fromContext === toContext) continue;
|
|
32
|
+
counts.imports += 1;
|
|
33
|
+
if (covered.has(`${edge.from}|${to.owner.root}`)) continue;
|
|
34
|
+
once(`${edge.from}|${edge.to}|${edge.line}`, { path: edge.from, line: edge.line, column: edge.column, message: `${from.owner.root} (context ${fromContext}) imports ${to.owner.root} (context ${toContext}); a context never imports another context's entities, services or SQL. Keep a local copy fed by its events, or read a projection.`, context: fromContext, targetContext: toContext });
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const relationTarget = (kit, model, argument) => {
|
|
39
|
+
const { ts } = kit;
|
|
40
|
+
const isFunction = argument && (ts.isArrowFunction(argument) || ts.isFunctionExpression(argument));
|
|
41
|
+
const target = isFunction && !ts.isBlock(argument.body) ? model.unparen(argument.body) : null;
|
|
42
|
+
return target && (ts.isIdentifier(target) || ts.isPropertyAccessExpression(target)) ? target : null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// The relation decorator (`@ManyToOne(() => Target)` imported from typeorm) a node is, with the checker that resolves it, else null.
|
|
46
|
+
const relationBinding = (kit, checker, node) => {
|
|
47
|
+
const { ts } = kit;
|
|
48
|
+
if (!ts.isDecorator(node) || !ts.isCallExpression(node.expression)) return null;
|
|
49
|
+
const binding = kit.importBinding(checker, node.expression.expression);
|
|
50
|
+
return binding?.module === TYPEORM && RELATIONS.has(binding.name) ? binding : null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const checkRelations = (scan, file, ownContext) => {
|
|
54
|
+
const { kit, model, counts, covered, once } = scan;
|
|
55
|
+
const { ts } = kit;
|
|
56
|
+
const checker = kit.checkerOf(file.sourceFile);
|
|
57
|
+
kit.walk(file.sourceFile, node => {
|
|
58
|
+
const binding = relationBinding(kit, checker, node);
|
|
59
|
+
if (!binding) return;
|
|
60
|
+
counts.relations += 1;
|
|
61
|
+
const target = relationTarget(kit, model, node.expression.arguments[0]);
|
|
62
|
+
if (!target) return;
|
|
63
|
+
const home = kit.declarationsOf(checker, ts.isPropertyAccessExpression(target) ? target.name : target).map(kit.ownerOfDeclaration).find(Boolean);
|
|
64
|
+
const targetContext = home ? model.contextOfCapability(home.root) : null;
|
|
65
|
+
if (!home || !targetContext || targetContext === ownContext) return;
|
|
66
|
+
covered.add(`${file.rel}|${home.root}`);
|
|
67
|
+
once(`${file.rel}|${node.getStart()}`, { ...kit.at(file.rel, file.sourceFile, node), message: `A ${binding.name} relation in ${file.owner.root} (context ${ownContext}) targets an entity of ${home.root} (context ${targetContext}); a relation never crosses a context. Keep the other context's id as a plain column and fill a local copy or a projection from its events.`, context: ownContext, targetContext });
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const stringText = (ts, node) => (ts.isTemplateExpression(node) ? node.head.text + node.templateSpans.map(span => ` ${span.literal.text}`).join('') : node.text);
|
|
72
|
+
|
|
73
|
+
const checkForeignKeys = (scan, file, ownContext) => {
|
|
74
|
+
const { kit, model, tables, counts, once } = scan;
|
|
75
|
+
const { ts } = kit;
|
|
76
|
+
kit.walk(file.sourceFile, node => {
|
|
77
|
+
if (!(ts.isStringLiteralLike(node) || ts.isTemplateExpression(node))) return;
|
|
78
|
+
for (const match of stringText(ts, node).matchAll(REFERENCES)) {
|
|
79
|
+
counts.foreignKeys += 1;
|
|
80
|
+
const entity = tables.get(match[1].toLowerCase());
|
|
81
|
+
const targetContext = entity ? model.contextOfCapability(entity.owner) : null;
|
|
82
|
+
if (!entity || !targetContext || targetContext === ownContext) continue;
|
|
83
|
+
once(`${file.rel}|${node.getStart()}|${match[1]}`, { ...kit.at(file.rel, file.sourceFile, node), message: `A foreign key in ${file.rel} references table ${entity.table} of ${entity.owner} (context ${targetContext}), but this migration belongs to context ${ownContext}; a foreign key never crosses a context. Keep the id as a plain column and validate it through the other context's API or a local copy fed by its events.`, context: ownContext, targetContext, table: entity.table });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const checkPersistenceFile = (scan, file) => {
|
|
89
|
+
if (file.slot !== 'be.persistence' || !file.owner) return;
|
|
90
|
+
const ownContext = scan.model.contextOfCapability(file.owner.root);
|
|
91
|
+
if (!ownContext) return;
|
|
92
|
+
if (path.posix.basename(file.rel).endsWith('.entity.ts')) checkRelations(scan, file, ownContext);
|
|
93
|
+
if (file.rel.slice(file.owner.root.length + 1).startsWith('persistence/migrations/')) checkForeignKeys(scan, file, ownContext);
|
|
94
|
+
};
|
|
23
95
|
|
|
24
96
|
export function checkContextCoupling(input) {
|
|
25
97
|
const { graph } = input;
|
|
26
98
|
const kit = machineKit(input);
|
|
27
|
-
const { ts, resolver } = kit;
|
|
28
99
|
const model = contextModelOf(kit, graph);
|
|
29
100
|
const { tables } = entitiesOf(kit, graph);
|
|
30
101
|
const violations = [];
|
|
@@ -32,57 +103,10 @@ export function checkContextCoupling(input) {
|
|
|
32
103
|
const once = (key, violation) => { if (!seen.has(key)) { seen.add(key); violations.push({ ruleId: RULE, ...violation }); } };
|
|
33
104
|
const counts = { relations: 0, foreignKeys: 0, imports: 0 };
|
|
34
105
|
const covered = new Set(); // `${fromRel}|${targetRoot}` pairs a relation already reported, so the import edge is not a second finding
|
|
106
|
+
const scan = { kit, model, tables, counts, covered, once };
|
|
35
107
|
|
|
36
|
-
for (const file of graph.files.values())
|
|
37
|
-
if (file.slot !== 'be.persistence' || !file.owner) continue;
|
|
38
|
-
const base = path.posix.basename(file.rel);
|
|
39
|
-
const ownContext = model.contextOfCapability(file.owner.root);
|
|
40
|
-
if (!ownContext) continue;
|
|
41
|
-
const checker = kit.checkerOf(file.sourceFile);
|
|
42
|
-
if (base.endsWith('.entity.ts')) {
|
|
43
|
-
kit.walk(file.sourceFile, node => {
|
|
44
|
-
if (!ts.isDecorator(node) || !ts.isCallExpression(node.expression)) return true;
|
|
45
|
-
const binding = kit.importBinding(checker, node.expression.expression);
|
|
46
|
-
if (binding?.module !== TYPEORM || !RELATIONS.has(binding.name)) return true;
|
|
47
|
-
counts.relations += 1;
|
|
48
|
-
const argument = node.expression.arguments[0];
|
|
49
|
-
const target = argument && (ts.isArrowFunction(argument) || ts.isFunctionExpression(argument)) && !ts.isBlock(argument.body) ? model.unparen(argument.body) : null;
|
|
50
|
-
if (!target || !(ts.isIdentifier(target) || ts.isPropertyAccessExpression(target))) return true;
|
|
51
|
-
const home = kit.declarationsOf(checker, ts.isPropertyAccessExpression(target) ? target.name : target).map(kit.ownerOfDeclaration).find(Boolean);
|
|
52
|
-
const targetContext = home ? model.contextOfCapability(home.root) : null;
|
|
53
|
-
if (!home || !targetContext || targetContext === ownContext) return true;
|
|
54
|
-
covered.add(`${file.rel}|${home.root}`);
|
|
55
|
-
once(`${file.rel}|${node.getStart()}`, { ...kit.at(file.rel, file.sourceFile, node), message: `A ${binding.name} relation in ${file.owner.root} (context ${ownContext}) targets an entity of ${home.root} (context ${targetContext}); a relation never crosses a context. Keep the other context's id as a plain column and fill a local copy or a projection from its events.`, context: ownContext, targetContext });
|
|
56
|
-
return true;
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
if (file.rel.slice(file.owner.root.length + 1).startsWith('persistence/migrations/')) {
|
|
60
|
-
kit.walk(file.sourceFile, node => {
|
|
61
|
-
if (!(ts.isStringLiteralLike(node) || ts.isTemplateExpression(node))) return true;
|
|
62
|
-
const text = ts.isTemplateExpression(node) ? node.head.text + node.templateSpans.map(span => ` ${span.literal.text}`).join('') : node.text;
|
|
63
|
-
for (const match of text.matchAll(REFERENCES)) {
|
|
64
|
-
counts.foreignKeys += 1;
|
|
65
|
-
const entity = tables.get(match[1].toLowerCase());
|
|
66
|
-
const targetContext = entity ? model.contextOfCapability(entity.owner) : null;
|
|
67
|
-
if (!entity || !targetContext || targetContext === ownContext) continue;
|
|
68
|
-
once(`${file.rel}|${node.getStart()}|${match[1]}`, { ...kit.at(file.rel, file.sourceFile, node), message: `A foreign key in ${file.rel} references table ${entity.table} of ${entity.owner} (context ${targetContext}), but this migration belongs to context ${ownContext}; a foreign key never crosses a context. Keep the id as a plain column and validate it through the other context's API or a local copy fed by its events.`, context: ownContext, targetContext, table: entity.table });
|
|
69
|
-
}
|
|
70
|
-
return true;
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
}
|
|
108
|
+
for (const file of graph.files.values()) checkPersistenceFile(scan, file);
|
|
74
109
|
|
|
75
|
-
|
|
76
|
-
const from = graph.files.get(edge.from);
|
|
77
|
-
const to = graph.files.get(edge.to);
|
|
78
|
-
if (!from?.owner || !to?.owner || from.owner.root === to.owner.root) continue;
|
|
79
|
-
const fromContext = model.contextOfFile(edge.from);
|
|
80
|
-
const toContext = model.contextOfFile(edge.to);
|
|
81
|
-
if (!fromContext || !toContext || fromContext === toContext) continue;
|
|
82
|
-
counts.imports += 1;
|
|
83
|
-
if (covered.has(`${edge.from}|${to.owner.root}`)) continue;
|
|
84
|
-
once(`${edge.from}|${edge.to}|${edge.line}`, { path: edge.from, line: edge.line, column: edge.column, message: `${from.owner.root} (context ${fromContext}) imports ${to.owner.root} (context ${toContext}); a context never imports another context's entities, services or SQL. Keep a local copy fed by its events, or read a projection.`, context: fromContext, targetContext: toContext });
|
|
85
|
-
}
|
|
86
|
-
void resolver;
|
|
110
|
+
checkCrossContextImports(graph, model, covered, counts, once);
|
|
87
111
|
return { violations, coverage: { status: 'checked', ...counts } };
|
|
88
112
|
}
|