@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
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
// (sides, app kinds) and refuses a manifest whole on any problem (HFS_MANIFEST_INVALID). Pure: every function takes a
|
|
5
5
|
// parsed manifest or slot and returns a list of problems in the words of modules/schemas/hfs-slots.schema.yaml.
|
|
6
6
|
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
7
|
-
import { stringList } from '../lib/list.mjs';
|
|
7
|
+
import { stringList, byCodeUnit } from '../lib/list.mjs';
|
|
8
8
|
|
|
9
9
|
export const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
10
10
|
export const NAME = /^[a-z][a-z0-9-]*$/;
|
|
11
|
-
|
|
11
|
+
const ENV_PREFIX =/^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/;
|
|
12
12
|
/** How a bounded context (a connection) is isolated: its own logical database, or its own schema of a shared one. */
|
|
13
13
|
const CONTEXT_ISOLATIONS = Object.freeze(["database", "schema"]);
|
|
14
14
|
/** The app kinds that may own a context: the services, never the migrate or cli apps that only run migrations. */
|
|
@@ -31,7 +31,7 @@ export function connectionShapeProblems(list, apps) {
|
|
|
31
31
|
const keys = (c) => isPlainObject(c) && NAME.test(String(c.name)) && ENV_PREFIX.test(String(c.envPrefix)) && NAME.test(String(c.owner)) && CONTEXT_ISOLATIONS.includes(c.isolation)
|
|
32
32
|
&& (c.provider === undefined || CONNECTION_PROVIDERS.includes(c.provider))
|
|
33
33
|
&& Object.keys(c).every((k) => ['name', 'envPrefix', 'owner', 'isolation', 'provider'].includes(k)) && ['name', 'envPrefix', 'owner', 'isolation'].every((k) => k in c);
|
|
34
|
-
if (!list
|
|
34
|
+
if (!list?.every(keys)) return [`connections must be a list of {name: kebab-case context name, envPrefix: UPPER_SNAKE prefix of its env keys, owner: the service app that owns the context, isolation: ${CONTEXT_ISOLATIONS.join(' | ')}, provider?: ${CONNECTION_PROVIDERS.join(' | ')}}`];
|
|
35
35
|
const owns = (c) => Array.isArray(apps) && apps.some((app) => isPlainObject(app) && app.name === c.owner && CONTEXT_OWNER_KINDS.includes(app.kind));
|
|
36
36
|
return list.filter((c) => !owns(c)).map((c) => `connections ${c.name} is owned by ${c.owner}, which is not a ${CONTEXT_OWNER_KINDS.join(' or ')} app declared in the same side`);
|
|
37
37
|
}
|
|
@@ -42,7 +42,7 @@ export const TRACKED = ['tracked', 'ignored', 'external'];
|
|
|
42
42
|
/** A runtime manifest adds `generated`: tracked, written only by the slot's generatedBy, judged by drift (RT_GENERATED_DRIFT). */
|
|
43
43
|
const RUNTIME_TRACKED = [...TRACKED, 'generated'];
|
|
44
44
|
export const TESTS = ['unit-beside', 'e2e', 'none'];
|
|
45
|
-
/** The coverage mode of a tracked slot of the be profile (scripts/hfs/coverage-scope.mjs
|
|
45
|
+
/** The coverage mode of a tracked slot of the be profile or of a runtime manifest (scripts/hfs/coverage-scope.mjs and scripts/hfs/runtime-coverage-scope.mjs derive the coverage consumers from it). */
|
|
46
46
|
export const COVERAGE = ['required', 'none'];
|
|
47
47
|
export const APP_KIND = 'app';
|
|
48
48
|
|
|
@@ -86,31 +86,54 @@ export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
|
|
|
86
86
|
export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
|
|
87
87
|
const strList = (v) => stringList(v);
|
|
88
88
|
|
|
89
|
-
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', '
|
|
89
|
+
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'anonymousActions'];
|
|
90
90
|
/** The fields a slot's `lite` overlay may hold: the same keys it would carry in the slot body, resolved under edition lite. */
|
|
91
91
|
const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances'];
|
|
92
92
|
/** A runtime slot has no app kind, side composition, layer or managed template; it may name the generator of a generated copy. */
|
|
93
|
-
const RUNTIME_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'minInstances', 'requires', 'allows', 'forbids', 'budget', 'rules', 'goesTo', 'why', 'since', '
|
|
93
|
+
const RUNTIME_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'minInstances', 'requires', 'allows', 'forbids', 'budget', 'rules', 'goesTo', 'why', 'since', 'generatedBy', 'parent', 'coverage'];
|
|
94
|
+
|
|
95
|
+
const isPositiveInt = (v) => Number.isInteger(v) && v >= 1;
|
|
96
|
+
const instancesShapeOk = (v) => isPlainObject(v) && Object.values(v).every((list) => strList(list) && list.length);
|
|
97
|
+
|
|
98
|
+
const generatedByInvalid = (generatedBy) => typeof generatedBy !== 'string' || !generatedBy || generatedBy.startsWith('/') || generatedBy.includes('..');
|
|
99
|
+
|
|
100
|
+
const profilesInvalid = (profiles, appScope, scopes) => !Array.isArray(profiles) || !profiles.length || !profiles.every((p) => scopes.includes(p)) || new Set(profiles).size !== profiles.length || (profiles.includes(appScope) && profiles.length !== 1);
|
|
101
|
+
|
|
102
|
+
function runtimeIdentityProblems(slot, at) {
|
|
103
|
+
const bad = [];
|
|
104
|
+
if (!RUNTIME_SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like runtime.<name>`);
|
|
105
|
+
if (JSON.stringify(slot.profiles) !== JSON.stringify([RUNTIME_KIND])) bad.push(`${at}: profiles must be [runtime]`);
|
|
106
|
+
if (slot.tracked === 'generated' && generatedByInvalid(slot.generatedBy)) bad.push(`${at}: a generated slot names its generator (generatedBy, a repository-relative path)`);
|
|
107
|
+
if (slot.generatedBy !== undefined && slot.tracked !== 'generated') bad.push(`${at}: generatedBy belongs to a generated slot`);
|
|
108
|
+
return bad;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function appIdentityProblems(slot, at, appScope, scopes) {
|
|
112
|
+
const bad = [];
|
|
113
|
+
if (!SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like be.transport.http`);
|
|
114
|
+
if (profilesInvalid(slot.profiles, appScope, scopes)) bad.push(`${at}: profiles must be [app] or a unique non-empty subset of be, fe`);
|
|
115
|
+
else if ((slot.profiles[0] === appScope) !== String(slot.id).startsWith(`${appScope}.`)) bad.push(`${at}: an app-root slot has profiles [app] and an id app.<name>, and only it`);
|
|
116
|
+
return bad;
|
|
117
|
+
}
|
|
94
118
|
|
|
95
119
|
/** Shape problems of one slot of a manifest of `kind` (app or runtime). */
|
|
96
|
-
|
|
120
|
+
function slotIdentityProblems(slot, runtime, slotKeys, at, appScope, scopes) {
|
|
97
121
|
const bad = [];
|
|
98
|
-
const runtime = kind === RUNTIME_KIND;
|
|
99
|
-
const slotKeys = new Set(runtime ? RUNTIME_SLOT_KEYS : APP_SLOT_KEYS);
|
|
100
|
-
const tracked = runtime ? RUNTIME_TRACKED : TRACKED;
|
|
101
|
-
const at = isPlainObject(slot) && typeof slot.id === 'string' ? `slot ${slot.id}` : `slots[${index}]`;
|
|
102
|
-
if (!isPlainObject(slot)) return [`${at} is not a map`];
|
|
103
122
|
for (const key of Object.keys(slot)) if (!slotKeys.has(key)) bad.push(`${at}: unknown field ${key}`);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
123
|
+
bad.push(...(runtime ? runtimeIdentityProblems(slot, at) : appIdentityProblems(slot, at, appScope, scopes)));
|
|
124
|
+
return bad;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function slotCoverageProblems(slot, at, runtime) {
|
|
128
|
+
const bad = [];
|
|
129
|
+
const measured = slot.tracked === 'tracked' && (runtime || (Array.isArray(slot.profiles) && slot.profiles.includes('be')));
|
|
130
|
+
if (measured && !COVERAGE.includes(slot.coverage)) bad.push(`${at}: coverage must be one of ${COVERAGE.join(', ')} (every tracked slot of ${runtime ? 'a runtime manifest' : 'the be profile'} declares it)`);
|
|
131
|
+
if (!measured && slot.coverage !== undefined) bad.push(`${at}: coverage belongs to a tracked slot of ${runtime ? 'a runtime manifest' : 'the be profile'} only`);
|
|
132
|
+
return bad;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function slotLocationProblems(slot, at, runtime, tracked) {
|
|
136
|
+
const bad = [];
|
|
114
137
|
if (typeof slot.path !== 'string' || !slot.path) bad.push(`${at}: path is required`);
|
|
115
138
|
if (slot.parent !== undefined && !/^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$/.test(String(slot.parent))) bad.push(`${at}: parent must be a slot id`);
|
|
116
139
|
if (!PRESENCE.includes(slot.presence)) bad.push(`${at}: presence must be one of ${PRESENCE.join(', ')}`);
|
|
@@ -118,41 +141,70 @@ export function slotProblems(slot, index, kind, { appScope = 'app', scopes = []
|
|
|
118
141
|
if (!NAME.test(String(slot.tier))) bad.push(`${at}: tier must be a tier name, none or inherit`);
|
|
119
142
|
if (!TESTS.includes(slot.tests)) bad.push(`${at}: tests must be one of ${TESTS.join(', ')}`);
|
|
120
143
|
if (slot.owner !== undefined && typeof slot.owner !== 'boolean') bad.push(`${at}: owner must be a boolean`);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (slot.minInstances !== undefined && !(Number.isInteger(slot.minInstances) && slot.minInstances >= 1)) bad.push(`${at}: minInstances must be a positive integer`);
|
|
130
|
-
if (slot.requiredWhen !== undefined && slot.requiredWhen !== 'connections') bad.push(`${at}: requiredWhen may only be connections`);
|
|
144
|
+
bad.push(...slotCoverageProblems(slot, at, runtime));
|
|
145
|
+
return bad;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const NAMED_SLOT_FIELDS = [['appKind', 'appKind must be a name'], ['trigger', 'trigger must be a trigger kind name'], ['pattern', 'pattern must be a pattern name']];
|
|
149
|
+
|
|
150
|
+
function slotInstanceProblems(slot, at) {
|
|
151
|
+
const bad = [];
|
|
131
152
|
if (slot.pattern !== undefined && !/^[a-z][a-z0-9-]*$/.test(String(slot.pattern))) bad.push(`${at}: pattern must be a kebab-case pattern name`);
|
|
132
|
-
if (slot.requiredInstances !== undefined && !(
|
|
153
|
+
if (slot.requiredInstances !== undefined && !instancesShapeOk(slot.requiredInstances)) bad.push(`${at}: requiredInstances must map a variable to a non-empty list of names`);
|
|
154
|
+
return bad;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function slotPatternProblems(slot, at) {
|
|
158
|
+
const bad = [];
|
|
159
|
+
for (const [key, what] of NAMED_SLOT_FIELDS) if (slot[key] !== undefined && !NAME.test(String(slot[key]))) bad.push(`${at}: ${what}`);
|
|
160
|
+
if (slot.minInstances !== undefined && !isPositiveInt(slot.minInstances)) bad.push(`${at}: minInstances must be a positive integer`);
|
|
161
|
+
if (slot.requiredWhen !== undefined && slot.requiredWhen !== 'connections') bad.push(`${at}: requiredWhen may only be connections`);
|
|
162
|
+
bad.push(...slotInstanceProblems(slot, at));
|
|
163
|
+
return bad;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const kindsInvalid = (slot) => {
|
|
167
|
+
if (slot.kinds === undefined || !strList(slot.kinds)) return false;
|
|
168
|
+
return !slot.kinds.length || new Set(slot.kinds).size !== slot.kinds.length || slot.kinds.some((k) => !NAME.test(k) || (slot.layers ?? []).includes(k));
|
|
169
|
+
};
|
|
170
|
+
const rolesValid = (roles) => isPlainObject(roles) && Object.keys(roles).length && Object.entries(roles).every(([role, file]) => NAME.test(role) && typeof file === 'string' && file && !file.includes('/'));
|
|
171
|
+
const composedByValid = (list) => strList(list) && list.length && new Set(list).size === list.length;
|
|
172
|
+
const budgetValid = (budget) => isPlainObject(budget) && Object.keys(budget).length && Object.values(budget).every(isPositiveInt);
|
|
173
|
+
|
|
174
|
+
function slotListProblems(slot, at) {
|
|
175
|
+
const bad = [];
|
|
133
176
|
for (const key of ['requires', 'allows', 'forbids', 'layers', 'kinds']) if (slot[key] !== undefined && !strList(slot[key])) bad.push(`${at}: ${key} must be a list of strings`);
|
|
134
|
-
if (
|
|
135
|
-
if (slot.roles !== undefined && !(
|
|
136
|
-
if (slot.composedBy !== undefined && !(
|
|
137
|
-
if (slot.budget !== undefined && !(
|
|
138
|
-
if (slot
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (slot.
|
|
148
|
-
|
|
149
|
-
&& (slot.lite.path === undefined || (typeof slot.lite.path === 'string' && slot.lite.path))
|
|
150
|
-
&& ['requires', 'allows', 'forbids'].every((key) => slot.lite[key] === undefined || strList(slot.lite[key]))
|
|
151
|
-
&& (slot.lite.minInstances === undefined || (Number.isInteger(slot.lite.minInstances) && slot.lite.minInstances >= 1))
|
|
152
|
-
&& (slot.lite.requiredInstances === undefined || (isPlainObject(slot.lite.requiredInstances) && Object.values(slot.lite.requiredInstances).every((v) => strList(v) && v.length)));
|
|
153
|
-
if (!ok) bad.push(`${at}: lite may hold only ${LITE_OVERLAY_KEYS.join(', ')}, each shaped as in the slot body`);
|
|
154
|
-
}
|
|
177
|
+
if (kindsInvalid(slot)) bad.push(`${at}: kinds must be a non-empty list of unique folder names that are not layers`);
|
|
178
|
+
if (slot.roles !== undefined && !rolesValid(slot.roles)) bad.push(`${at}: roles must map a role name to a file name`);
|
|
179
|
+
if (slot.composedBy !== undefined && !composedByValid(slot.composedBy)) bad.push(`${at}: composedBy must be a non-empty list of unique app kinds`);
|
|
180
|
+
if (slot.budget !== undefined && !budgetValid(slot.budget)) bad.push(`${at}: budget must map names to positive integers`);
|
|
181
|
+
for (const key of ['managedBy', 'liteManagedBy']) if (slot[key] !== undefined && !NAME.test(String(slot[key]))) bad.push(`${at}: ${key} must be a template id`);
|
|
182
|
+
return bad;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function slotAppOptionProblems(slot, at) {
|
|
186
|
+
const bad = [];
|
|
187
|
+
if (slot.editions !== undefined && !(Array.isArray(slot.editions) && slot.editions.length && slot.editions.every((e) => EDITIONS.includes(e)) && new Set(slot.editions).size === slot.editions.length)) bad.push(`${at}: editions must be a non-empty subset of ${EDITIONS.join(', ')}`);
|
|
188
|
+
if (slot.litePresence !== undefined && !(PRESENCE.includes(slot.litePresence) || (isPlainObject(slot.litePresence) && Object.keys(slot.litePresence).length && Object.entries(slot.litePresence).every(([side, value]) => ['be', 'fe'].includes(side) && PRESENCE.includes(value))))) bad.push(`${at}: litePresence must be a presence value or map sides to presence values`);
|
|
189
|
+
if (slot.provider !== undefined) {
|
|
190
|
+
if (!CONNECTION_PROVIDERS.includes(slot.provider)) bad.push(`${at}: provider must be one of ${CONNECTION_PROVIDERS.join(' | ')}`);
|
|
191
|
+
else if (slot.presence !== 'opt-in' || slot.appKind !== undefined) bad.push(`${at}: provider belongs to an opt-in slot that is not an app kind (a connection with that provider enables it)`);
|
|
155
192
|
}
|
|
193
|
+
return bad;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function slotLiteOverlayProblems(slot, at) {
|
|
197
|
+
if (slot.lite === undefined) return [];
|
|
198
|
+
const ok = isPlainObject(slot.lite) && Object.keys(slot.lite).length && Object.keys(slot.lite).every((key) => LITE_OVERLAY_KEYS.includes(key))
|
|
199
|
+
&& (slot.lite.path === undefined || (typeof slot.lite.path === 'string' && slot.lite.path))
|
|
200
|
+
&& ['requires', 'allows', 'forbids'].every((key) => slot.lite[key] === undefined || strList(slot.lite[key]))
|
|
201
|
+
&& (slot.lite.minInstances === undefined || (Number.isInteger(slot.lite.minInstances) && slot.lite.minInstances >= 1))
|
|
202
|
+
&& (slot.lite.requiredInstances === undefined || (isPlainObject(slot.lite.requiredInstances) && Object.values(slot.lite.requiredInstances).every((v) => strList(v) && v.length)));
|
|
203
|
+
return ok ? [] : [`${at}: lite may hold only ${LITE_OVERLAY_KEYS.join(', ')}, each shaped as in the slot body`];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function slotEntryProblems(slot, at) {
|
|
207
|
+
const bad = [];
|
|
156
208
|
if (slot.entries !== undefined) {
|
|
157
209
|
const listed = strList(slot.entries) && slot.entries.length > 0 && new Set(slot.entries).size === slot.entries.length && slot.entries.every(relPath);
|
|
158
210
|
if (!listed) bad.push(`${at}: entries must be a non-empty list of unique relative file paths`);
|
|
@@ -161,33 +213,53 @@ export function slotProblems(slot, index, kind, { appScope = 'app', scopes = []
|
|
|
161
213
|
}
|
|
162
214
|
if (slot.anonymousActions !== undefined && !(strList(slot.anonymousActions) && slot.anonymousActions.length > 0 && new Set(slot.anonymousActions).size === slot.anonymousActions.length && slot.anonymousActions.every(relPath) && typeof slot.path === 'string' && slot.path.endsWith('/'))) bad.push(`${at}: anonymousActions must be a non-empty list of unique relative file paths of a directory slot`);
|
|
163
215
|
if (slot.outcomeHome !== undefined && !(slot.outcomeHome === true && typeof slot.path === 'string' && slot.path && !slot.path.endsWith('/'))) bad.push(`${at}: outcomeHome is true and belongs to a file slot (the one file that declares the repository's Outcome union)`);
|
|
216
|
+
return bad;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function slotPolicyProblems(slot, at) {
|
|
220
|
+
const bad = [];
|
|
164
221
|
if (slot.rules !== undefined && !(Array.isArray(slot.rules) && slot.rules.every((r) => /^[A-Z][A-Z0-9_]*\*?$/.test(String(r))) && new Set(slot.rules).size === slot.rules.length)) bad.push(`${at}: rules must be unique rule ids`);
|
|
165
222
|
if (slot.perConnection !== undefined && !(slot.id === 'be.persistence' && strList(slot.perConnection) && slot.perConnection.length && new Set(slot.perConnection).size === slot.perConnection.length && slot.perConnection.every((name) => NAME.test(name)))) bad.push(`${at}: perConnection is a unique list of capability names, only on be.persistence (the platform capabilities whose tables exist on every connection that uses them)`);
|
|
166
223
|
if (slot.since !== undefined && !SEMVER.test(String(slot.since))) bad.push(`${at}: since must be a version`);
|
|
167
|
-
if (slot.retiredIn !== undefined && !(Number.isInteger(slot.retiredIn) && slot.retiredIn >= 2)) bad.push(`${at}: retiredIn must be a major`);
|
|
168
|
-
if (slot.retiredIn !== undefined && typeof slot.successor !== 'string') bad.push(`${at}: a retired slot names its successor`);
|
|
169
224
|
if (slot.tracked === 'external' && (typeof slot.goesTo !== 'string' || slot.presence !== 'forbidden')) bad.push(`${at}: an external slot is forbidden and says where it goes (goesTo)`);
|
|
170
225
|
if (slot.presence === 'forbidden' && slot.tracked !== 'external') bad.push(`${at}: a forbidden slot is external`);
|
|
171
226
|
return bad;
|
|
172
227
|
}
|
|
173
228
|
|
|
229
|
+
export function slotProblems(slot, index, kind, { appScope = 'app', scopes = [] } = {}) {
|
|
230
|
+
const runtime = kind === RUNTIME_KIND;
|
|
231
|
+
const slotKeys = new Set(runtime ? RUNTIME_SLOT_KEYS : APP_SLOT_KEYS);
|
|
232
|
+
const tracked = runtime ? RUNTIME_TRACKED : TRACKED;
|
|
233
|
+
const at = isPlainObject(slot) && typeof slot.id === 'string' ? `slot ${slot.id}` : `slots[${index}]`;
|
|
234
|
+
if (!isPlainObject(slot)) return [`${at} is not a map`];
|
|
235
|
+
const bad = slotIdentityProblems(slot, runtime, slotKeys, at, appScope, scopes);
|
|
236
|
+
bad.push(...slotLocationProblems(slot, at, runtime, tracked), ...slotPatternProblems(slot, at), ...slotListProblems(slot, at));
|
|
237
|
+
if (!runtime) bad.push(...slotAppOptionProblems(slot, at), ...slotLiteOverlayProblems(slot, at));
|
|
238
|
+
bad.push(...slotEntryProblems(slot, at), ...slotPolicyProblems(slot, at));
|
|
239
|
+
return bad;
|
|
240
|
+
}
|
|
241
|
+
|
|
174
242
|
/** The owners of an external call in a runtime manifest: an api system folder (`api/<system>`, `api/*` any system) or the DB tier. */
|
|
175
243
|
const INFRA_OWNER = /^(?:api\/(?:\*|[a-z][a-z0-9-]*)|engine\/db)$/;
|
|
176
|
-
const RUNTIME_PARAM_KEYS = ['fileLines', 'sourceRoots', 'infraOwners', 'baseWriteMembers', 'baseEnvSeams', 'apiContracts', 'sourceName', 'oneOffNames', 'sharedBasenames', 'generated', 'pinned', 'selfChecks'];
|
|
244
|
+
const RUNTIME_PARAM_KEYS = new Set(['fileLines', 'sourceRoots', 'infraOwners', 'baseWriteMembers', 'baseEnvSeams', 'apiContracts', 'sourceName', 'oneOffNames', 'sharedBasenames', 'generated', 'heldSecrets', 'pinned', 'selfChecks']);
|
|
177
245
|
const relPath = (v) => typeof v === 'string' && v.length > 0 && !v.startsWith('/') && !v.includes('..') && !v.includes('\\');
|
|
178
246
|
|
|
179
|
-
|
|
180
|
-
export function runtimeShapeProblems(m) {
|
|
247
|
+
function runtimeHeaderProblems(m) {
|
|
181
248
|
const bad = [];
|
|
182
249
|
const allowed = new Set(['schema', 'kind', 'version', 'versioning', 'presenceValues', 'trackedValues', 'testValues', 'profiles', 'tiers', 'ruleParams', 'crossOwner', 'crossSystem', 'slots', 'consumers']);
|
|
183
250
|
for (const key of Object.keys(m)) if (!allowed.has(key)) bad.push(`unknown top-level key ${key}`);
|
|
184
251
|
if (!/^starci\/runtime-slots@\d+$/.test(String(m.schema))) bad.push('schema must be starci/runtime-slots@<major>');
|
|
185
252
|
if (!SEMVER.test(String(m.version))) bad.push('version must be MAJOR.MINOR.PATCH');
|
|
186
|
-
if (!isPlainObject(m.versioning) || !['patch', 'minor', 'major', '
|
|
253
|
+
if (!isPlainObject(m.versioning) || !['patch', 'minor', 'major', 'pins'].every((k) => typeof m.versioning[k] === 'string')) bad.push('versioning needs patch, minor, major and pins text');
|
|
187
254
|
if (JSON.stringify(m.presenceValues) !== JSON.stringify(PRESENCE)) bad.push(`presenceValues must be ${PRESENCE.join(', ')}`);
|
|
188
255
|
if (JSON.stringify(m.trackedValues) !== JSON.stringify(RUNTIME_TRACKED)) bad.push(`trackedValues must be ${RUNTIME_TRACKED.join(', ')}`);
|
|
189
256
|
if (JSON.stringify(m.testValues) !== JSON.stringify(TESTS)) bad.push(`testValues must be ${TESTS.join(', ')}`);
|
|
190
257
|
if (JSON.stringify(m.profiles) !== JSON.stringify([RUNTIME_KIND])) bad.push('profiles must be [runtime]');
|
|
258
|
+
return bad;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function runtimeSectionsProblems(m) {
|
|
262
|
+
const bad = [];
|
|
191
263
|
for (const key of ['crossOwner', 'crossSystem']) if (m[key] !== undefined && typeof m[key] !== 'string') bad.push(`${key} must be text`);
|
|
192
264
|
if (!isPlainObject(m.tiers) || Object.keys(m.tiers).join() !== RUNTIME_KIND) bad.push('tiers must be a map with exactly runtime');
|
|
193
265
|
else bad.push(...tierMapProblems(RUNTIME_KIND, m.tiers.runtime));
|
|
@@ -200,19 +272,39 @@ export function runtimeShapeProblems(m) {
|
|
|
200
272
|
return bad;
|
|
201
273
|
}
|
|
202
274
|
|
|
275
|
+
/** Shape problems of a parsed manifest of kind runtime (knowledge/hfs/runtime-slots.yaml), in the words of modules/schemas/hfs-slots.schema.yaml. */
|
|
276
|
+
export function runtimeShapeProblems(m) {
|
|
277
|
+
return [...runtimeHeaderProblems(m), ...runtimeSectionsProblems(m)];
|
|
278
|
+
}
|
|
279
|
+
|
|
203
280
|
/** Shape problems of ruleParams.runtime. */
|
|
204
|
-
function
|
|
281
|
+
function runtimeParamKeyProblems(rp) {
|
|
205
282
|
const bad = [];
|
|
206
|
-
for (const key of Object.keys(rp)) if (!RUNTIME_PARAM_KEYS.
|
|
283
|
+
for (const key of Object.keys(rp)) if (!RUNTIME_PARAM_KEYS.has(key)) bad.push(`ruleParams.runtime.${key} is not a runtime parameter`);
|
|
207
284
|
for (const key of RUNTIME_PARAM_KEYS) if (!(key in rp)) bad.push(`ruleParams.runtime.${key} is missing`);
|
|
285
|
+
return bad;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function runtimeParamFoundationProblems(rp) {
|
|
289
|
+
const bad = [];
|
|
208
290
|
const fl = rp.fileLines;
|
|
209
291
|
if (!(isPlainObject(fl) && Number.isInteger(fl.soft) && fl.soft >= 1 && typeof fl.hardGrowth === 'boolean' && Object.keys(fl).length === 2)) bad.push('ruleParams.runtime.fileLines must be {soft, hardGrowth}');
|
|
210
292
|
const owners = rp.infraOwners;
|
|
211
293
|
const ownerMap = (v) => isPlainObject(v) && Object.entries(v).every(([key, list]) => key && Array.isArray(list) && list.every((o) => INFRA_OWNER.test(String(o))) && new Set(list).size === list.length);
|
|
212
|
-
if (!isPlainObject(owners) || Object.keys(owners).sort().join() !== 'globals,modules,programs' || !['globals', 'modules', 'programs'].every((k) => ownerMap(owners[k]))) bad.push('ruleParams.runtime.infraOwners must be {modules, globals, programs}, each mapping a name to unique owners api/<system>, api/* or engine/db ([] means nowhere)');
|
|
294
|
+
if (!isPlainObject(owners) || Object.keys(owners).sort(byCodeUnit).join() !== 'globals,modules,programs' || !['globals', 'modules', 'programs'].every((k) => ownerMap(owners[k]))) bad.push('ruleParams.runtime.infraOwners must be {modules, globals, programs}, each mapping a name to unique owners api/<system>, api/* or engine/db ([] means nowhere)');
|
|
295
|
+
return bad;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function runtimeParamListProblems(rp) {
|
|
299
|
+
const bad = [];
|
|
213
300
|
for (const key of ['sourceRoots', 'baseWriteMembers', 'oneOffNames', 'sharedBasenames']) if (!(strList(rp[key]) && rp[key].length && new Set(rp[key]).size === rp[key].length)) bad.push(`ruleParams.runtime.${key} must be a non-empty list of unique strings`);
|
|
214
301
|
if (!(Array.isArray(rp.baseEnvSeams) && rp.baseEnvSeams.every(relPath))) bad.push('ruleParams.runtime.baseEnvSeams must be a list of repository-relative paths');
|
|
215
302
|
if (!(isPlainObject(rp.apiContracts) && Object.entries(rp.apiContracts).every(([system, file]) => NAME.test(system) && relPath(file)))) bad.push('ruleParams.runtime.apiContracts must map an api system to its calls contract path');
|
|
303
|
+
return bad;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function runtimeParamArtifactProblems(rp) {
|
|
307
|
+
const bad = [];
|
|
216
308
|
let sourceNameOk = typeof rp.sourceName === 'string';
|
|
217
309
|
try { if (sourceNameOk) new RegExp(rp.sourceName); } catch { sourceNameOk = false; }
|
|
218
310
|
if (!sourceNameOk) bad.push('ruleParams.runtime.sourceName must be a regular expression');
|
|
@@ -222,6 +314,15 @@ function runtimeParamProblems(rp) {
|
|
|
222
314
|
return bad;
|
|
223
315
|
}
|
|
224
316
|
|
|
317
|
+
function runtimeParamProblems(rp) {
|
|
318
|
+
return [
|
|
319
|
+
...runtimeParamKeyProblems(rp),
|
|
320
|
+
...runtimeParamFoundationProblems(rp),
|
|
321
|
+
...runtimeParamListProblems(rp),
|
|
322
|
+
...runtimeParamArtifactProblems(rp),
|
|
323
|
+
];
|
|
324
|
+
}
|
|
325
|
+
|
|
225
326
|
/** Shape problems of the tier map of one profile. */
|
|
226
327
|
export function tierMapProblems(profile, tiers) {
|
|
227
328
|
const bad = [];
|
|
@@ -21,6 +21,37 @@ const PLAIN_ENTRY = /^<[a-z][a-z0-9-]*>.ts$/;
|
|
|
21
21
|
const DATA_ACCESS_HOME = 'SQL text is a constant in <name>.sql.ts of the capability persistence/ folder, and data access is the capability *.service.ts (or the application *.handler.ts) calling the shared EntityManager through its Inject<Conn>EntityManager()';
|
|
22
22
|
const BANNED_SUFFIX_HOME = Object.freeze({ repository: DATA_ACCESS_HOME, store: DATA_ACCESS_HOME });
|
|
23
23
|
|
|
24
|
+
function sourceFormContext(file, resolver, suffixes, bannedSuffixes) {
|
|
25
|
+
if (!file.endsWith('.ts') || !SOURCE_ROOT.test(file)) return null;
|
|
26
|
+
const c = resolver.classifyPath(file);
|
|
27
|
+
if (c.status !== 'owned' || c.tracking === 'ignored') return null;
|
|
28
|
+
const base = path.posix.basename(file);
|
|
29
|
+
if (FREE_NAMES.has(base)) return null;
|
|
30
|
+
const slot = resolver.slot(c.slot);
|
|
31
|
+
if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].includes(base)) return null;
|
|
32
|
+
const admitted = allowsFile(resolver, file);
|
|
33
|
+
if (admitted?.allowed && admitted.entry?.includes('/') && path.posix.basename(admitted.entry) === base) return null;
|
|
34
|
+
if (admitted?.allowed && PLAIN_ENTRY.test(admitted.entry ?? '') && KEBAB.test(base.slice(0, -'.ts'.length))) return null;
|
|
35
|
+
if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') return null;
|
|
36
|
+
return { c, base, suffixes, bannedSuffixes };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sourceFormFinding(file, { c, base, suffixes, bannedSuffixes }, boundSuffixes) {
|
|
40
|
+
const parts = base.slice(0, -'.ts'.length).split('.');
|
|
41
|
+
const banned = parts.slice(1).find((part) => bannedSuffixes.includes(part));
|
|
42
|
+
if (banned) {
|
|
43
|
+
const homeNote = BANNED_SUFFIX_HOME[banned] ? `. ${BANNED_SUFFIX_HOME[banned]}` : '';
|
|
44
|
+
return { code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: banned, message: `${file}: the suffix .${banned} is banned; use a role from the closed suffix list (${suffixes.join(', ')})${homeNote}` };
|
|
45
|
+
}
|
|
46
|
+
if (boundSuffixes.has(parts.at(-1)) && parts.length >= 2 && boundSuffixes.get(parts.at(-1)).id !== c.slot) {
|
|
47
|
+
return { code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: parts.at(-1), message: `${file}: the suffix .${parts.at(-1)}.ts belongs to ${boundSuffixes.get(parts.at(-1)).path} only; move the file there` };
|
|
48
|
+
}
|
|
49
|
+
if (parts.length < 2 || !parts.every((part) => KEBAB.test(part)) || !suffixes.includes(parts.at(-1))) {
|
|
50
|
+
return { code: 'BE_SOURCE_FORM', level: 'error', path: file, message: `${file}: the name must be <kebab-name>.<suffix>.ts with a suffix from the closed list (${suffixes.join(', ')}), or index.ts, main.ts or a migration` };
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
24
55
|
/**
|
|
25
56
|
* BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, a migration of
|
|
26
57
|
* be.persistence, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
|
|
@@ -38,53 +69,42 @@ function sourceFormFindings({ files, resolver }) {
|
|
|
38
69
|
}
|
|
39
70
|
const findings = [];
|
|
40
71
|
for (const file of files) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (FREE_NAMES.has(base)) continue;
|
|
46
|
-
// A literal file name the owning slot itself requires or allows (persistence/connection.ts, world/global-setup.ts) is its role.
|
|
47
|
-
const slot = resolver.slot(c.slot);
|
|
48
|
-
if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].some((entry) => entry === base)) continue;
|
|
49
|
-
// So is an allows entry below the slot root whose last segment is that literal name (be.tests.world fakes/<provider>/server.ts).
|
|
50
|
-
const admitted = allowsFile(resolver, file);
|
|
51
|
-
if (admitted?.allowed && admitted.entry?.includes('/') && path.posix.basename(admitted.entry) === base) continue;
|
|
52
|
-
// A slot whose `allows` holds a bare <name>.ts entry (be.tests.world.kit) names its files plainly, as platform/primitives does: kebab-case is the whole form.
|
|
53
|
-
if (admitted?.allowed && PLAIN_ENTRY.test(admitted.entry ?? '') && KEBAB.test(base.slice(0, -'.ts'.length))) continue;
|
|
54
|
-
if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') continue;
|
|
55
|
-
const parts = base.slice(0, -'.ts'.length).split('.');
|
|
56
|
-
const banned = parts.slice(1).find((part) => bannedSuffixes.includes(part));
|
|
57
|
-
if (banned) {
|
|
58
|
-
findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: banned, message: `${file}: the suffix .${banned} is banned; use a role from the closed suffix list (${suffixes.join(', ')})${BANNED_SUFFIX_HOME[banned] ? `. ${BANNED_SUFFIX_HOME[banned]}` : ''}` });
|
|
59
|
-
} else if (boundSuffixes.has(parts.at(-1)) && parts.length >= 2 && boundSuffixes.get(parts.at(-1)).id !== c.slot) {
|
|
60
|
-
findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: parts.at(-1), message: `${file}: the suffix .${parts.at(-1)}.ts belongs to ${boundSuffixes.get(parts.at(-1)).path} only; move the file there` });
|
|
61
|
-
} else if (parts.length < 2 || !parts.every((part) => KEBAB.test(part)) || !suffixes.includes(parts.at(-1))) {
|
|
62
|
-
findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, message: `${file}: the name must be <kebab-name>.<suffix>.ts with a suffix from the closed list (${suffixes.join(', ')}), or index.ts, main.ts or a migration` });
|
|
63
|
-
}
|
|
72
|
+
const context = sourceFormContext(file, resolver, suffixes, bannedSuffixes);
|
|
73
|
+
if (!context) continue;
|
|
74
|
+
const finding = sourceFormFinding(file, context, boundSuffixes);
|
|
75
|
+
if (finding) findings.push(finding);
|
|
64
76
|
}
|
|
65
77
|
return findings;
|
|
66
78
|
}
|
|
67
79
|
|
|
80
|
+
function forbiddenPathFinding(file, c, resolver) {
|
|
81
|
+
const slot = resolver.slot(c.slot);
|
|
82
|
+
if (slotOwnsSecrets(slot)) return null;
|
|
83
|
+
const own = slot.rules?.includes('HFS_TOOL_CONFIG_LOCAL') ? 'HFS_TOOL_CONFIG_LOCAL' : 'HFS_FORBIDDEN_PRESENT';
|
|
84
|
+
const goesToNote = c.goesTo ? `; it belongs at ${c.goesTo}` : '';
|
|
85
|
+
return { code: own, level: 'error', path: file, slot: c.slot, goesTo: c.goesTo, message: `${file} is tracked but ${c.slot} is forbidden in the tree${goesToNote}` };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pathStatusFinding(file, resolver) {
|
|
89
|
+
const c = resolver.classifyPath(file);
|
|
90
|
+
if (c.status === 'no-slot') {
|
|
91
|
+
const nearestNote = c.nearest ? `; nearest slot ${c.nearest.slot} (${c.nearest.pattern}), matched ${c.nearest.matchedPrefix || '.'} then expected ${c.nearest.expectedNext ?? 'nothing'}` : '';
|
|
92
|
+
return { code: 'HFS_SLOT_UNDECLARED', level: 'error', path: file, nearest: c.nearest, message: `${file} matches no slot${nearestNote}` };
|
|
93
|
+
}
|
|
94
|
+
if (c.status === 'ambiguous') return { code: 'HFS_SLOT_AMBIGUOUS', level: 'error', path: file, candidates: c.candidates, message: `${file} is owned equally by ${c.candidates.map((x) => x.slot ?? x).join(', ')}` };
|
|
95
|
+
if (c.status === 'not-enabled') return { code: 'HFS_SLOT_NOT_ENABLED', level: 'error', path: file, slot: c.slot, message: `${file} belongs to ${c.slot}, an opt-in slot hfs.json neither lists in optionalSlots nor implies through an app kind` };
|
|
96
|
+
if (c.status === 'forbidden') return forbiddenPathFinding(file, c, resolver);
|
|
97
|
+
if (c.tracking === 'ignored') return { code: 'HFS_TRACKED_MUST_BE_IGNORED', level: 'error', path: file, slot: c.slot, message: `${file} is tracked but ${c.slot} must be gitignored` };
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
68
101
|
/** The findings of the slot manifest over `files` (repository-relative tracked paths) of a repository of `profile`. */
|
|
69
102
|
export function pathFindings({ files, resolver, profile }) {
|
|
70
103
|
const findings = [];
|
|
71
104
|
for (const file of files) {
|
|
72
105
|
if (profile === 'fe' && isFeTestPath(file)) continue; // a test path of a front end is FE_NO_TESTS's, the one finding of that file
|
|
73
|
-
const
|
|
74
|
-
if (
|
|
75
|
-
findings.push({ code: 'HFS_SLOT_UNDECLARED', level: 'error', path: file, nearest: c.nearest, message: `${file} matches no slot${c.nearest ? `; nearest slot ${c.nearest.slot} (${c.nearest.pattern}), matched ${c.nearest.matchedPrefix || '.'} then expected ${c.nearest.expectedNext ?? 'nothing'}` : ''}` });
|
|
76
|
-
} else if (c.status === 'ambiguous') {
|
|
77
|
-
findings.push({ code: 'HFS_SLOT_AMBIGUOUS', level: 'error', path: file, candidates: c.candidates, message: `${file} is owned equally by ${c.candidates.map((x) => x.slot ?? x).join(', ')}` });
|
|
78
|
-
} else if (c.status === 'not-enabled') {
|
|
79
|
-
findings.push({ code: 'HFS_SLOT_NOT_ENABLED', level: 'error', path: file, slot: c.slot, message: `${file} belongs to ${c.slot}, an opt-in slot hfs.json neither lists in optionalSlots nor implies through an app kind` });
|
|
80
|
-
} else if (c.status === 'forbidden') {
|
|
81
|
-
const slot = resolver.slot(c.slot);
|
|
82
|
-
if (slotOwnsSecrets(slot)) continue; // the secret scan reports the file (R06): one finding per file
|
|
83
|
-
const own = slot.rules?.includes('HFS_TOOL_CONFIG_LOCAL') ? 'HFS_TOOL_CONFIG_LOCAL' : 'HFS_FORBIDDEN_PRESENT';
|
|
84
|
-
findings.push({ code: own, level: 'error', path: file, slot: c.slot, goesTo: c.goesTo, message: `${file} is tracked but ${c.slot} is forbidden in the tree${c.goesTo ? `; it belongs at ${c.goesTo}` : ''}` });
|
|
85
|
-
} else if (c.tracking === 'ignored') {
|
|
86
|
-
findings.push({ code: 'HFS_TRACKED_MUST_BE_IGNORED', level: 'error', path: file, slot: c.slot, message: `${file} is tracked but ${c.slot} must be gitignored` });
|
|
87
|
-
}
|
|
106
|
+
const finding = pathStatusFinding(file, resolver);
|
|
107
|
+
if (finding) findings.push(finding);
|
|
88
108
|
}
|
|
89
109
|
|
|
90
110
|
if (profile === 'be') findings.push(...sourceFormFindings({ files, resolver }), ...specPlacementFindings({ files, resolver }));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { APP_SCOPE } from './slots.mjs';
|
|
4
|
+
|
|
5
|
+
const DEP_SECTIONS = ['dependencies', 'devDependencies'];
|
|
6
|
+
const pinnedSpec = (spec, pin) => (spec === pin.version ? null : `declared ${spec}, pinned ${pin.version}`);
|
|
7
|
+
|
|
8
|
+
function pinFileFindings(repoRoot, file, profile, pins) {
|
|
9
|
+
const findings = [];
|
|
10
|
+
let pkg;
|
|
11
|
+
try { pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8')); } catch { return findings; }
|
|
12
|
+
for (const [name, pin] of Object.entries(pins)) {
|
|
13
|
+
// The app root's one package.json carries the pins of both sides.
|
|
14
|
+
if (profile !== APP_SCOPE && pin.side !== 'both' && pin.side !== profile) continue;
|
|
15
|
+
for (const section of DEP_SECTIONS) {
|
|
16
|
+
const spec = pkg[section]?.[name];
|
|
17
|
+
if (spec === undefined) continue;
|
|
18
|
+
const drift = pinnedSpec(spec, pin);
|
|
19
|
+
if (drift) findings.push({ code: 'HFS_CANON_PIN_DRIFT', level: 'error', path: file, dependency: name, section, pinned: pin.version, declared: spec, message: `${name} in ${file} ${section}: ${drift}` });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return findings;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function pinFindings({ repoRoot, files, profile, pins, only }) {
|
|
26
|
+
const findings = [];
|
|
27
|
+
for (const file of files.filter((f) => (f === 'package.json' || f.endsWith('/package.json')) && (!only || only.has(f)))) {
|
|
28
|
+
findings.push(...pinFileFindings(repoRoot, file, profile, pins));
|
|
29
|
+
}
|
|
30
|
+
return findings;
|
|
31
|
+
}
|
|
@@ -6,19 +6,36 @@ import path from 'node:path';
|
|
|
6
6
|
import { revParseQuery } from '../api/git/rev-parse-query.mjs';
|
|
7
7
|
import { remote as gitRemote } from '../api/git/remote.mjs';
|
|
8
8
|
import { gitOutputOf } from '../lib/git.mjs';
|
|
9
|
+
import { trimTrailingSlashes } from './trailing-slashes.mjs';
|
|
9
10
|
|
|
10
11
|
/** One git call (a scripts/api/git call file) in `root`: its trimmed stdout; throws unless git exits 0. */
|
|
11
12
|
const git = (call, root, args) => gitOutputOf(call(args, { cwd: root })).trim();
|
|
12
13
|
const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
|
14
|
+
const GIT_SUFFIX_SOURCE = String.raw`\.git$`;
|
|
15
|
+
const GIT_SUFFIX = new RegExp(GIT_SUFFIX_SOURCE, 'iu');
|
|
16
|
+
|
|
17
|
+
// The identity of a folder that has its own .git entry holds while that entry is the same file system object (same inode, same modification time), so a
|
|
18
|
+
// process asks Git once per such folder state instead of at every check. A folder with no .git entry of its own is asked every time.
|
|
19
|
+
const known = new Map();
|
|
20
|
+
const stateOf = (root) => { try { const stat = fs.statSync(path.join(root, '.git')); return `${real(root)}|${stat.ino}|${stat.mtimeMs}`; } catch { return null; } };
|
|
13
21
|
|
|
14
22
|
/** { repositoryRoot, inWorkTree, home } for `root`: home is the main checkout folder when root is a repository top level. */
|
|
15
23
|
function identityOf(root) {
|
|
24
|
+
const state = stateOf(root);
|
|
25
|
+
if (state === null) return askGit(root);
|
|
26
|
+
if (!known.has(state)) known.set(state, askGit(root));
|
|
27
|
+
return known.get(state);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function askGit(root) {
|
|
16
31
|
let inWorkTree = false, repositoryRoot = false, home = null;
|
|
17
32
|
try {
|
|
18
33
|
// Identity comes from Git only when root IS a repository (or worktree) top level, never a folder inside one.
|
|
19
|
-
|
|
34
|
+
// One git call answers both questions: the top level on the first line, the common git dir on the second.
|
|
35
|
+
const [top, commonDir] = git(revParseQuery, root, ['--show-toplevel', '--git-common-dir']).split(String.fromCodePoint(10)).map((line) => line.trim());
|
|
36
|
+
repositoryRoot = real(top) === real(root);
|
|
20
37
|
inWorkTree = true;
|
|
21
|
-
const common = path.resolve(root,
|
|
38
|
+
const common = path.resolve(root, commonDir);
|
|
22
39
|
if (repositoryRoot && path.basename(common) === '.git') home = path.dirname(common);
|
|
23
40
|
} catch { /* Not a Git work tree; the caller falls back to the folder and package name. */ }
|
|
24
41
|
return { inWorkTree, repositoryRoot, home };
|
|
@@ -34,8 +51,8 @@ export function repositoryName(root) {
|
|
|
34
51
|
if (home) return path.basename(home);
|
|
35
52
|
if (repositoryRoot) {
|
|
36
53
|
try {
|
|
37
|
-
const remote = git(gitRemote, root, ['get-url', 'origin'])
|
|
38
|
-
const name = remote.split(/[
|
|
54
|
+
const remote = trimTrailingSlashes(git(gitRemote, root, ['get-url', 'origin'])).replace(GIT_SUFFIX, '');
|
|
55
|
+
const name = remote.split(/[/:]/u).pop();
|
|
39
56
|
if (name) return name;
|
|
40
57
|
} catch { /* No origin remote. */ }
|
|
41
58
|
}
|