typekro 0.33.9 → 0.34.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/dist/.tsbuildinfo +1 -1
- package/dist/compositions/typekro-runtime/typekro-runtime.d.ts +0 -33
- package/dist/compositions/typekro-runtime/typekro-runtime.d.ts.map +1 -1
- package/dist/compositions/typekro-runtime/typekro-runtime.js +63 -1
- package/dist/compositions/typekro-runtime/typekro-runtime.js.map +1 -1
- package/dist/compositions/typekro-runtime/types.d.ts +8 -0
- package/dist/compositions/typekro-runtime/types.d.ts.map +1 -1
- package/dist/compositions/typekro-runtime/types.js +1 -0
- package/dist/compositions/typekro-runtime/types.js.map +1 -1
- package/dist/core/aspects/values-merge.d.ts +17 -0
- package/dist/core/aspects/values-merge.d.ts.map +1 -1
- package/dist/core/aspects/values-merge.js +67 -8
- package/dist/core/aspects/values-merge.js.map +1 -1
- package/dist/core/deployment/direct-factory.d.ts +9 -0
- package/dist/core/deployment/direct-factory.d.ts.map +1 -1
- package/dist/core/deployment/direct-factory.js +37 -3
- package/dist/core/deployment/direct-factory.js.map +1 -1
- package/dist/core/deployment/engine.d.ts +34 -0
- package/dist/core/deployment/engine.d.ts.map +1 -1
- package/dist/core/deployment/engine.js +173 -29
- package/dist/core/deployment/engine.js.map +1 -1
- package/dist/core/deployment/index.d.ts +2 -0
- package/dist/core/deployment/index.d.ts.map +1 -1
- package/dist/core/deployment/index.js +1 -0
- package/dist/core/deployment/index.js.map +1 -1
- package/dist/core/deployment/k8s-helpers.d.ts +73 -0
- package/dist/core/deployment/k8s-helpers.d.ts.map +1 -1
- package/dist/core/deployment/k8s-helpers.js +198 -4
- package/dist/core/deployment/k8s-helpers.js.map +1 -1
- package/dist/core/deployment/kro-factory.d.ts.map +1 -1
- package/dist/core/deployment/kro-factory.js +21 -1
- package/dist/core/deployment/kro-factory.js.map +1 -1
- package/dist/core/deployment/status-leaf-resolution.d.ts +101 -0
- package/dist/core/deployment/status-leaf-resolution.d.ts.map +1 -0
- package/dist/core/deployment/status-leaf-resolution.js +232 -0
- package/dist/core/deployment/status-leaf-resolution.js.map +1 -0
- package/dist/core/deployment/strategies/base-strategy.d.ts.map +1 -1
- package/dist/core/deployment/strategies/base-strategy.js +31 -3
- package/dist/core/deployment/strategies/base-strategy.js.map +1 -1
- package/dist/core/deployment/strategies/kro-strategy.d.ts.map +1 -1
- package/dist/core/deployment/strategies/kro-strategy.js +8 -1
- package/dist/core/deployment/strategies/kro-strategy.js.map +1 -1
- package/dist/core/expressions/composition/composition-analyzer-helpers.d.ts.map +1 -1
- package/dist/core/expressions/composition/composition-analyzer-helpers.js +20 -2
- package/dist/core/expressions/composition/composition-analyzer-helpers.js.map +1 -1
- package/dist/core/kro/index.d.ts +6 -0
- package/dist/core/kro/index.d.ts.map +1 -0
- package/dist/core/kro/index.js +6 -0
- package/dist/core/kro/index.js.map +1 -0
- package/dist/core/kro/label-guard-capability.d.ts +141 -0
- package/dist/core/kro/label-guard-capability.d.ts.map +1 -0
- package/dist/core/kro/label-guard-capability.js +257 -0
- package/dist/core/kro/label-guard-capability.js.map +1 -0
- package/dist/core/kro/labels.d.ts +56 -0
- package/dist/core/kro/labels.d.ts.map +1 -0
- package/dist/core/kro/labels.js +62 -0
- package/dist/core/kro/labels.js.map +1 -0
- package/dist/core/kubernetes/api-capability.d.ts +196 -0
- package/dist/core/kubernetes/api-capability.d.ts.map +1 -0
- package/dist/core/kubernetes/api-capability.js +301 -0
- package/dist/core/kubernetes/api-capability.js.map +1 -0
- package/dist/core/kubernetes/index.d.ts +2 -0
- package/dist/core/kubernetes/index.d.ts.map +1 -1
- package/dist/core/kubernetes/index.js +2 -0
- package/dist/core/kubernetes/index.js.map +1 -1
- package/dist/core/kubernetes/naming.d.ts +100 -0
- package/dist/core/kubernetes/naming.d.ts.map +1 -0
- package/dist/core/kubernetes/naming.js +93 -0
- package/dist/core/kubernetes/naming.js.map +1 -0
- package/dist/core/planning/direct-runtime-adapter.d.ts.map +1 -1
- package/dist/core/planning/direct-runtime-adapter.js +54 -17
- package/dist/core/planning/direct-runtime-adapter.js.map +1 -1
- package/dist/core/planning/planner.d.ts.map +1 -1
- package/dist/core/planning/planner.js +17 -0
- package/dist/core/planning/planner.js.map +1 -1
- package/dist/core/planning/schema.d.ts.map +1 -1
- package/dist/core/planning/schema.js +7 -3
- package/dist/core/planning/schema.js.map +1 -1
- package/dist/core/references/cel-lexical-scanner.d.ts +100 -1
- package/dist/core/references/cel-lexical-scanner.d.ts.map +1 -1
- package/dist/core/references/cel-lexical-scanner.js +196 -2
- package/dist/core/references/cel-lexical-scanner.js.map +1 -1
- package/dist/core/references/cel.d.ts +249 -1
- package/dist/core/references/cel.d.ts.map +1 -1
- package/dist/core/references/cel.js +289 -8
- package/dist/core/references/cel.js.map +1 -1
- package/dist/core/serialization/arktype-ast.d.ts +21 -0
- package/dist/core/serialization/arktype-ast.d.ts.map +1 -0
- package/dist/core/serialization/arktype-ast.js +29 -0
- package/dist/core/serialization/arktype-ast.js.map +1 -0
- package/dist/core/serialization/cel-references.d.ts +133 -32
- package/dist/core/serialization/cel-references.d.ts.map +1 -1
- package/dist/core/serialization/cel-references.js +908 -109
- package/dist/core/serialization/cel-references.js.map +1 -1
- package/dist/core/serialization/core.d.ts.map +1 -1
- package/dist/core/serialization/core.js +13 -2
- package/dist/core/serialization/core.js.map +1 -1
- package/dist/core/serialization/schema.d.ts +16 -2
- package/dist/core/serialization/schema.d.ts.map +1 -1
- package/dist/core/serialization/schema.js +113 -21
- package/dist/core/serialization/schema.js.map +1 -1
- package/dist/core/types/composable.d.ts +7 -1
- package/dist/core/types/composable.d.ts.map +1 -1
- package/dist/core/types/deployment.d.ts +32 -2
- package/dist/core/types/deployment.d.ts.map +1 -1
- package/dist/core/types/serialization.d.ts +37 -0
- package/dist/core/types/serialization.d.ts.map +1 -1
- package/dist/core/validation/cel-dialect.d.ts +246 -0
- package/dist/core/validation/cel-dialect.d.ts.map +1 -0
- package/dist/core/validation/cel-dialect.js +1997 -0
- package/dist/core/validation/cel-dialect.js.map +1 -0
- package/dist/core/validation/cel-validator.d.ts.map +1 -1
- package/dist/core/validation/cel-validator.js +8 -1
- package/dist/core/validation/cel-validator.js.map +1 -1
- package/dist/core/validation/literal-status.d.ts +107 -0
- package/dist/core/validation/literal-status.d.ts.map +1 -0
- package/dist/core/validation/literal-status.js +346 -0
- package/dist/core/validation/literal-status.js.map +1 -0
- package/dist/core/validation/structural-spec-dependence.d.ts +179 -0
- package/dist/core/validation/structural-spec-dependence.d.ts.map +1 -0
- package/dist/core/validation/structural-spec-dependence.js +911 -0
- package/dist/core/validation/structural-spec-dependence.js.map +1 -0
- package/dist/factories/apisix/compositions/apisix-bootstrap.d.ts.map +1 -1
- package/dist/factories/apisix/compositions/apisix-bootstrap.js +9 -0
- package/dist/factories/apisix/compositions/apisix-bootstrap.js.map +1 -1
- package/dist/factories/clickhouse/compositions/clickhouse-cluster.d.ts +6 -0
- package/dist/factories/clickhouse/compositions/clickhouse-cluster.d.ts.map +1 -1
- package/dist/factories/clickhouse/compositions/clickhouse-cluster.js +273 -23
- package/dist/factories/clickhouse/compositions/clickhouse-cluster.js.map +1 -1
- package/dist/factories/clickhouse/resources/index.d.ts +1 -0
- package/dist/factories/clickhouse/resources/index.d.ts.map +1 -1
- package/dist/factories/clickhouse/resources/index.js +1 -0
- package/dist/factories/clickhouse/resources/index.js.map +1 -1
- package/dist/factories/clickhouse/resources/installation.d.ts.map +1 -1
- package/dist/factories/clickhouse/resources/installation.js +31 -4
- package/dist/factories/clickhouse/resources/installation.js.map +1 -1
- package/dist/factories/clickhouse/resources/s3-backup.d.ts +215 -0
- package/dist/factories/clickhouse/resources/s3-backup.d.ts.map +1 -0
- package/dist/factories/clickhouse/resources/s3-backup.js +410 -0
- package/dist/factories/clickhouse/resources/s3-backup.js.map +1 -0
- package/dist/factories/clickhouse/types.d.ts +416 -20
- package/dist/factories/clickhouse/types.d.ts.map +1 -1
- package/dist/factories/clickhouse/types.js +235 -11
- package/dist/factories/clickhouse/types.js.map +1 -1
- package/dist/factories/clickhouse/utils/helm-values-mapper.d.ts +42 -0
- package/dist/factories/clickhouse/utils/helm-values-mapper.d.ts.map +1 -1
- package/dist/factories/clickhouse/utils/helm-values-mapper.js +48 -0
- package/dist/factories/clickhouse/utils/helm-values-mapper.js.map +1 -1
- package/dist/factories/clickhouse/utils/index.d.ts +2 -0
- package/dist/factories/clickhouse/utils/index.d.ts.map +1 -1
- package/dist/factories/clickhouse/utils/index.js +2 -0
- package/dist/factories/clickhouse/utils/index.js.map +1 -1
- package/dist/factories/clickhouse/utils/s3-storage.d.ts +503 -0
- package/dist/factories/clickhouse/utils/s3-storage.d.ts.map +1 -0
- package/dist/factories/clickhouse/utils/s3-storage.js +1139 -0
- package/dist/factories/clickhouse/utils/s3-storage.js.map +1 -0
- package/dist/factories/clickhouse/utils/validation.d.ts +103 -0
- package/dist/factories/clickhouse/utils/validation.d.ts.map +1 -1
- package/dist/factories/clickhouse/utils/validation.js +129 -0
- package/dist/factories/clickhouse/utils/validation.js.map +1 -1
- package/dist/factories/clickhouse/utils/xml.d.ts +87 -0
- package/dist/factories/clickhouse/utils/xml.d.ts.map +1 -0
- package/dist/factories/clickhouse/utils/xml.js +151 -0
- package/dist/factories/clickhouse/utils/xml.js.map +1 -0
- package/dist/factories/clickstack/compositions/clickstack-bootstrap.d.ts +57 -0
- package/dist/factories/clickstack/compositions/clickstack-bootstrap.d.ts.map +1 -1
- package/dist/factories/clickstack/compositions/clickstack-bootstrap.js +266 -7
- package/dist/factories/clickstack/compositions/clickstack-bootstrap.js.map +1 -1
- package/dist/factories/clickstack/types.d.ts +205 -26
- package/dist/factories/clickstack/types.d.ts.map +1 -1
- package/dist/factories/clickstack/types.js +106 -8
- package/dist/factories/clickstack/types.js.map +1 -1
- package/dist/factories/clickstack/utils/collector-config.d.ts +104 -0
- package/dist/factories/clickstack/utils/collector-config.d.ts.map +1 -0
- package/dist/factories/clickstack/utils/collector-config.js +266 -0
- package/dist/factories/clickstack/utils/collector-config.js.map +1 -0
- package/dist/factories/clickstack/utils/helm-values-mapper.d.ts +26 -0
- package/dist/factories/clickstack/utils/helm-values-mapper.d.ts.map +1 -1
- package/dist/factories/clickstack/utils/helm-values-mapper.js +53 -12
- package/dist/factories/clickstack/utils/helm-values-mapper.js.map +1 -1
- package/dist/factories/clickstack/utils/index.d.ts +2 -0
- package/dist/factories/clickstack/utils/index.d.ts.map +1 -1
- package/dist/factories/clickstack/utils/index.js +2 -0
- package/dist/factories/clickstack/utils/index.js.map +1 -1
- package/dist/factories/clickstack/utils/storage.d.ts +449 -0
- package/dist/factories/clickstack/utils/storage.d.ts.map +1 -0
- package/dist/factories/clickstack/utils/storage.js +698 -0
- package/dist/factories/clickstack/utils/storage.js.map +1 -0
- package/dist/factories/cnpg/resources/helm.d.ts.map +1 -1
- package/dist/factories/cnpg/resources/helm.js +4 -4
- package/dist/factories/cnpg/resources/helm.js.map +1 -1
- package/dist/factories/cnpg/types.d.ts +10 -1
- package/dist/factories/cnpg/types.d.ts.map +1 -1
- package/dist/factories/cnpg/types.js.map +1 -1
- package/dist/factories/cnpg/utils/helm-values-mapper.d.ts +19 -2
- package/dist/factories/cnpg/utils/helm-values-mapper.d.ts.map +1 -1
- package/dist/factories/cnpg/utils/helm-values-mapper.js +22 -4
- package/dist/factories/cnpg/utils/helm-values-mapper.js.map +1 -1
- package/dist/factories/envoy-ai-gateway/compositions/gateway.d.ts.map +1 -1
- package/dist/factories/envoy-ai-gateway/compositions/gateway.js +16 -3
- package/dist/factories/envoy-ai-gateway/compositions/gateway.js.map +1 -1
- package/dist/factories/envoy-ai-gateway/constants.d.ts +5 -2
- package/dist/factories/envoy-ai-gateway/constants.d.ts.map +1 -1
- package/dist/factories/envoy-ai-gateway/constants.js +5 -2
- package/dist/factories/envoy-ai-gateway/constants.js.map +1 -1
- package/dist/factories/envoy-ai-gateway/resources/gateway.d.ts +12 -0
- package/dist/factories/envoy-ai-gateway/resources/gateway.d.ts.map +1 -1
- package/dist/factories/envoy-ai-gateway/resources/gateway.js +27 -104
- package/dist/factories/envoy-ai-gateway/resources/gateway.js.map +1 -1
- package/dist/factories/envoy-ai-gateway/types.d.ts +15 -47
- package/dist/factories/envoy-ai-gateway/types.d.ts.map +1 -1
- package/dist/factories/envoy-ai-gateway/types.js.map +1 -1
- package/dist/factories/gateway-api/constants.d.ts +27 -0
- package/dist/factories/gateway-api/constants.d.ts.map +1 -0
- package/dist/factories/gateway-api/constants.js +27 -0
- package/dist/factories/gateway-api/constants.js.map +1 -0
- package/dist/factories/gateway-api/index.d.ts +14 -0
- package/dist/factories/gateway-api/index.d.ts.map +1 -0
- package/dist/factories/gateway-api/index.js +14 -0
- package/dist/factories/gateway-api/index.js.map +1 -0
- package/dist/factories/gateway-api/readiness.d.ts +67 -0
- package/dist/factories/gateway-api/readiness.d.ts.map +1 -0
- package/dist/factories/gateway-api/readiness.js +213 -0
- package/dist/factories/gateway-api/readiness.js.map +1 -0
- package/dist/factories/gateway-api/resources/gateway.d.ts +91 -0
- package/dist/factories/gateway-api/resources/gateway.d.ts.map +1 -0
- package/dist/factories/gateway-api/resources/gateway.js +99 -0
- package/dist/factories/gateway-api/resources/gateway.js.map +1 -0
- package/dist/factories/gateway-api/resources/index.d.ts +2 -0
- package/dist/factories/gateway-api/resources/index.d.ts.map +1 -0
- package/dist/factories/gateway-api/resources/index.js +2 -0
- package/dist/factories/gateway-api/resources/index.js.map +1 -0
- package/dist/factories/gateway-api/types.d.ts +1269 -0
- package/dist/factories/gateway-api/types.d.ts.map +1 -0
- package/dist/factories/gateway-api/types.js +417 -0
- package/dist/factories/gateway-api/types.js.map +1 -0
- package/dist/factories/helm/readiness-evaluators.d.ts +21 -3
- package/dist/factories/helm/readiness-evaluators.d.ts.map +1 -1
- package/dist/factories/helm/readiness-evaluators.js +117 -11
- package/dist/factories/helm/readiness-evaluators.js.map +1 -1
- package/dist/factories/helm/types.d.ts +28 -0
- package/dist/factories/helm/types.d.ts.map +1 -1
- package/dist/factories/index.d.ts +2 -0
- package/dist/factories/index.d.ts.map +1 -1
- package/dist/factories/index.js +8 -0
- package/dist/factories/index.js.map +1 -1
- package/dist/factories/inngest/compositions/inngest-bootstrap.d.ts.map +1 -1
- package/dist/factories/inngest/compositions/inngest-bootstrap.js +10 -0
- package/dist/factories/inngest/compositions/inngest-bootstrap.js.map +1 -1
- package/dist/factories/kubernetes/admission/index.d.ts +2 -0
- package/dist/factories/kubernetes/admission/index.d.ts.map +1 -1
- package/dist/factories/kubernetes/admission/index.js +2 -0
- package/dist/factories/kubernetes/admission/index.js.map +1 -1
- package/dist/factories/kubernetes/admission/label-propagation-guard.d.ts +108 -0
- package/dist/factories/kubernetes/admission/label-propagation-guard.d.ts.map +1 -0
- package/dist/factories/kubernetes/admission/label-propagation-guard.js +247 -0
- package/dist/factories/kubernetes/admission/label-propagation-guard.js.map +1 -0
- package/dist/factories/kubernetes/admission/mutating-admission-policy.d.ts +286 -0
- package/dist/factories/kubernetes/admission/mutating-admission-policy.d.ts.map +1 -0
- package/dist/factories/kubernetes/admission/mutating-admission-policy.js +157 -0
- package/dist/factories/kubernetes/admission/mutating-admission-policy.js.map +1 -0
- package/dist/factories/opensearch/compositions/cluster.d.ts.map +1 -1
- package/dist/factories/opensearch/compositions/cluster.js +6 -1
- package/dist/factories/opensearch/compositions/cluster.js.map +1 -1
- package/dist/factories/ory/compositions/ory-identity-stack.d.ts +8 -8
- package/dist/factories/ory/types.d.ts +16 -16
- package/dist/factories/rook/compositions/object-storage-claim.d.ts +1 -1
- package/dist/factories/rook/compositions/rook-ceph-platform.js +8 -1
- package/dist/factories/rook/compositions/rook-ceph-platform.js.map +1 -1
- package/dist/factories/rook/types.d.ts +1 -1
- package/dist/factories/searxng/compositions/searxng-bootstrap.d.ts +1 -1
- package/dist/factories/searxng/compositions/searxng-bootstrap.d.ts.map +1 -1
- package/dist/factories/searxng/compositions/searxng-bootstrap.js +18 -13
- package/dist/factories/searxng/compositions/searxng-bootstrap.js.map +1 -1
- package/dist/factories/searxng/index.d.ts +3 -0
- package/dist/factories/searxng/index.d.ts.map +1 -1
- package/dist/factories/searxng/index.js +3 -0
- package/dist/factories/searxng/index.js.map +1 -1
- package/dist/factories/searxng/types.js +1 -1
- package/dist/factories/searxng/types.js.map +1 -1
- package/dist/factories/traefik/compositions/index.d.ts +6 -0
- package/dist/factories/traefik/compositions/index.d.ts.map +1 -0
- package/dist/factories/traefik/compositions/index.js +6 -0
- package/dist/factories/traefik/compositions/index.js.map +1 -0
- package/dist/factories/traefik/compositions/traefik-bootstrap.d.ts +185 -0
- package/dist/factories/traefik/compositions/traefik-bootstrap.d.ts.map +1 -0
- package/dist/factories/traefik/compositions/traefik-bootstrap.js +339 -0
- package/dist/factories/traefik/compositions/traefik-bootstrap.js.map +1 -0
- package/dist/factories/traefik/compositions/traefik-helm-repository.d.ts +16 -0
- package/dist/factories/traefik/compositions/traefik-helm-repository.d.ts.map +1 -0
- package/dist/factories/traefik/compositions/traefik-helm-repository.js +31 -0
- package/dist/factories/traefik/compositions/traefik-helm-repository.js.map +1 -0
- package/dist/factories/traefik/constants.d.ts +78 -0
- package/dist/factories/traefik/constants.d.ts.map +1 -0
- package/dist/factories/traefik/constants.js +104 -0
- package/dist/factories/traefik/constants.js.map +1 -0
- package/dist/factories/traefik/index.d.ts +25 -0
- package/dist/factories/traefik/index.d.ts.map +1 -0
- package/dist/factories/traefik/index.js +25 -0
- package/dist/factories/traefik/index.js.map +1 -0
- package/dist/factories/traefik/resources/common.d.ts +68 -0
- package/dist/factories/traefik/resources/common.d.ts.map +1 -0
- package/dist/factories/traefik/resources/common.js +59 -0
- package/dist/factories/traefik/resources/common.js.map +1 -0
- package/dist/factories/traefik/resources/gateway.d.ts +85 -0
- package/dist/factories/traefik/resources/gateway.d.ts.map +1 -0
- package/dist/factories/traefik/resources/gateway.js +109 -0
- package/dist/factories/traefik/resources/gateway.js.map +1 -0
- package/dist/factories/traefik/resources/helm.d.ts +56 -0
- package/dist/factories/traefik/resources/helm.d.ts.map +1 -0
- package/dist/factories/traefik/resources/helm.js +131 -0
- package/dist/factories/traefik/resources/helm.js.map +1 -0
- package/dist/factories/traefik/resources/index.d.ts +10 -0
- package/dist/factories/traefik/resources/index.d.ts.map +1 -0
- package/dist/factories/traefik/resources/index.js +10 -0
- package/dist/factories/traefik/resources/index.js.map +1 -0
- package/dist/factories/traefik/resources/middleware.d.ts +169 -0
- package/dist/factories/traefik/resources/middleware.d.ts.map +1 -0
- package/dist/factories/traefik/resources/middleware.js +225 -0
- package/dist/factories/traefik/resources/middleware.js.map +1 -0
- package/dist/factories/traefik/resources/routing.d.ts +113 -0
- package/dist/factories/traefik/resources/routing.d.ts.map +1 -0
- package/dist/factories/traefik/resources/routing.js +119 -0
- package/dist/factories/traefik/resources/routing.js.map +1 -0
- package/dist/factories/traefik/resources/tls.d.ts +85 -0
- package/dist/factories/traefik/resources/tls.d.ts.map +1 -0
- package/dist/factories/traefik/resources/tls.js +93 -0
- package/dist/factories/traefik/resources/tls.js.map +1 -0
- package/dist/factories/traefik/types.d.ts +2690 -0
- package/dist/factories/traefik/types.d.ts.map +1 -0
- package/dist/factories/traefik/types.js +1084 -0
- package/dist/factories/traefik/types.js.map +1 -0
- package/dist/factories/traefik/utils/helm-values-mapper.d.ts +198 -0
- package/dist/factories/traefik/utils/helm-values-mapper.d.ts.map +1 -0
- package/dist/factories/traefik/utils/helm-values-mapper.js +372 -0
- package/dist/factories/traefik/utils/helm-values-mapper.js.map +1 -0
- package/dist/factories/traefik/utils/index.d.ts +6 -0
- package/dist/factories/traefik/utils/index.d.ts.map +1 -0
- package/dist/factories/traefik/utils/index.js +6 -0
- package/dist/factories/traefik/utils/index.js.map +1 -0
- package/dist/factories/traefik/utils/middleware-validation.d.ts +42 -0
- package/dist/factories/traefik/utils/middleware-validation.d.ts.map +1 -0
- package/dist/factories/traefik/utils/middleware-validation.js +78 -0
- package/dist/factories/traefik/utils/middleware-validation.js.map +1 -0
- package/dist/factories/webapp/compositions/web-app-with-processing.d.ts.map +1 -1
- package/dist/factories/webapp/compositions/web-app-with-processing.js +10 -0
- package/dist/factories/webapp/compositions/web-app-with-processing.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/package.json +9 -1
|
@@ -0,0 +1,1997 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dual-dialect compatibility check for emitted status CEL.
|
|
3
|
+
*
|
|
4
|
+
* TypeKro emits one CEL expression per dynamic status field and then runs it
|
|
5
|
+
* through **two different engines**: `cel-js` evaluates it locally in direct
|
|
6
|
+
* mode, and `cel-go` (inside the KRO controller, with KRO's type environment)
|
|
7
|
+
* evaluates it in Kro mode. The two disagree in ways that only surface when the
|
|
8
|
+
* expression meets real data on a live cluster — one engine returns a value and
|
|
9
|
+
* the other rejects the expression or blows up on a missing field.
|
|
10
|
+
*
|
|
11
|
+
* This module runs every emitted status expression through cel-js's own parser
|
|
12
|
+
* **and** a curated denylist of the divergences we have confirmed, so an
|
|
13
|
+
* expression that only one engine accepts fails at serialization time with the
|
|
14
|
+
* expression, the status leaf it came from, and the dialect that rejects it.
|
|
15
|
+
*
|
|
16
|
+
* ## cel-js's parser is not the CEL grammar
|
|
17
|
+
*
|
|
18
|
+
* cel-js 0.8.2 parses a proper subset of CEL, so a `parse()` failure is a fact
|
|
19
|
+
* about **direct mode only**. Its `atomicExpression` rule takes a postfix
|
|
20
|
+
* `.`/`[` after an identifier (and after a map literal, and one index after a
|
|
21
|
+
* list literal) and nowhere else, while the spec's `Member` production takes a
|
|
22
|
+
* postfix on any `Member` and `Primary` includes both `LITERAL` and
|
|
23
|
+
* `"(" Expr ")"` — so `"x".size()`, `[1,2].size()`, `(a).b` and `size(a).b` are
|
|
24
|
+
* all valid CEL that cel-js rejects, and its lexer has neither the exponent
|
|
25
|
+
* `FLOAT_LIT` form nor the `r`/`b`/triple-quoted `STRING_LIT` forms. Inferring
|
|
26
|
+
* "cel-go would reject this too" from a cel-js parse failure is therefore
|
|
27
|
+
* unsound, and the parse half does not do it: it sorts a failure into
|
|
28
|
+
* `not-valid-cel` (a form the *spec's* grammar has no token or production for),
|
|
29
|
+
* `cel-js-rejects-spec-cel` (a positively identified cel-js shortfall — a real
|
|
30
|
+
* divergence) or `cel-js-parse-failure` (no verdict beyond direct mode).
|
|
31
|
+
*
|
|
32
|
+
* Identifying a shortfall *somewhere* in a rejected expression is not by itself
|
|
33
|
+
* a divergence, because the parse may have failed for an unrelated reason: the
|
|
34
|
+
* shortfall in `"x".size() +` is real and the expression is still unfinished,
|
|
35
|
+
* and cel-go refuses it exactly as cel-js does. So the divergence bucket is
|
|
36
|
+
* earned rather than assumed — every identified shortfall is rewritten into a
|
|
37
|
+
* spelling of the same spec production that cel-js does have (`"x".size()` into
|
|
38
|
+
* `__typekro_recv0.size()`, `1e3` into `0.0`), the rest of the text is left
|
|
39
|
+
* untouched, and cel-js is asked again. Only a rewrite that parses proves the
|
|
40
|
+
* shortfall was the whole reason; anything else falls to `cel-js-parse-failure`.
|
|
41
|
+
*
|
|
42
|
+
* ## Why a denylist rather than a real cel-go
|
|
43
|
+
*
|
|
44
|
+
* cel-go is a Go library; there is no in-process cel-go for a TypeScript
|
|
45
|
+
* serializer to consult, and shelling out to one would make `toYaml()` depend
|
|
46
|
+
* on a Go toolchain. The denylist is therefore a deliberately *curated*,
|
|
47
|
+
* documented set of confirmed divergences rather than a general type checker.
|
|
48
|
+
* It is meant to grow: each rule below records what was observed and on which
|
|
49
|
+
* engine, so a new entry can be justified the same way.
|
|
50
|
+
*
|
|
51
|
+
* ## What may fail strict mode
|
|
52
|
+
*
|
|
53
|
+
* A rule may fail strict mode only when the two engines genuinely **diverge** on
|
|
54
|
+
* the form: there is data for which one engine yields a value and the other does
|
|
55
|
+
* not. That bar rules out two tempting kinds of rule, and both are deliberately
|
|
56
|
+
* excluded rather than merely unwritten.
|
|
57
|
+
*
|
|
58
|
+
* - **Anything that needs a CEL *type* to be a divergence.** Syntax does not
|
|
59
|
+
* establish a type. `x[0]` is a list index against one schema and a map lookup
|
|
60
|
+
* against another; `"k" in e` is fine when `e` is a map and rejected when
|
|
61
|
+
* cel-go's type env has typed `e` as a message. The real types live in the
|
|
62
|
+
* referenced resources' Kubernetes schemas, and nothing at this call site has
|
|
63
|
+
* them: the check receives the serialized status map — expression text naming
|
|
64
|
+
* graph resources by id — and TypeKro's `Enhanced<>` types are erased before
|
|
65
|
+
* serialization runs. A rule that guesses a type from bracket shape fails
|
|
66
|
+
* strict mode on valid CEL, so such rules are reported as notes instead.
|
|
67
|
+
* - **Forms both engines treat the same.** Indexing a required list inside a
|
|
68
|
+
* logical chain is the worked example: `size(l) > 0 && l[0].f != ""` returns
|
|
69
|
+
* `false` on an empty `l` in both engines (cel-js short-circuits, cel-go
|
|
70
|
+
* absorbs the error under a deciding `false`), and an out-of-range index
|
|
71
|
+
* errors on both. Nothing diverges, so nothing is reported.
|
|
72
|
+
*
|
|
73
|
+
* What the bar does **not** license is a claim about what KRO ultimately does
|
|
74
|
+
* with the field. Every rule here reasons about the grammar and about published
|
|
75
|
+
* engine behaviour; none of them models cel-go's *type checker*, which runs
|
|
76
|
+
* after the parse with KRO's type environment and function set and rejects
|
|
77
|
+
* plenty of grammatical CEL — `1.string()` parses on any conformant grammar and
|
|
78
|
+
* the checker still refuses it, `string` being a global conversion function
|
|
79
|
+
* rather than a member, and an unknown member function goes the same way. So a
|
|
80
|
+
* finding says "direct mode can never evaluate this field" where that is proven,
|
|
81
|
+
* and leaves KRO's verdict open. A rule stays a `divergence` on the strength of
|
|
82
|
+
* the disagreement it does establish: the engines differ on the *form*, at a
|
|
83
|
+
* stage that needs no type environment, which makes the emitted CEL defective
|
|
84
|
+
* for one of the two targets TypeKro serializes for whatever the other decides.
|
|
85
|
+
*
|
|
86
|
+
* Findings that do not clear the bar are still worth surfacing, so each rule
|
|
87
|
+
* declares a {@link CelDialectFindingKind}: `divergence` findings abort
|
|
88
|
+
* serialization in strict mode, `note` findings never do and are logged in both
|
|
89
|
+
* modes. Strictness otherwise follows the shared CEL diagnostics convention
|
|
90
|
+
* (`strictCelDiagnostics` factory option, `TYPEKRO_STRICT_CEL=1`).
|
|
91
|
+
*/
|
|
92
|
+
import { parse } from 'cel-js';
|
|
93
|
+
import { collectCelLambdaScopes, isCelLambdaLocalAt } from '../references/cel-lexical-scanner.js';
|
|
94
|
+
/**
|
|
95
|
+
* Largest expression this module will analyze, in characters.
|
|
96
|
+
*
|
|
97
|
+
* Both halves of the check cost roughly a microsecond per character — cel-js's
|
|
98
|
+
* parser dominates — so the cost is linear in the length of the expression and
|
|
99
|
+
* the only way it can run away is for the expression itself to run away.
|
|
100
|
+
*
|
|
101
|
+
* The budget is set from what TypeKro actually emits. Across the unit suite the
|
|
102
|
+
* median status expression is under 100 characters, the 99th percentile is
|
|
103
|
+
* ~1.6k, and the largest authored one is ~3.3k. 16 KiB leaves roughly five
|
|
104
|
+
* times the headroom over the largest real expression while capping the check
|
|
105
|
+
* at ~16ms for any single leaf.
|
|
106
|
+
*
|
|
107
|
+
* Anything past this is not an authored status field. The known source is a
|
|
108
|
+
* nested composition whose inlined status re-expands into itself, doubling per
|
|
109
|
+
* level until the depth limit stops it — which yields a multi-megabyte
|
|
110
|
+
* expression that no engine can use: cel-js needs seconds to parse it on every
|
|
111
|
+
* direct-mode reconcile, and a ResourceGraphDefinition carrying it is past the
|
|
112
|
+
* Kubernetes object size limit, so the API server refuses it outright.
|
|
113
|
+
*/
|
|
114
|
+
export const CEL_DIALECT_MAX_EXPRESSION_LENGTH = 16_384;
|
|
115
|
+
/**
|
|
116
|
+
* How many levels of bracket nesting the span walks will descend.
|
|
117
|
+
*
|
|
118
|
+
* Both the lazy-region blanking and the logical-chain walk recurse once per
|
|
119
|
+
* nested group, and they now run on text cel-js could not parse — where a
|
|
120
|
+
* runaway `((((…` is exactly the shape that shows up. Nesting is bounded by the
|
|
121
|
+
* expression length, so 16 KiB of open parens is 8k levels of recursion and a
|
|
122
|
+
* blown stack; the parse gate used to hide that by returning first.
|
|
123
|
+
*
|
|
124
|
+
* 64 is far past anything an emitted status expression reaches — the deepest in
|
|
125
|
+
* the unit suite is single digits — and stopping the descent only means a
|
|
126
|
+
* finding deeper than that is not reported, which is the safe direction.
|
|
127
|
+
*/
|
|
128
|
+
const CEL_DIALECT_MAX_NESTING_DEPTH = 64;
|
|
129
|
+
/**
|
|
130
|
+
* The curated denylist.
|
|
131
|
+
*
|
|
132
|
+
* Every entry names the engine that rejects the form, whether the entry counts
|
|
133
|
+
* as a divergence, and the observation behind it. Keep this table and
|
|
134
|
+
* {@link checkCelDialectCompatibility} in step.
|
|
135
|
+
*/
|
|
136
|
+
export const CEL_DIALECT_RULES = [
|
|
137
|
+
{
|
|
138
|
+
id: 'has-index-argument',
|
|
139
|
+
kind: 'divergence',
|
|
140
|
+
dialect: 'cel-js',
|
|
141
|
+
summary: 'has() applied to an index expression',
|
|
142
|
+
observed: 'cel-js raises "has() does not support atomic expressions" whenever the operand of has() is an index — `has(list[0].field)` and `has(map["k"].field)` alike, so this is about the shape of the macro argument and not about the type of what is indexed. cel-go\'s has() macro accepts any select expression, index included',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: 'guard-after-use-in-logical-chain',
|
|
146
|
+
kind: 'divergence',
|
|
147
|
+
dialect: 'cel-js',
|
|
148
|
+
summary: 'a has() guard placed to the right of the access it guards, in the operator\'s guarding polarity',
|
|
149
|
+
observed: 'cel-go absorbs an error in one operand of && / || when the other operand decides the result, regardless of order; cel-js evaluates left to right and propagates the error before the guard is ever reached. The divergence needs no type: the guard itself says the author expects the path to be absent sometimes. Which form is the guard follows the operator: `&&` is decided by `false`, so `has(p)` guards there, while `||` is decided by `true`, so `!has(p)` is the guarding form and the late-guard mirror',
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: 'heterogeneous-map-literal',
|
|
153
|
+
kind: 'divergence',
|
|
154
|
+
dialect: 'cel-js',
|
|
155
|
+
summary: 'a CEL map literal whose values are not all of one type',
|
|
156
|
+
observed: 'cel-js pins a map literal\'s value type to that of its first entry and throws "invalid_argument: <value>" on the first entry that differs (cel-js 0.8.2, `mapExpression` in its visitor), so `{"name": "http", "port": 80}` cannot be evaluated at all; cel-go types the literal as `map(string, dyn)` when the entry types differ and evaluates it. The divergence needs no schema: the differing types are written out in the literal itself. Lists are unaffected — cel-js evaluates `[1, "a"]` — and a single-entry or empty map is always fine',
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: 'in-on-list-entry',
|
|
160
|
+
kind: 'note',
|
|
161
|
+
dialect: 'cel-go',
|
|
162
|
+
summary: '`in` applied to something that may be a typed list entry',
|
|
163
|
+
observed: "KRO's cel-go type env types a *message* list entry as a message rather than a map and reports \"no matching overload for '@in'\", where cel-js accepts it. Whether the entry here is a message or a map is a fact about the referenced resource's schema, which this check cannot see, so the form is reported for information rather than failed",
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
id: 'cel-js-rejects-spec-cel',
|
|
167
|
+
kind: 'divergence',
|
|
168
|
+
dialect: 'cel-js',
|
|
169
|
+
summary: 'a form the CEL grammar permits and cel-js is known not to parse',
|
|
170
|
+
observed: "cel-js 0.8.2 is not a conformant CEL parser. Its `atomicExpression` rule (dist/parser.js) allows a postfix `.`/`[` only after an Identifier — plus one index after a list literal, and any postfix after a map literal — while the spec's `Member = Primary | Member \".\" SELECTOR [\"(\" [ExprList] \")\"] | Member \"[\" Expr \"]\"` allows a postfix on *any* Member, and `Primary` includes `LITERAL` and `\"(\" Expr \")\"` (cel-spec doc/langdef.md, \"Syntax\"). Its lexer is short of the spec's `FLOAT_LIT` (no EXPONENT form) and `STRING_LIT`/`BYTES_LIT` (no `r`/`R`/`b`/`B` prefix, no triple-quoted form). Each form is confirmed to fail `parse()` and is grammatical CEL. The entry fires only once the *whole* expression is shown to be grammatical: the identified forms are replaced by same-production spellings cel-js does have and cel-js is asked again, and only a rewrite cel-js accepts whole — every character lexed, the token stream parsed — establishes that the shortfall is the sole obstruction rather than one of several problems in the text. What that proves is that the grammar permits the form and cel-js's refusal is its own shortfall, so direct mode can never evaluate the field; it is a divergence in the form rather than a defect in the expression. It does not establish what KRO then does with the field: cel-go parses the form, but its type checker runs afterwards with KRO's type environment and function set, which this module does not model — `1.string()` is grammatical and cel-go's checker still rejects it, `string` being a global conversion function rather than a member",
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: 'not-valid-cel',
|
|
174
|
+
kind: 'note',
|
|
175
|
+
dialect: 'both',
|
|
176
|
+
summary: 'the text contains something no CEL grammar accepts',
|
|
177
|
+
observed: "matched against the spec's own lexical and syntactic grammar rather than against an engine: `=` outside `==`/`!=`/`<=`/`>=` is not a CEL token at all (the punctuation list in cel-spec doc/langdef.md is `() [] {} . , ? : || && ! < <= >= > == != in + - * / %`), `$` is in neither the punctuation list nor `IDENT`, and a `?` with no matching `:` cannot close `Expr = ConditionalOr [\"?\" ConditionalOr \":\" Expr]`. These are the converter-leakage forms — `===`, `!==`, `=>`, `${`, `?.`, `?[`. Neither engine can evaluate them, so this is a defect rather than a divergence. Note cel-js's *lexer* silently drops a character it has no token for, so `a === b` reaches its parser as `a == b` and \"parses\" — this check therefore runs whether or not cel-js parsed",
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: 'cel-js-parse-failure',
|
|
181
|
+
kind: 'note',
|
|
182
|
+
dialect: 'cel-js',
|
|
183
|
+
summary: 'cel-js cannot parse the expression and no positive verdict was reached',
|
|
184
|
+
observed: "cel-js's parser rejected the text and nothing above accounts for it: either no confirmed cel-js shortfall was identified, or one was and rewriting it away still left text cel-js refuses — which means the expression has a further problem of its own, and an expression that is ungrammatical elsewhere is rejected by cel-go too. A cel-js parse failure on its own says only that *direct mode* cannot evaluate the field: cel-js is not a conformant CEL grammar, so it establishes nothing about cel-go or KRO. Reported so the field is visible, never failed",
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
id: 'expression-too-large',
|
|
188
|
+
kind: 'note',
|
|
189
|
+
dialect: 'unchecked',
|
|
190
|
+
summary: 'the expression is past the analysis budget, so neither half of the check ran',
|
|
191
|
+
observed: 'a nested composition whose inlined status re-expands into itself doubles the expression per level, reaching megabytes; parsing one costs seconds per call and the ResourceGraphDefinition carrying it is past the Kubernetes object size limit. No dialect verdict was reached, so there is no divergence to report',
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
/** True when any finding in the set may fail strict mode. */
|
|
195
|
+
export function hasCelDialectDivergence(findings) {
|
|
196
|
+
return findings.some((found) => found.kind === 'divergence');
|
|
197
|
+
}
|
|
198
|
+
const CLOSERS = { ')': '(', ']': '[', '}': '{' };
|
|
199
|
+
/** Counters behind {@link celDialectWorkStats}; see it for why they exist. */
|
|
200
|
+
let workIndexBuilds = 0;
|
|
201
|
+
let workIndexedCharacters = 0;
|
|
202
|
+
let workLookups = 0;
|
|
203
|
+
/** Read the work counters. */
|
|
204
|
+
export function celDialectWorkStats() {
|
|
205
|
+
return {
|
|
206
|
+
indexBuilds: workIndexBuilds,
|
|
207
|
+
indexedCharacters: workIndexedCharacters,
|
|
208
|
+
lookups: workLookups,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/** Zero the work counters, so one check can be measured on its own. */
|
|
212
|
+
export function resetCelDialectWorkStats() {
|
|
213
|
+
workIndexBuilds = 0;
|
|
214
|
+
workIndexedCharacters = 0;
|
|
215
|
+
workLookups = 0;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Resolve every bracket pair in `text` in one left-to-right stack pass.
|
|
219
|
+
*
|
|
220
|
+
* A closer that does not match the opener on top of the stack is text no scan
|
|
221
|
+
* could have got past either: a walk forward from any opener still on the stack
|
|
222
|
+
* would reach this character with the same pending brackets and give up, so all
|
|
223
|
+
* of them are recorded unmatched and the stack is cleared. An opener that starts
|
|
224
|
+
* *after* the offending closer is unaffected, which is what a forward scan from
|
|
225
|
+
* it would also have found.
|
|
226
|
+
*/
|
|
227
|
+
function buildBracketIndex(text) {
|
|
228
|
+
workIndexBuilds += 1;
|
|
229
|
+
workIndexedCharacters += text.length;
|
|
230
|
+
const close = new Int32Array(text.length).fill(-1);
|
|
231
|
+
const stack = [];
|
|
232
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
233
|
+
const character = text[index];
|
|
234
|
+
if (character === '(' || character === '[' || character === '{') {
|
|
235
|
+
stack.push(index);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const opener = CLOSERS[character];
|
|
239
|
+
if (opener === undefined)
|
|
240
|
+
continue;
|
|
241
|
+
const top = stack.length === 0 ? -1 : stack[stack.length - 1];
|
|
242
|
+
if (top >= 0 && text[top] === opener) {
|
|
243
|
+
stack.pop();
|
|
244
|
+
close[top] = index;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
stack.length = 0;
|
|
248
|
+
}
|
|
249
|
+
return { close };
|
|
250
|
+
}
|
|
251
|
+
/** Index of the bracket matching the one at `open`, or -1. */
|
|
252
|
+
function matchingParen(brackets, open) {
|
|
253
|
+
workLookups += 1;
|
|
254
|
+
return open >= 0 && open < brackets.close.length ? brackets.close[open] : -1;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Split `[start, end)` on the given separators, ignoring anything nested inside
|
|
258
|
+
* brackets. Separators are matched as whole tokens.
|
|
259
|
+
*
|
|
260
|
+
* A matched bracket group at depth zero is stepped over in one index lookup
|
|
261
|
+
* rather than character by character, which is what keeps the recursive descent
|
|
262
|
+
* in {@link checkChain} from re-reading the same nested text once per level. The
|
|
263
|
+
* depth counter stays for the unbalanced case — a group whose closer is missing
|
|
264
|
+
* or falls outside the span has no entry to jump to, and the old scan is then
|
|
265
|
+
* exactly the right behaviour.
|
|
266
|
+
*/
|
|
267
|
+
function splitTopLevel(masked, span, separators, brackets) {
|
|
268
|
+
const parts = [];
|
|
269
|
+
let depth = 0;
|
|
270
|
+
let partStart = span.start;
|
|
271
|
+
let index = span.start;
|
|
272
|
+
while (index < span.end) {
|
|
273
|
+
const character = masked[index];
|
|
274
|
+
if (character === '(' || character === '[' || character === '{') {
|
|
275
|
+
if (depth === 0) {
|
|
276
|
+
const close = matchingParen(brackets, index);
|
|
277
|
+
if (close >= 0 && close < span.end) {
|
|
278
|
+
index = close + 1;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
depth += 1;
|
|
283
|
+
index += 1;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (character === ')' || character === ']' || character === '}') {
|
|
287
|
+
depth -= 1;
|
|
288
|
+
index += 1;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (depth === 0) {
|
|
292
|
+
const separator = separators.find((candidate) => masked.startsWith(candidate, index));
|
|
293
|
+
if (separator !== undefined) {
|
|
294
|
+
parts.push({ start: partStart, end: index });
|
|
295
|
+
index += separator.length;
|
|
296
|
+
partStart = index;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
index += 1;
|
|
301
|
+
}
|
|
302
|
+
parts.push({ start: partStart, end: span.end });
|
|
303
|
+
return parts;
|
|
304
|
+
}
|
|
305
|
+
/** Top-level parenthesized groups inside a span, as interior spans. */
|
|
306
|
+
function parenGroups(masked, span, brackets) {
|
|
307
|
+
const groups = [];
|
|
308
|
+
let index = span.start;
|
|
309
|
+
while (index < span.end) {
|
|
310
|
+
if (masked[index] === '(') {
|
|
311
|
+
const close = matchingParen(brackets, index);
|
|
312
|
+
if (close < 0 || close > span.end)
|
|
313
|
+
break;
|
|
314
|
+
groups.push({ start: index + 1, end: close });
|
|
315
|
+
index = close + 1;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
index += 1;
|
|
319
|
+
}
|
|
320
|
+
return groups;
|
|
321
|
+
}
|
|
322
|
+
const DOTTED_PATH = /[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+/g;
|
|
323
|
+
function blankRange(text, start, end) {
|
|
324
|
+
return text.slice(0, start) + ' '.repeat(Math.max(0, end - start)) + text.slice(end);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Blank out the regions of an expression that are *not* part of the enclosing
|
|
328
|
+
* logical chain's eager evaluation:
|
|
329
|
+
*
|
|
330
|
+
* - **Collection-macro bodies.** `list.exists(c, ...)` binds `c` per entry, and
|
|
331
|
+
* a `has(c.x)` in one operand's lambda says nothing about another operand's
|
|
332
|
+
* lambda variable of the same name.
|
|
333
|
+
* - **Nested ternaries.** A parenthesized group containing a top-level `?` is
|
|
334
|
+
* lazy in both engines, so its branches cannot make the chain diverge. The
|
|
335
|
+
* group is analyzed on its own by the recursion in `checkLogicalChain`.
|
|
336
|
+
*
|
|
337
|
+
* Offsets are preserved so spans stay valid against the original expression.
|
|
338
|
+
*
|
|
339
|
+
* The bracket index is the one built for `masked`, and stays correct here
|
|
340
|
+
* because every region blanked is a bracket *interior*: a lambda body runs from
|
|
341
|
+
* after the macro's `(` to before its `)`, and a lazy ternary group is blanked
|
|
342
|
+
* between its own parens. Blanking therefore only ever removes whole matched
|
|
343
|
+
* pairs, so a bracket that survives into `blanked` still closes where the index
|
|
344
|
+
* says it does, and a bracket that does not survive is a space nothing asks
|
|
345
|
+
* about.
|
|
346
|
+
*/
|
|
347
|
+
function blankLazyRegions(masked, brackets) {
|
|
348
|
+
let blanked = masked;
|
|
349
|
+
for (const scope of collectCelLambdaScopes(masked)) {
|
|
350
|
+
blanked = blankRange(blanked, scope.bodyStart, scope.bodyEnd);
|
|
351
|
+
}
|
|
352
|
+
const blankTernaryGroups = (span, depth) => {
|
|
353
|
+
if (depth > CEL_DIALECT_MAX_NESTING_DEPTH)
|
|
354
|
+
return;
|
|
355
|
+
let index = span.start;
|
|
356
|
+
while (index < span.end) {
|
|
357
|
+
if (blanked[index] === '(') {
|
|
358
|
+
const close = matchingParen(brackets, index);
|
|
359
|
+
if (close < 0 || close > span.end)
|
|
360
|
+
return;
|
|
361
|
+
const interior = { start: index + 1, end: close };
|
|
362
|
+
if (splitTopLevel(blanked, interior, ['?'], brackets).length > 1) {
|
|
363
|
+
blanked = blankRange(blanked, interior.start, interior.end);
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
blankTernaryGroups(interior, depth + 1);
|
|
367
|
+
}
|
|
368
|
+
index = close + 1;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
index += 1;
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
blankTernaryGroups({ start: 0, end: blanked.length }, 0);
|
|
375
|
+
return blanked;
|
|
376
|
+
}
|
|
377
|
+
const GUARD_OPERAND = /^has\s*\(\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\)\s*$/;
|
|
378
|
+
/**
|
|
379
|
+
* Read one operand as a possibly-negated `has()` guard.
|
|
380
|
+
*
|
|
381
|
+
* Deliberately whole-operand: the operand has to reduce, after stripping
|
|
382
|
+
* whitespace, `!` operators and balanced enclosing parentheses, to exactly
|
|
383
|
+
* `has(<dotted path>)`. That is what makes the *polarity* readable. A `has()`
|
|
384
|
+
* buried in a larger boolean operand — `has(p) == true`, `x || has(p)` inside a
|
|
385
|
+
* conjunct — has no single polarity with respect to the enclosing chain, so it
|
|
386
|
+
* is not read as a guard in either direction: it neither establishes a path nor
|
|
387
|
+
* is reported as a late guard.
|
|
388
|
+
*
|
|
389
|
+
* Group negation is handled to the extent the masked-span machinery allows:
|
|
390
|
+
* `!has(p)`, `!(has(p))` and `!!has(p)` all parse, because stripping is purely
|
|
391
|
+
* lexical and each step keeps the operand a single `has()`. `!(has(p) && q)`
|
|
392
|
+
* does not, and is treated as "not a guard" rather than guessed at — negating a
|
|
393
|
+
* compound is not a statement about `p` on its own.
|
|
394
|
+
*/
|
|
395
|
+
function chainGuard(blanked, span, brackets) {
|
|
396
|
+
// Stripping narrows a span rather than re-slicing a string, so the enclosing
|
|
397
|
+
// parens can be matched out of the shared index instead of rescanned.
|
|
398
|
+
let start = span.start;
|
|
399
|
+
let end = span.end;
|
|
400
|
+
let negated = false;
|
|
401
|
+
const trim = () => {
|
|
402
|
+
while (start < end && /\s/.test(blanked[start]))
|
|
403
|
+
start += 1;
|
|
404
|
+
while (end > start && /\s/.test(blanked[end - 1]))
|
|
405
|
+
end -= 1;
|
|
406
|
+
};
|
|
407
|
+
trim();
|
|
408
|
+
while (start < end) {
|
|
409
|
+
if (blanked[start] === '!') {
|
|
410
|
+
negated = !negated;
|
|
411
|
+
start += 1;
|
|
412
|
+
trim();
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (blanked[start] === '(' && matchingParen(brackets, start) === end - 1) {
|
|
416
|
+
start += 1;
|
|
417
|
+
end -= 1;
|
|
418
|
+
trim();
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
const path = GUARD_OPERAND.exec(blanked.slice(start, end))?.[1];
|
|
424
|
+
return path === undefined ? undefined : { path, negated };
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* The path an operand guards for the operands to its right, if any.
|
|
428
|
+
*
|
|
429
|
+
* A guard only guards when its truth value is the chain's absorbing value
|
|
430
|
+
* exactly where the path is missing: `has(p)` is `false` when `p` is absent, so
|
|
431
|
+
* it guards in an `&&` chain; `!has(p)` is `true` when `p` is absent, so it
|
|
432
|
+
* guards in an `||` chain. A guard of the wrong polarity — `!has(p)` in `&&`,
|
|
433
|
+
* `has(p)` in `||` — establishes nothing, because the chain carries on into the
|
|
434
|
+
* access precisely when the path is absent.
|
|
435
|
+
*
|
|
436
|
+
* The same predicate decides the divergent *late* guard, and for the same
|
|
437
|
+
* reason: a late guard diverges only when cel-go's absorption of the earlier
|
|
438
|
+
* error is what decides the chain. In `p.f == 1 && has(p)` cel-go yields
|
|
439
|
+
* `false` where cel-js errors — a divergence — while in `p.f == 1 && !has(p)`
|
|
440
|
+
* cel-go's absorbed error meets a `true` and stays an error, which is what
|
|
441
|
+
* cel-js does too.
|
|
442
|
+
*/
|
|
443
|
+
function guardsEstablishedBy(blanked, span, mode, brackets) {
|
|
444
|
+
const guard = chainGuard(blanked, span, brackets);
|
|
445
|
+
if (guard === undefined)
|
|
446
|
+
return [];
|
|
447
|
+
return guard.negated === (mode === 'or') ? [guard.path] : [];
|
|
448
|
+
}
|
|
449
|
+
/** True when `guard` covers `path` — the same path, or an ancestor of it. */
|
|
450
|
+
function guardCovers(guard, path) {
|
|
451
|
+
return path === guard || path.startsWith(`${guard}.`);
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* True when a guard already written to the left makes a later one redundant.
|
|
455
|
+
*
|
|
456
|
+
* The direction matters and is the opposite of {@link guardCovers}. Covering a
|
|
457
|
+
* path is about *reaching* it: `has(a.status)` covers the dereference
|
|
458
|
+
* `a.status.list[0]` because it guards an ancestor of it. Establishing a guard
|
|
459
|
+
* is about *presence*: only a guard at least as specific as the later one says
|
|
460
|
+
* the later one's path exists.
|
|
461
|
+
*
|
|
462
|
+
* So `has(a.status)` does not establish `has(a.status.list)` — the status object
|
|
463
|
+
* being present says nothing about the list — while `has(a.status.list.deeper)`
|
|
464
|
+
* does, because CEL evaluates the receiver of a `has()` before testing the last
|
|
465
|
+
* field: for `has(a.status.list.deeper)` to have returned true rather than
|
|
466
|
+
* errored, `a.status.list` had to be present.
|
|
467
|
+
*
|
|
468
|
+
* @param earlier A guard written to the left of the operand in question.
|
|
469
|
+
* @param later The guard written to its right.
|
|
470
|
+
*/
|
|
471
|
+
function guardEstablishes(earlier, later) {
|
|
472
|
+
return guardCovers(later, earlier);
|
|
473
|
+
}
|
|
474
|
+
/** Dotted paths dereferenced in a span, excluding those that are `has()` arguments. */
|
|
475
|
+
function dereferencedPaths(masked, span, brackets) {
|
|
476
|
+
let slice = masked.slice(span.start, span.end);
|
|
477
|
+
// Blank out has() arguments: naming a path inside has() is not a dereference.
|
|
478
|
+
// The scan runs over the whole text from `span.start` rather than over the
|
|
479
|
+
// slice, so the shared bracket index answers where each `has(` closes; a
|
|
480
|
+
// `has(` whose closer falls outside the span is left alone, which is what
|
|
481
|
+
// matching inside the slice alone used to arrive at.
|
|
482
|
+
const pattern = /\bhas\s*\(/g;
|
|
483
|
+
pattern.lastIndex = span.start;
|
|
484
|
+
const blanks = [];
|
|
485
|
+
let match = pattern.exec(masked);
|
|
486
|
+
while (match !== null && match.index < span.end) {
|
|
487
|
+
const open = match.index + match[0].length - 1;
|
|
488
|
+
const close = matchingParen(brackets, open);
|
|
489
|
+
if (close > open && close < span.end) {
|
|
490
|
+
blanks.push([match.index - span.start, close + 1 - span.start]);
|
|
491
|
+
}
|
|
492
|
+
match = pattern.exec(masked);
|
|
493
|
+
}
|
|
494
|
+
for (const [start, end] of blanks.reverse()) {
|
|
495
|
+
slice = slice.slice(0, start) + ' '.repeat(end - start) + slice.slice(end);
|
|
496
|
+
}
|
|
497
|
+
return [...slice.matchAll(DOTTED_PATH)].map((found) => found[0]);
|
|
498
|
+
}
|
|
499
|
+
/** Mask one expression into the three alignments the rules read. */
|
|
500
|
+
function celDialectText(reported) {
|
|
501
|
+
const { source, masked } = maskCelCommentsAndStrings(reported);
|
|
502
|
+
// One comparison, and the single thing every offset in this module rests on.
|
|
503
|
+
// The failure it guards against is silent rather than loud: a mask that
|
|
504
|
+
// walked by code point would blank an astral character to *one* space, and
|
|
505
|
+
// every span after it would index the wrong characters — a fragment cut one
|
|
506
|
+
// unit short, a bracket index pointing between two tokens, a literal re-lexed
|
|
507
|
+
// from the wrong place — with no symptom but a wrong finding, and only on
|
|
508
|
+
// expressions that happen to carry an emoji.
|
|
509
|
+
if (source.length !== reported.length || masked.length !== reported.length) {
|
|
510
|
+
throw new Error(`cel-dialect: the lexical mask changed the length of the expression (${reported.length} in, ${source.length}/${masked.length} out). Every offset in this module is a UTF-16 code-unit offset shared across all three texts`);
|
|
511
|
+
}
|
|
512
|
+
return { reported, source, masked };
|
|
513
|
+
}
|
|
514
|
+
function finding(rule, field, expression, fragment, message, suggestion) {
|
|
515
|
+
const entry = CEL_DIALECT_RULES.find((candidate) => candidate.id === rule);
|
|
516
|
+
return {
|
|
517
|
+
rule,
|
|
518
|
+
kind: entry?.kind ?? 'note',
|
|
519
|
+
dialect: entry?.dialect ?? 'cel-js',
|
|
520
|
+
field,
|
|
521
|
+
expression,
|
|
522
|
+
...(fragment === undefined ? {} : { fragment }),
|
|
523
|
+
message,
|
|
524
|
+
suggestion,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Rule: `has()` whose argument is an index expression.
|
|
529
|
+
*
|
|
530
|
+
* Any index counts, numeric or string-keyed. That is not an inference about what
|
|
531
|
+
* is being indexed: cel-js rejects the *shape* of the macro argument, throwing
|
|
532
|
+
* "has() does not support atomic expressions" for `has(list[0].f)` and
|
|
533
|
+
* `has(map["k"].f)` alike, while cel-go's has() accepts any select expression.
|
|
534
|
+
* The divergence is established without knowing a single type.
|
|
535
|
+
*
|
|
536
|
+
* What that establishes is that direct mode can never evaluate the field. It
|
|
537
|
+
* does not establish that KRO does: cel-go's macro expansion is not its type
|
|
538
|
+
* checker, and the checker runs afterwards with KRO's type environment. The
|
|
539
|
+
* message says so rather than promising the field resolves under KRO.
|
|
540
|
+
*/
|
|
541
|
+
function checkHasIndexArgument(text, field, findings, brackets) {
|
|
542
|
+
const masked = text.masked;
|
|
543
|
+
const pattern = /\bhas\s*\(/g;
|
|
544
|
+
let match = pattern.exec(masked);
|
|
545
|
+
while (match !== null) {
|
|
546
|
+
const open = match.index + match[0].length - 1;
|
|
547
|
+
const close = matchingParen(brackets, open);
|
|
548
|
+
if (close > open && masked.slice(open + 1, close).includes('[')) {
|
|
549
|
+
const fragment = text.source.slice(match.index, close + 1);
|
|
550
|
+
findings.push(finding('has-index-argument', field, text.reported, fragment, 'cel-js rejects has() whose operand is an index expression ("has() does not support atomic expressions") while cel-go\'s has() macro accepts any select expression, index included. Direct mode can therefore never evaluate this field. Whether KRO evaluates it depends on cel-go\'s type checker and function environment, which this check does not model', 'Select entries with `list.filter(entry, has(entry.field))` inside a lazy ternary — Cel.firstWhereHas() emits exactly that'));
|
|
551
|
+
}
|
|
552
|
+
match = pattern.exec(masked);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* A value that is exactly one whole CEL string literal and nothing else.
|
|
557
|
+
*
|
|
558
|
+
* This is cel-js's own `StringLiteral` token pattern anchored to the entire
|
|
559
|
+
* value: either quote style, `\`-escapes, no newline inside. It is deliberately
|
|
560
|
+
* *not* derived from the masked text. `maskCelStringLiterals` overwrites the
|
|
561
|
+
* open quote, the interior and the close quote alike with spaces, and ordinary
|
|
562
|
+
* inter-token whitespace is spaces already, so a masked string is
|
|
563
|
+
* indistinguishable from a run of blanks and the closing quote's offset is not
|
|
564
|
+
* recoverable from the mask — `"a" "b"` and `"a b"` mask identically. Re-lexing
|
|
565
|
+
* the one token off the original text is what actually settles where the
|
|
566
|
+
* literal ends.
|
|
567
|
+
*
|
|
568
|
+
* cel-js's grammar has no raw (`r"..."`), bytes (`b"..."`) or triple-quoted
|
|
569
|
+
* string form, and nothing in the tree emits one, so a value opening with `r` or
|
|
570
|
+
* `b` simply fails to match here and is left unclassified — the safe direction.
|
|
571
|
+
*
|
|
572
|
+
* That is why this pattern deliberately does **not** follow
|
|
573
|
+
* {@link celStringLiteralEnd} in suppressing escapes inside a raw literal: the
|
|
574
|
+
* two answer different questions. `celStringLiteralEnd` asks where a *spec*
|
|
575
|
+
* token ends, so it has to get the raw rule right; this asks whether cel-js
|
|
576
|
+
* would read the value as one of *its* string literals, and cel-js has no raw
|
|
577
|
+
* form to read. Widening this to admit `r"..."` would be a claim about cel-js's
|
|
578
|
+
* typing of a value cel-js cannot even lex, so a raw literal stays `undefined`
|
|
579
|
+
* and takes its map entry out of the comparison, which is where it belongs.
|
|
580
|
+
*/
|
|
581
|
+
const WHOLE_STRING_LITERAL = /^(?:"(?:[^"\n\\]|\\[\s\S])*"|'(?:[^'\n\\]|\\[\s\S])*')$/;
|
|
582
|
+
/**
|
|
583
|
+
* The CEL type a value expression visibly *is*, when the syntax settles it.
|
|
584
|
+
*
|
|
585
|
+
* Only *whole* literals are classified, and that is a statement about the entire
|
|
586
|
+
* value rather than about its first character. A value that merely *opens* with
|
|
587
|
+
* a literal is a different type as often as not: `"x".size()` and `[1,2].size()`
|
|
588
|
+
* are ints, `"s".startsWith("t")` is a bool, and `"x" + y` is a string only by
|
|
589
|
+
* luck. So a string value has to match {@link WHOLE_STRING_LITERAL} end to end,
|
|
590
|
+
* and a list or map value is whole only when `matchingParen` of its opening
|
|
591
|
+
* bracket lands on the value's last character. Numbers, bools and `null` are
|
|
592
|
+
* anchored patterns already and stay as they are. `1 + 2` starts with a digit
|
|
593
|
+
* and is still not classified, because the point is to be certain rather than
|
|
594
|
+
* clever: an identifier, a call, a ternary or any arithmetic yields `undefined`
|
|
595
|
+
* and takes its entry out of the comparison entirely.
|
|
596
|
+
*
|
|
597
|
+
* Brackets are matched out of `brackets`, which was built over the *masked*
|
|
598
|
+
* text, so a `]` or `}` sitting inside a string cannot pose as the closer; the
|
|
599
|
+
* classification itself reads `expression`, since masking is what erases the
|
|
600
|
+
* quotes that make a value a string. Masking preserves offsets, so `span` and
|
|
601
|
+
* the index address the same characters.
|
|
602
|
+
*
|
|
603
|
+
* `int` and `double` are separate classes because cel-js separates them —
|
|
604
|
+
* `{"a": 1, "b": 2.5}` is as rejected as `{"a": 1, "b": "x"}`.
|
|
605
|
+
*/
|
|
606
|
+
function literalTypeClass(expression, span, brackets) {
|
|
607
|
+
let start = span.start;
|
|
608
|
+
let end = span.end;
|
|
609
|
+
while (start < end && /\s/.test(expression[start]))
|
|
610
|
+
start += 1;
|
|
611
|
+
while (end > start && /\s/.test(expression[end - 1]))
|
|
612
|
+
end -= 1;
|
|
613
|
+
if (start === end)
|
|
614
|
+
return undefined;
|
|
615
|
+
const value = expression.slice(start, end);
|
|
616
|
+
if (value === 'true' || value === 'false')
|
|
617
|
+
return 'bool';
|
|
618
|
+
if (value === 'null')
|
|
619
|
+
return 'null';
|
|
620
|
+
if (/^-?\d+u$/.test(value))
|
|
621
|
+
return 'uint';
|
|
622
|
+
if (/^-?\d+$/.test(value))
|
|
623
|
+
return 'int';
|
|
624
|
+
if (/^-?(?:\d+\.\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(value))
|
|
625
|
+
return 'double';
|
|
626
|
+
if (WHOLE_STRING_LITERAL.test(value))
|
|
627
|
+
return 'string';
|
|
628
|
+
if (value.startsWith('[') && matchingParen(brackets, start) === end - 1)
|
|
629
|
+
return 'list';
|
|
630
|
+
if (value.startsWith('{') && matchingParen(brackets, start) === end - 1)
|
|
631
|
+
return 'map';
|
|
632
|
+
return undefined;
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Rule: a map literal whose entry values are not all of the same CEL type.
|
|
636
|
+
*
|
|
637
|
+
* Every `{...}` in the expression is checked, nested ones included, and the
|
|
638
|
+
* classification is purely syntactic: two entries have to carry *visibly*
|
|
639
|
+
* different literal types before anything is reported, so a map whose values
|
|
640
|
+
* are identifiers or calls is left alone even though cel-js may still refuse it
|
|
641
|
+
* at runtime. Under-reporting is the deliberate direction — the alternative is
|
|
642
|
+
* failing strict mode on a map the checker merely cannot read.
|
|
643
|
+
*
|
|
644
|
+
* Masking matters here: `masked` settles the structure, since a `,` or `:`
|
|
645
|
+
* inside a string is not a separator, while the classification reads the
|
|
646
|
+
* original text, since masking is what erases the quotes that make a value a
|
|
647
|
+
* string. Offsets are shared, so the same spans index both, and
|
|
648
|
+
* {@link literalTypeClass} is handed the span rather than the sliced text so it
|
|
649
|
+
* can read the original while matching brackets out of the masked index.
|
|
650
|
+
*/
|
|
651
|
+
function checkHeterogeneousMapLiteral(text, field, findings, brackets) {
|
|
652
|
+
const masked = text.masked;
|
|
653
|
+
for (let index = 0; index < masked.length; index += 1) {
|
|
654
|
+
if (masked[index] !== '{')
|
|
655
|
+
continue;
|
|
656
|
+
const close = matchingParen(brackets, index);
|
|
657
|
+
if (close < 0)
|
|
658
|
+
continue;
|
|
659
|
+
const classes = new Map();
|
|
660
|
+
for (const entry of splitTopLevel(masked, { start: index + 1, end: close }, [','], brackets)) {
|
|
661
|
+
const [, afterKey] = splitTopLevel(masked, entry, [':'], brackets);
|
|
662
|
+
if (afterKey === undefined)
|
|
663
|
+
continue;
|
|
664
|
+
const span = { start: afterKey.start, end: entry.end };
|
|
665
|
+
const found = literalTypeClass(text.source, span, brackets);
|
|
666
|
+
if (found !== undefined && !classes.has(found)) {
|
|
667
|
+
classes.set(found, text.source.slice(span.start, span.end).trim());
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (classes.size > 1) {
|
|
671
|
+
const [first, second] = [...classes.entries()];
|
|
672
|
+
findings.push(finding('heterogeneous-map-literal', field, text.reported, text.source.slice(index, close + 1), `this map literal mixes ${first?.[0]} (${first?.[1]}) and ${second?.[0]} (${second?.[1]}) values. cel-js takes the map's value type from its first entry and throws "invalid_argument" on the first entry that differs, so it cannot evaluate this map at all; cel-go types the literal as map(string, dyn) and evaluates it. Direct mode can therefore never evaluate this field. Whether KRO evaluates it depends on the rest of cel-go's type checking under KRO's environment, which this check does not model`, 'Give the entries one value type — `string(...)` around the odd ones out is usually enough — or reference an object of the right shape instead of writing a literal, which is what a KubernetesRef or CEL expression of that type does'));
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
/** Rule: `in` whose right-hand operand may be a typed list entry. */
|
|
677
|
+
function checkInOnListEntry(text, field, findings) {
|
|
678
|
+
const masked = text.masked;
|
|
679
|
+
const lambdaScopes = collectCelLambdaScopes(text.source);
|
|
680
|
+
const pattern = /\bin\b/g;
|
|
681
|
+
let match = pattern.exec(masked);
|
|
682
|
+
while (match !== null) {
|
|
683
|
+
let cursor = match.index + 2;
|
|
684
|
+
while (cursor < masked.length && masked[cursor] === ' ')
|
|
685
|
+
cursor += 1;
|
|
686
|
+
const operandStart = cursor;
|
|
687
|
+
let depth = 0;
|
|
688
|
+
while (cursor < masked.length) {
|
|
689
|
+
const character = masked[cursor];
|
|
690
|
+
if (character === '[' || character === '(' || character === '{')
|
|
691
|
+
depth += 1;
|
|
692
|
+
else if (character === ']' || character === ')' || character === '}') {
|
|
693
|
+
if (depth === 0)
|
|
694
|
+
break;
|
|
695
|
+
depth -= 1;
|
|
696
|
+
}
|
|
697
|
+
else if (depth === 0 && !/[\w$.]/.test(character))
|
|
698
|
+
break;
|
|
699
|
+
cursor += 1;
|
|
700
|
+
}
|
|
701
|
+
const operand = masked.slice(operandStart, cursor);
|
|
702
|
+
const root = /^[A-Za-z_$][\w$]*/.exec(operand)?.[0];
|
|
703
|
+
// Two shapes that *may* denote a single entry of a list: an index
|
|
704
|
+
// expression, and a variable bound by a collection macro. Neither says what
|
|
705
|
+
// the entry's CEL type is — `in` is correct on a map entry and rejected on a
|
|
706
|
+
// message one, and that distinction lives in the resource's schema, which is
|
|
707
|
+
// not reachable here. Hence a note rather than a strict-mode failure.
|
|
708
|
+
const mayBeListEntry = operand.includes('[') ||
|
|
709
|
+
(root !== undefined && isCelLambdaLocalAt(root, operandStart, lambdaScopes));
|
|
710
|
+
if (operand.length > 0 && mayBeListEntry) {
|
|
711
|
+
findings.push(finding('in-on-list-entry', field, text.reported, text.source.slice(match.index, cursor), `\`in\` is applied to '${operand}', which may be a single list entry. If KRO's cel-go type env types that entry as a message rather than a map it rejects \`in\` on it ("no matching overload for '@in'") where cel-js accepts it. Whether it does is a fact about the resource's schema, which this check cannot see — so this is reported, not failed`, 'If the entry is a message, test the field with has() instead: `list.filter(entry, has(entry.field))`'));
|
|
712
|
+
}
|
|
713
|
+
match = pattern.exec(masked);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* The one rule that only makes sense across the operands of an `&&` / `||`
|
|
718
|
+
* chain: a `has()` guard that sits to the right of the access it guards.
|
|
719
|
+
*
|
|
720
|
+
* A companion rule used to live here — `unguarded-index-in-logical-chain`,
|
|
721
|
+
* flagging `list[0].f` inside a chain with no guard before it — and it has been
|
|
722
|
+
* removed because it was not a divergence. On `size(l) > 0 && l[0].f != ""` with
|
|
723
|
+
* an empty `l`, cel-js short-circuits on the `false` and never indexes, while
|
|
724
|
+
* cel-go absorbs the index error under the deciding `false`: both return
|
|
725
|
+
* `false`. On an out-of-range index with no deciding operand, both error. There
|
|
726
|
+
* is no data that separates the engines, so indexing a required list is simply
|
|
727
|
+
* valid on both and reporting it failed strict mode on correct CEL.
|
|
728
|
+
*
|
|
729
|
+
* ## The chain model
|
|
730
|
+
*
|
|
731
|
+
* `&&` binds tighter than `||`, so a span is read as a disjunction of
|
|
732
|
+
* conjunctions: split on `||` first, and split each disjunct on `&&`.
|
|
733
|
+
* Flattening the two into one operand list would let a guard in one `||`
|
|
734
|
+
* disjunct reach an access in another, which it never does — `A || B && C` is
|
|
735
|
+
* `A || (B && C)`, and nothing in `A` runs before `B`.
|
|
736
|
+
*
|
|
737
|
+
* Within one chain, guards carry left to right with the polarity
|
|
738
|
+
* {@link guardsEstablishedBy} describes, and what an operand has established
|
|
739
|
+
* carries *into* that operand's own nested chains and parenthesized groups: in
|
|
740
|
+
* `has(p) && (p.f > 0 || x)` the group is only reached once `has(p)` held.
|
|
741
|
+
*
|
|
742
|
+
* ## Stated limits
|
|
743
|
+
*
|
|
744
|
+
* - Only a whole-operand `has()` is read as a guard, so a `has()` folded into a
|
|
745
|
+
* larger boolean operand neither establishes a path nor is reported as a late
|
|
746
|
+
* guard.
|
|
747
|
+
* - Negation is lexical: `!has(p)`, `!(has(p))` and `!!has(p)` are understood;
|
|
748
|
+
* `!(has(p) && q)` is not read as a guard at all.
|
|
749
|
+
* - Guards inside a collection-macro body or a lazy ternary group are blanked
|
|
750
|
+
* before this runs (see {@link blankLazyRegions}) and so never carry out of
|
|
751
|
+
* the region that binds them.
|
|
752
|
+
*
|
|
753
|
+
* Every limit is in the direction of reporting less, so what escapes the model
|
|
754
|
+
* is a missed finding rather than a strict-mode failure on valid CEL.
|
|
755
|
+
*/
|
|
756
|
+
function checkLogicalChain(text, blanked, field, span, established, findings, depth, brackets) {
|
|
757
|
+
if (depth > CEL_DIALECT_MAX_NESTING_DEPTH)
|
|
758
|
+
return;
|
|
759
|
+
// Ternary branches are lazy in both engines, so each `?`/`:` part is its own
|
|
760
|
+
// chain rather than an operand of the surrounding one.
|
|
761
|
+
for (const part of splitTopLevel(text.masked, span, ['?', ':'], brackets)) {
|
|
762
|
+
checkChain(text, blanked, field, part, established, findings, depth, brackets);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
/** One `||` or `&&` chain — or a single operand — with what already holds at it. */
|
|
766
|
+
function checkChain(text, blanked, field, span, established, findings, depth, brackets) {
|
|
767
|
+
if (depth > CEL_DIALECT_MAX_NESTING_DEPTH)
|
|
768
|
+
return;
|
|
769
|
+
const masked = text.masked;
|
|
770
|
+
// Precedence: `||` is the loosest operator, so it splits first and each
|
|
771
|
+
// disjunct is then read as its own `&&` chain.
|
|
772
|
+
const disjuncts = splitTopLevel(masked, span, ['||'], brackets);
|
|
773
|
+
const conjuncts = disjuncts.length > 1 ? [] : splitTopLevel(masked, span, ['&&'], brackets);
|
|
774
|
+
const mode = disjuncts.length > 1 ? 'or' : conjuncts.length > 1 ? 'and' : undefined;
|
|
775
|
+
if (mode === undefined) {
|
|
776
|
+
// Not a chain, but a parenthesized group inside it may hold one — and that
|
|
777
|
+
// group inherits whatever this position already established.
|
|
778
|
+
for (const group of parenGroups(masked, span, brackets)) {
|
|
779
|
+
checkLogicalChain(text, blanked, field, group, established, findings, depth + 1, brackets);
|
|
780
|
+
}
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const operands = mode === 'or' ? disjuncts : conjuncts;
|
|
784
|
+
const guards = operands.map((operand) => guardsEstablishedBy(blanked, operand, mode, brackets));
|
|
785
|
+
let known = [...established];
|
|
786
|
+
for (let index = 0; index < operands.length; index += 1) {
|
|
787
|
+
const operand = operands[index];
|
|
788
|
+
const after = guards.slice(index + 1).flat();
|
|
789
|
+
const derefs = dereferencedPaths(blanked, operand, brackets);
|
|
790
|
+
// A guard to the right of an access it covers is only harmless if something
|
|
791
|
+
// to the left already established the same path. "Established" is not
|
|
792
|
+
// "covered": a shallower earlier guard reaches the late guard's path without
|
|
793
|
+
// saying it is present, so it cannot stand in for it.
|
|
794
|
+
const lateGuard = after.find((guard) => derefs.some((path) => guardCovers(guard, path)) &&
|
|
795
|
+
!known.some((earlier) => guardEstablishes(earlier, guard)));
|
|
796
|
+
if (lateGuard !== undefined) {
|
|
797
|
+
const guardText = mode === 'or' ? `!has(${lateGuard})` : `has(${lateGuard})`;
|
|
798
|
+
findings.push(finding('guard-after-use-in-logical-chain', field, text.reported, text.source.slice(operand.start, operand.end).trim(), `${guardText} guards this operand but is written after it. cel-go absorbs the error either way; cel-js evaluates left to right and fails before reaching the guard`, `Move ${guardText} to the left of the access, or use a lazy ternary: has(${lateGuard}) ? (...) : <fallback>`));
|
|
799
|
+
}
|
|
800
|
+
// Descend with what holds at this position: the `&&` chain nested inside an
|
|
801
|
+
// `||` disjunct, and any parenthesized group.
|
|
802
|
+
checkChain(text, blanked, field, operand, known, findings, depth + 1, brackets);
|
|
803
|
+
known = [...known, ...guards[index]];
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
/* ------------------------------------------------------------------------- *
|
|
807
|
+
* The parse half.
|
|
808
|
+
*
|
|
809
|
+
* cel-js's `parse()` is the only CEL parser this module can call, and it is
|
|
810
|
+
* **not** a conformant implementation of the CEL grammar. So a `parse()` failure
|
|
811
|
+
* is never read as a verdict about cel-go: it is sorted into one of three
|
|
812
|
+
* buckets, each of which stands on evidence of its own.
|
|
813
|
+
*
|
|
814
|
+
* The spec quoted throughout is cel-spec `doc/langdef.md`, section "Syntax"
|
|
815
|
+
* (the EBNF) and its "Lexical Elements" subsection (the token definitions).
|
|
816
|
+
* ------------------------------------------------------------------------- */
|
|
817
|
+
/**
|
|
818
|
+
* `[start, end)` of every **plain-quoted** string literal body in `expression`.
|
|
819
|
+
*
|
|
820
|
+
* Plain-quoted on purpose, and not the same walk as {@link celStringLiteralEnd}.
|
|
821
|
+
* This feeds {@link findSpecCelCelJsRejects}, which looks *backwards* from a
|
|
822
|
+
* span's start for an `r`/`b` prefix, so the span has to stop at the quote the
|
|
823
|
+
* way cel-js's own `StringLiteral` token does rather than swallow the prefix.
|
|
824
|
+
* The `\`-escape rule here is cel-js's, which has no raw form to exempt.
|
|
825
|
+
*
|
|
826
|
+
* The consequence is that a raw literal whose body ends in `\` — `r"a\"`, one
|
|
827
|
+
* whole token to the spec tokenizer — is mis-read or missed here. That costs a
|
|
828
|
+
* rewrite, never a false proof: a span this walk gets wrong yields a rewrite
|
|
829
|
+
* whose result {@link rewriteKeepsTokenBoundaries} re-tokenizes with
|
|
830
|
+
* {@link celSpecTokens}, and a splice that does not land on exactly one token
|
|
831
|
+
* there throws the whole round away. Identification may under-report; only the
|
|
832
|
+
* boundary proof may license a divergence.
|
|
833
|
+
*/
|
|
834
|
+
function stringLiteralSpans(expression) {
|
|
835
|
+
const spans = [];
|
|
836
|
+
let quote;
|
|
837
|
+
let start = 0;
|
|
838
|
+
let escaped = false;
|
|
839
|
+
for (let index = 0; index < expression.length; index += 1) {
|
|
840
|
+
const character = expression[index];
|
|
841
|
+
if (quote === undefined) {
|
|
842
|
+
if (character === '"' || character === "'") {
|
|
843
|
+
quote = character;
|
|
844
|
+
start = index;
|
|
845
|
+
}
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
if (escaped)
|
|
849
|
+
escaped = false;
|
|
850
|
+
else if (character === '\\')
|
|
851
|
+
escaped = true;
|
|
852
|
+
else if (character === quote) {
|
|
853
|
+
spans.push({ start, end: index + 1 });
|
|
854
|
+
quote = undefined;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return spans;
|
|
858
|
+
}
|
|
859
|
+
/** Index of the next non-whitespace character at or after `from`, or -1. */
|
|
860
|
+
function nextNonSpace(text, from) {
|
|
861
|
+
for (let index = from; index < text.length; index += 1) {
|
|
862
|
+
if (!/\s/.test(text[index]))
|
|
863
|
+
return index;
|
|
864
|
+
}
|
|
865
|
+
return -1;
|
|
866
|
+
}
|
|
867
|
+
/** Index of the previous non-whitespace character before `from`, or -1. */
|
|
868
|
+
function previousNonSpace(text, from) {
|
|
869
|
+
for (let index = from - 1; index >= 0; index -= 1) {
|
|
870
|
+
if (!/\s/.test(text[index]))
|
|
871
|
+
return index;
|
|
872
|
+
}
|
|
873
|
+
return -1;
|
|
874
|
+
}
|
|
875
|
+
const IDENT_CHARACTER = /[A-Za-z0-9_]/;
|
|
876
|
+
/** True when a `.` or `[` sits at `index`, i.e. a postfix member operator follows. */
|
|
877
|
+
function postfixFollows(masked, after) {
|
|
878
|
+
const index = nextNonSpace(masked, after);
|
|
879
|
+
if (index < 0)
|
|
880
|
+
return undefined;
|
|
881
|
+
const character = masked[index];
|
|
882
|
+
return character === '.' || character === '[' ? character : undefined;
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Forms the CEL grammar permits that cel-js 0.8.2 provably cannot parse.
|
|
886
|
+
*
|
|
887
|
+
* Every entry here was confirmed two ways before it was written down: the form
|
|
888
|
+
* is derivable from the spec's own productions (quoted per entry), and
|
|
889
|
+
* `parse()` was actually called on it and failed. The unit suite re-asserts the
|
|
890
|
+
* `parse()` half, so the day cel-js catches up the test fails rather than the
|
|
891
|
+
* rule quietly over-reporting.
|
|
892
|
+
*
|
|
893
|
+
* Detection is deliberately conservative in one direction only: an unrecognized
|
|
894
|
+
* shape yields nothing, because a false positive here fails strict mode on
|
|
895
|
+
* valid CEL while a false negative merely leaves a `cel-js-parse-failure` note.
|
|
896
|
+
*/
|
|
897
|
+
function findSpecCelCelJsRejects(text, brackets) {
|
|
898
|
+
// Comments are already blanked out of both, so nothing below can identify a
|
|
899
|
+
// "shortfall" written in a comment, and no rewrite can splice into one.
|
|
900
|
+
const expression = text.source;
|
|
901
|
+
const masked = text.masked;
|
|
902
|
+
const found = [];
|
|
903
|
+
// `end` bounds the quoted *excerpt* and is deliberately generous — it runs past
|
|
904
|
+
// the fragment so the reader sees what it was the receiver of. `rewrite` is the
|
|
905
|
+
// exact span, which is a different thing and never guessed from `end`.
|
|
906
|
+
const add = (at, end, reason, rewrite) => {
|
|
907
|
+
found.push({
|
|
908
|
+
at,
|
|
909
|
+
fragment: expression.slice(at, Math.min(end, at + 80)).trim(),
|
|
910
|
+
reason,
|
|
911
|
+
...(rewrite === undefined ? {} : { rewrite }),
|
|
912
|
+
});
|
|
913
|
+
};
|
|
914
|
+
// Spec: `Primary = ... | LITERAL`, and `Member = Member "." SELECTOR [...]`
|
|
915
|
+
// | `Member "[" Expr "]"`, so a postfix applies to a literal primary. cel-js
|
|
916
|
+
// consumes a StringLiteral as a bare `atomicExpression` alternative with no
|
|
917
|
+
// postfix at all: `"x".size()`, `"a" .size()` and `"a"["b"]` all fail.
|
|
918
|
+
for (const span of stringLiteralSpans(expression)) {
|
|
919
|
+
const postfix = postfixFollows(masked, span.end);
|
|
920
|
+
if (postfix !== undefined) {
|
|
921
|
+
add(span.start, span.end + 24, 'a string literal used as the receiver of a member access', {
|
|
922
|
+
start: span.start,
|
|
923
|
+
end: span.end,
|
|
924
|
+
as: 'receiver',
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
// Spec: `STRING_LIT ::= [rR]? (...)` and `BYTES_LIT ::= [bB] STRING_LIT`.
|
|
928
|
+
// cel-js has no raw-string and no bytes token, so the prefix lexes as a
|
|
929
|
+
// one-character identifier and the parse fails.
|
|
930
|
+
const before = previousNonSpace(expression, span.start);
|
|
931
|
+
if (before === span.start - 1 &&
|
|
932
|
+
/[rRbB]/.test(expression[before]) &&
|
|
933
|
+
!IDENT_CHARACTER.test(expression[before - 1] ?? '')) {
|
|
934
|
+
add(before, span.end, 'a raw-string or bytes literal prefix, which cel-js has no token for', {
|
|
935
|
+
start: before,
|
|
936
|
+
end: span.end,
|
|
937
|
+
as: 'string',
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
// Spec `STRING_LIT` also admits the triple-quoted forms; cel-js has neither.
|
|
942
|
+
for (const quote of ['"""', "'''"]) {
|
|
943
|
+
const at = expression.indexOf(quote);
|
|
944
|
+
if (at < 0)
|
|
945
|
+
continue;
|
|
946
|
+
// The closing run is what makes the literal rewritable. Without one there is
|
|
947
|
+
// no span to substitute, so the shortfall is recorded with no rewrite and
|
|
948
|
+
// can never carry a divergence on its own.
|
|
949
|
+
const closed = expression.indexOf(quote, at + quote.length);
|
|
950
|
+
add(at, at + 24, 'a triple-quoted string literal, which cel-js has no token for', closed < 0 ? undefined : { start: at, end: closed + quote.length, as: 'string' });
|
|
951
|
+
}
|
|
952
|
+
// Spec: `Primary = "(" Expr ")"`, again a Member and so a legal receiver.
|
|
953
|
+
// cel-js's `parenthesisExpression` takes no postfix: `(a).b` and `(a+b)[0]`
|
|
954
|
+
// fail. A `(` that opens a *call* is a different production and is fine, so
|
|
955
|
+
// only a grouping paren — one not preceded by an identifier character —
|
|
956
|
+
// counts here.
|
|
957
|
+
for (let index = 0; index < masked.length; index += 1) {
|
|
958
|
+
if (masked[index] !== '(')
|
|
959
|
+
continue;
|
|
960
|
+
const before = previousNonSpace(masked, index);
|
|
961
|
+
const identifierCall = before >= 0 && IDENT_CHARACTER.test(masked[before]);
|
|
962
|
+
const close = matchingParen(brackets, index);
|
|
963
|
+
if (close < 0 || postfixFollows(masked, close + 1) === undefined)
|
|
964
|
+
continue;
|
|
965
|
+
if (!identifierCall) {
|
|
966
|
+
add(index, close + 24, 'a parenthesized expression used as the receiver of a member access', {
|
|
967
|
+
start: index,
|
|
968
|
+
end: close + 1,
|
|
969
|
+
as: 'receiver',
|
|
970
|
+
});
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
// A global function call — `size(a)`, `has(a.b)`, `int(a)` — is cel-js's
|
|
974
|
+
// `macrosExpression`, which also takes no postfix, so `size(a).b` fails.
|
|
975
|
+
// A *member* call (`a.map(x, x).size()`) is part of `identifierExpression`
|
|
976
|
+
// and cel-js handles it, so a callee written after a `.` is left alone.
|
|
977
|
+
let start = before;
|
|
978
|
+
while (start >= 0 && IDENT_CHARACTER.test(masked[start]))
|
|
979
|
+
start -= 1;
|
|
980
|
+
if (previousNonSpace(masked, start + 1) < 0 || masked[start] !== '.') {
|
|
981
|
+
add(start + 1, close + 24, 'a global function call used as the receiver of a member access', { start: start + 1, end: close + 1, as: 'receiver' });
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
// Spec: `Primary = "[" [ExprList] [","] "]"`, and a postfix applies to it.
|
|
985
|
+
// cel-js's `listExpression` allows exactly one trailing index and no `.` at
|
|
986
|
+
// all, so `[a,b].size()` and `[1,2][0].f` fail while `[1,2][0]` parses.
|
|
987
|
+
for (let index = 0; index < masked.length; index += 1) {
|
|
988
|
+
if (masked[index] !== '[')
|
|
989
|
+
continue;
|
|
990
|
+
const before = previousNonSpace(masked, index);
|
|
991
|
+
// An index expression, not a list literal, when something precedes it that
|
|
992
|
+
// a postfix can attach to.
|
|
993
|
+
if (before >= 0 && /[A-Za-z0-9_)\]}]/.test(masked[before]))
|
|
994
|
+
continue;
|
|
995
|
+
const close = matchingParen(brackets, index);
|
|
996
|
+
if (close < 0)
|
|
997
|
+
continue;
|
|
998
|
+
if (postfixFollows(masked, close + 1) === '.') {
|
|
999
|
+
add(index, close + 24, 'a list literal used as the receiver of a member access', {
|
|
1000
|
+
start: index,
|
|
1001
|
+
end: close + 1,
|
|
1002
|
+
as: 'receiver',
|
|
1003
|
+
});
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
// The one permitted index, then anything further is past what cel-js takes.
|
|
1007
|
+
if (postfixFollows(masked, close + 1) !== '[')
|
|
1008
|
+
continue;
|
|
1009
|
+
const second = matchingParen(brackets, nextNonSpace(masked, close + 1));
|
|
1010
|
+
if (second > 0 && postfixFollows(masked, second + 1) !== undefined) {
|
|
1011
|
+
// The list *and* its one permitted index collapse into the placeholder:
|
|
1012
|
+
// `[1,2][0].f` is grammatical as `__typekro_recv0.f`, where rewriting the
|
|
1013
|
+
// list alone would leave `__typekro_recv0[0].f` — still past cel-js.
|
|
1014
|
+
add(index, second + 24, 'a list literal with more than the one postfix cel-js allows', {
|
|
1015
|
+
start: index,
|
|
1016
|
+
end: second + 1,
|
|
1017
|
+
as: 'receiver',
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
// Spec: `Primary = ... | LITERAL` covers the number, bool and null literals
|
|
1022
|
+
// too, so `1.string()`, `1.0.x`, `2u.x`, `true.x` and `null.x` are all
|
|
1023
|
+
// grammatical. cel-js consumes each as a bare token with no postfix.
|
|
1024
|
+
const literalReceiver = /(?<![A-Za-z0-9_.])(0[xX][0-9a-fA-F]+[uU]?|\d+(?:\.\d+)?[uU]?|true|false|null)\s*\.\s*[A-Za-z_]/g;
|
|
1025
|
+
for (const match of masked.matchAll(literalReceiver)) {
|
|
1026
|
+
// Only the literal itself is the receiver; the `.` and the selector after it
|
|
1027
|
+
// are the member access that stays.
|
|
1028
|
+
const literal = match[1];
|
|
1029
|
+
add(match.index, match.index + match[0].length, 'a number, bool or null literal used as the receiver of a member access', { start: match.index, end: match.index + literal.length, as: 'receiver' });
|
|
1030
|
+
}
|
|
1031
|
+
// Spec: `FLOAT_LIT ::= -? DIGIT* . DIGIT+ EXPONENT? | -? DIGIT+ EXPONENT`.
|
|
1032
|
+
// cel-js's `Float` token is `-?\d+\.\d+` with no exponent form, so `1e3`,
|
|
1033
|
+
// `1.5e-3` and `0.5e3` are all rejected.
|
|
1034
|
+
for (const match of masked.matchAll(/(?<![A-Za-z0-9_.])\d+(?:\.\d+)?[eE][+-]?\d+/g)) {
|
|
1035
|
+
add(match.index, match.index + match[0].length, 'a float literal written with an exponent, which cel-js has no token for', { start: match.index, end: match.index + match[0].length, as: 'float' });
|
|
1036
|
+
}
|
|
1037
|
+
return found.sort((left, right) => left.at - right.at);
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Largest number of rewrite rounds {@link rewriteAwayCelJsLimitations} will run.
|
|
1041
|
+
*
|
|
1042
|
+
* One round removes every shortfall that is not nested inside another, so a
|
|
1043
|
+
* round is needed per level of nesting: `("x".size()).b` takes one (the outer
|
|
1044
|
+
* parenthesized receiver swallows the inner string one), `r"x".size()` takes two
|
|
1045
|
+
* (the raw prefix goes first, and only then is the plain literal visible as a
|
|
1046
|
+
* receiver). Real emitted status CEL reaches one. The cap exists so a pathological
|
|
1047
|
+
* expression cannot loop — each round either rewrites something or the loop
|
|
1048
|
+
* stops, and hitting the cap simply means no divergence is proven, which is the
|
|
1049
|
+
* safe direction.
|
|
1050
|
+
*/
|
|
1051
|
+
const CEL_DIALECT_MAX_REWRITE_ROUNDS = 8;
|
|
1052
|
+
/** Receiver placeholders are bound nowhere; only their grammar matters. */
|
|
1053
|
+
const CEL_JS_RECEIVER_PLACEHOLDER = '__typekro_recv';
|
|
1054
|
+
/* ---------------------------------------------------------------------------
|
|
1055
|
+
* Lexical coverage: "cel-js parsed it" has to mean "cel-js read all of it".
|
|
1056
|
+
*
|
|
1057
|
+
* cel-js's `parse()` (dist/lib.js) calls `CELLexer.tokenize(expression)` and
|
|
1058
|
+
* then looks only at `parserInstance.errors` — `lexResult.errors` is discarded
|
|
1059
|
+
* unread. A chevrotain lexer skips a character it has no token for and carries
|
|
1060
|
+
* on, so every character cel-js has no token for is dropped silently before the
|
|
1061
|
+
* parser ever sees the text: `a === b` reaches the parser as `a == b` and
|
|
1062
|
+
* "parses", and so do `x.size() $` and `x.size() ☃`. Taking `isSuccess` as
|
|
1063
|
+
* proof that cel-js accepted the *text* is therefore wrong in exactly the
|
|
1064
|
+
* direction that matters here — a divergence proof is a claim about the whole
|
|
1065
|
+
* expression, and a parse that dropped part of it proves nothing about the part
|
|
1066
|
+
* it dropped.
|
|
1067
|
+
*
|
|
1068
|
+
* **Which route this takes, and why.** The first choice would be to ask cel-js
|
|
1069
|
+
* itself: `dist/tokens.js` exports both `CELLexer` and the `allTokens`
|
|
1070
|
+
* vocabulary, and `CELLexer.tokenize()` returns the `errors` that `parse()`
|
|
1071
|
+
* throws away. It is not reachable. cel-js 0.8.2's package.json declares
|
|
1072
|
+
* `"exports": { ".": "./dist/index.js" }` and nothing else, so
|
|
1073
|
+
* `cel-js/dist/tokens.js` does not resolve, and `index.js` re-exports only
|
|
1074
|
+
* `parse`, `evaluate` and the three error classes — no lexer, no token
|
|
1075
|
+
* vocabulary. The second choice, running chevrotain's own `Lexer` over that
|
|
1076
|
+
* vocabulary, needs the same unreachable export (and `chevrotain` is cel-js's
|
|
1077
|
+
* dependency, not TypeKro's). So this takes the third route: a coverage scanner
|
|
1078
|
+
* written from the CEL specification's own lexical grammar (cel-spec
|
|
1079
|
+
* doc/langdef.md, "Lexical Elements"), which walks the text and reports the
|
|
1080
|
+
* first character no CEL token can carry.
|
|
1081
|
+
*
|
|
1082
|
+
* That scanner is a superset of cel-js's lexer — it knows the raw, bytes and
|
|
1083
|
+
* triple-quoted `STRING_LIT` forms and the exponent `FLOAT_LIT` that cel-js has
|
|
1084
|
+
* no token for — and the superset direction is the safe one for both jobs it
|
|
1085
|
+
* does. As a *proof* gate it can only withhold a divergence, never invent one:
|
|
1086
|
+
* anything cel-js's lexer drops is a character cel-js has no token for, and the
|
|
1087
|
+
* only such characters the spec does have a token for are the four forms just
|
|
1088
|
+
* named, each of which the rewriter has already replaced by the time a proof is
|
|
1089
|
+
* checked. As a `not-valid-cel` input it reports only characters the **spec**
|
|
1090
|
+
* has no token for, which is a fact about the language rather than about either
|
|
1091
|
+
* engine, so it belongs in that bucket by the same standard as the rest of it.
|
|
1092
|
+
* ------------------------------------------------------------------------- */
|
|
1093
|
+
/** `WHITESPACE ::= [\t\n\f\r ]+`. */
|
|
1094
|
+
const CEL_WHITESPACE = /[\t\n\f\r ]/;
|
|
1095
|
+
/** `DIGIT ::= [0-9]`. */
|
|
1096
|
+
const CEL_DIGIT = /[0-9]/;
|
|
1097
|
+
/** `HEXDIGIT ::= [0-9abcdefABCDEF]`. */
|
|
1098
|
+
const CEL_HEXDIGIT = /[0-9a-fA-F]/;
|
|
1099
|
+
/** The first character of `IDENT ::= [_a-zA-Z][_a-zA-Z0-9]*`. */
|
|
1100
|
+
const CEL_IDENT_START = /[_A-Za-z]/;
|
|
1101
|
+
/** The two-character operators in the spec's punctuation list. */
|
|
1102
|
+
const CEL_PUNCTUATION_PAIRS = new Set(['||', '&&', '==', '!=', '<=', '>=']);
|
|
1103
|
+
/** The one-character punctuation, once the pairs above are taken out. */
|
|
1104
|
+
const CEL_PUNCTUATION_SINGLES = '()[]{}.,?:!<>+-*/%';
|
|
1105
|
+
/**
|
|
1106
|
+
* End of the `STRING_LIT`/`BYTES_LIT` token starting at `at`, or `at` for none.
|
|
1107
|
+
*
|
|
1108
|
+
* `STRING_LIT ::= [rR]? ( '"' … '"' | "'" … "'" | '"""' … '"""' | "'''" … "'''" )`
|
|
1109
|
+
* and `BYTES_LIT ::= [bB] STRING_LIT`; cel-go accepts the two prefixes in
|
|
1110
|
+
* either order, so at most one of each is taken. A prefix with no quote behind
|
|
1111
|
+
* it is not a string at all and is handed back for `IDENT` to consume, which is
|
|
1112
|
+
* what makes `bar"x"` lex as an identifier and a string rather than as nothing.
|
|
1113
|
+
*
|
|
1114
|
+
* A `\` consumes the character after it in every form **except** a raw one.
|
|
1115
|
+
* The langdef lexis and cel-go's lexer spell the raw alternatives with no
|
|
1116
|
+
* `ESC_SEQ` in them at all — `RAW '"' ~["\n\r]* '"'`, `RAW '"""' .*? '"""'`,
|
|
1117
|
+
* and the `'`-delimited pair of each — so a raw literal has no escapes to lex
|
|
1118
|
+
* and closes at the first delimiter after its opening one. `r"a\"` is therefore
|
|
1119
|
+
* one complete literal whose value is `a\`, and `r"a\" == x.status.y` is that
|
|
1120
|
+
* literal followed by `== x.status.y`. Reading the `\"` there as an escape runs
|
|
1121
|
+
* the literal on to the wrong closing quote — or off the end of the text, where
|
|
1122
|
+
* it is no token at all — and takes everything after it down with it, which
|
|
1123
|
+
* turns a perfectly ordinary comparison into an unlexable expression.
|
|
1124
|
+
*
|
|
1125
|
+
* The `b` prefix does **not** suppress escapes: `BYTES_LIT ::= [bB] STRING_LIT`
|
|
1126
|
+
* keeps whichever `STRING_LIT` form follows it, so `b"a\""` escapes and only
|
|
1127
|
+
* `r`, `rb` and `br` set the flag.
|
|
1128
|
+
*
|
|
1129
|
+
* An unterminated literal, or a newline inside a single-delimiter one — raw
|
|
1130
|
+
* included, since `~["\n\r]` excludes it there too — is not a token; the
|
|
1131
|
+
* opening quote is then the character nothing can carry, which is the honest
|
|
1132
|
+
* place to point at. The triple-quoted forms admit newlines in both variants.
|
|
1133
|
+
*/
|
|
1134
|
+
function celStringLiteralEnd(text, at) {
|
|
1135
|
+
let index = at;
|
|
1136
|
+
let raw = false;
|
|
1137
|
+
let bytes = false;
|
|
1138
|
+
for (let take = 0; take < 2; take += 1) {
|
|
1139
|
+
const character = text[index];
|
|
1140
|
+
if (!raw && (character === 'r' || character === 'R')) {
|
|
1141
|
+
raw = true;
|
|
1142
|
+
index += 1;
|
|
1143
|
+
}
|
|
1144
|
+
else if (!bytes && (character === 'b' || character === 'B')) {
|
|
1145
|
+
bytes = true;
|
|
1146
|
+
index += 1;
|
|
1147
|
+
}
|
|
1148
|
+
else
|
|
1149
|
+
break;
|
|
1150
|
+
}
|
|
1151
|
+
const delimiter = ['"""', "'''", '"', "'"].find((candidate) => text.startsWith(candidate, index));
|
|
1152
|
+
if (delimiter === undefined)
|
|
1153
|
+
return at;
|
|
1154
|
+
for (let scan = index + delimiter.length; scan < text.length; scan += 1) {
|
|
1155
|
+
const character = text[scan];
|
|
1156
|
+
if (!raw && character === '\\') {
|
|
1157
|
+
if (scan + 1 >= text.length)
|
|
1158
|
+
return at;
|
|
1159
|
+
scan += 1;
|
|
1160
|
+
continue;
|
|
1161
|
+
}
|
|
1162
|
+
if (delimiter.length === 1 && (character === '\n' || character === '\r'))
|
|
1163
|
+
return at;
|
|
1164
|
+
if (text.startsWith(delimiter, scan))
|
|
1165
|
+
return scan + delimiter.length;
|
|
1166
|
+
}
|
|
1167
|
+
return at;
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* End of the `INT_LIT`/`UINT_LIT`/`FLOAT_LIT` token starting at `at`, or `at`.
|
|
1171
|
+
*
|
|
1172
|
+
* `INT_LIT ::= DIGIT+ | '0x' HEXDIGIT+`, `UINT_LIT ::= INT_LIT [uU]`,
|
|
1173
|
+
* `FLOAT_LIT ::= DIGIT* '.' DIGIT+ EXPONENT? | DIGIT+ EXPONENT` and
|
|
1174
|
+
* `EXPONENT ::= [eE] [+-]? DIGIT+`. The leading `-` the spec writes into each
|
|
1175
|
+
* form is consumed as punctuation instead, which changes no coverage verdict.
|
|
1176
|
+
* `0x` with no hex digit behind it falls through to the decimal form, because
|
|
1177
|
+
* cel-go's longest-match lexer reads that as `0` and the identifier `x`.
|
|
1178
|
+
*/
|
|
1179
|
+
function celNumberLiteralEnd(text, at) {
|
|
1180
|
+
if (!CEL_DIGIT.test(text[at] ?? ''))
|
|
1181
|
+
return at;
|
|
1182
|
+
if (text[at] === '0' && (text[at + 1] === 'x' || text[at + 1] === 'X')) {
|
|
1183
|
+
let hex = at + 2;
|
|
1184
|
+
while (hex < text.length && CEL_HEXDIGIT.test(text[hex]))
|
|
1185
|
+
hex += 1;
|
|
1186
|
+
if (hex > at + 2)
|
|
1187
|
+
return text[hex] === 'u' || text[hex] === 'U' ? hex + 1 : hex;
|
|
1188
|
+
}
|
|
1189
|
+
let end = at;
|
|
1190
|
+
while (end < text.length && CEL_DIGIT.test(text[end]))
|
|
1191
|
+
end += 1;
|
|
1192
|
+
if (text[end] === '.' && CEL_DIGIT.test(text[end + 1] ?? '')) {
|
|
1193
|
+
end += 1;
|
|
1194
|
+
while (end < text.length && CEL_DIGIT.test(text[end]))
|
|
1195
|
+
end += 1;
|
|
1196
|
+
}
|
|
1197
|
+
const exponent = /^[eE][+-]?[0-9]+/.exec(text.slice(end));
|
|
1198
|
+
if (exponent !== null)
|
|
1199
|
+
return end + exponent[0].length;
|
|
1200
|
+
return text[end] === 'u' || text[end] === 'U' ? end + 1 : end;
|
|
1201
|
+
}
|
|
1202
|
+
/** End of the one CEL token starting at `at`, or `at` when there is none. */
|
|
1203
|
+
function celSpecTokenEnd(text, at) {
|
|
1204
|
+
const character = text[at];
|
|
1205
|
+
if (CEL_WHITESPACE.test(character)) {
|
|
1206
|
+
let end = at + 1;
|
|
1207
|
+
while (end < text.length && CEL_WHITESPACE.test(text[end]))
|
|
1208
|
+
end += 1;
|
|
1209
|
+
return end;
|
|
1210
|
+
}
|
|
1211
|
+
// `COMMENT ::= '//' ~NEWLINE*`.
|
|
1212
|
+
if (character === '/' && text[at + 1] === '/') {
|
|
1213
|
+
const newline = text.slice(at).search(/[\r\n]/);
|
|
1214
|
+
return newline < 0 ? text.length : at + newline;
|
|
1215
|
+
}
|
|
1216
|
+
const string = celStringLiteralEnd(text, at);
|
|
1217
|
+
if (string > at)
|
|
1218
|
+
return string;
|
|
1219
|
+
const number = celNumberLiteralEnd(text, at);
|
|
1220
|
+
if (number > at)
|
|
1221
|
+
return number;
|
|
1222
|
+
if (CEL_IDENT_START.test(character)) {
|
|
1223
|
+
let end = at + 1;
|
|
1224
|
+
while (end < text.length && IDENT_CHARACTER.test(text[end]))
|
|
1225
|
+
end += 1;
|
|
1226
|
+
return end;
|
|
1227
|
+
}
|
|
1228
|
+
if (CEL_PUNCTUATION_PAIRS.has(text.slice(at, at + 2)))
|
|
1229
|
+
return at + 2;
|
|
1230
|
+
return CEL_PUNCTUATION_SINGLES.includes(character) ? at + 1 : at;
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Offset of the first character of `text` no CEL token can carry, or `-1`.
|
|
1234
|
+
*
|
|
1235
|
+
* A `-1` means the spec's lexical grammar tiles the text end to end with no
|
|
1236
|
+
* gaps: every character belongs to a token, a comment or whitespace. That is
|
|
1237
|
+
* the property `parse()` does not check, and it is now required before anything
|
|
1238
|
+
* is read into a cel-js verdict.
|
|
1239
|
+
*/
|
|
1240
|
+
function firstUnlexableOffset(text) {
|
|
1241
|
+
let index = 0;
|
|
1242
|
+
while (index < text.length) {
|
|
1243
|
+
const end = celSpecTokenEnd(text, index);
|
|
1244
|
+
if (end <= index)
|
|
1245
|
+
return index;
|
|
1246
|
+
index = end;
|
|
1247
|
+
}
|
|
1248
|
+
return -1;
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Every significant CEL token in `text`, or `undefined` when a character is one
|
|
1252
|
+
* no CEL token can carry.
|
|
1253
|
+
*
|
|
1254
|
+
* "Significant" drops whitespace and comments, which is what the token stream a
|
|
1255
|
+
* parser sees consists of. This is the same walk {@link firstUnlexableOffset}
|
|
1256
|
+
* makes, keeping the spans instead of discarding them, so a token boundary here
|
|
1257
|
+
* is a token boundary there by construction. Its one caller is
|
|
1258
|
+
* {@link rewriteKeepsTokenBoundaries}, which needs to compare two token streams
|
|
1259
|
+
* rather than merely establish that both are lexable.
|
|
1260
|
+
*/
|
|
1261
|
+
function celSpecTokens(text) {
|
|
1262
|
+
const tokens = [];
|
|
1263
|
+
let index = 0;
|
|
1264
|
+
while (index < text.length) {
|
|
1265
|
+
const end = celSpecTokenEnd(text, index);
|
|
1266
|
+
if (end <= index)
|
|
1267
|
+
return undefined;
|
|
1268
|
+
const first = text[index];
|
|
1269
|
+
const comment = first === '/' && text[index + 1] === '/';
|
|
1270
|
+
if (!comment && !CEL_WHITESPACE.test(first))
|
|
1271
|
+
tokens.push({ start: index, end });
|
|
1272
|
+
index = end;
|
|
1273
|
+
}
|
|
1274
|
+
return tokens;
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Blank the string literals and the comments out of an expression, in one pass
|
|
1278
|
+
* driven by the same tokenizer {@link firstUnlexableOffset} walks with.
|
|
1279
|
+
*
|
|
1280
|
+
* Masking and lexical coverage have to agree about where a token starts and
|
|
1281
|
+
* ends, and the way to get that is to ask the same function rather than to
|
|
1282
|
+
* write the rule twice. Two orderings are what a string-only mask gets wrong,
|
|
1283
|
+
* and both fall out of walking tokens in source order: a `//` *inside* a string
|
|
1284
|
+
* literal is part of the literal and opens no comment (`"a // b"` is a string),
|
|
1285
|
+
* and a quote *inside* a comment is part of the comment and opens no literal
|
|
1286
|
+
* (`// it's "quoted"` is a comment — under a string-only mask its apostrophe
|
|
1287
|
+
* flips the masking of everything after it).
|
|
1288
|
+
*
|
|
1289
|
+
* `COMMENT ::= '//' ~NEWLINE*` (cel-spec doc/langdef.md, "Syntax") stops before
|
|
1290
|
+
* its newline, so the newline is never blanked and a multi-line expression
|
|
1291
|
+
* keeps its line structure.
|
|
1292
|
+
*
|
|
1293
|
+
* Two results, because the rules need two different things:
|
|
1294
|
+
*
|
|
1295
|
+
* - `masked` blanks comments *and* string literals. Every structural scan reads
|
|
1296
|
+
* it, so a bracket, `&&`, `?`, `:` or `has(` that is really text inside a
|
|
1297
|
+
* literal or a comment is not mistaken for structure.
|
|
1298
|
+
* - `source` blanks only the comments. A rule that needs the *content* of a
|
|
1299
|
+
* literal — {@link literalTypeClass}, {@link stringLiteralSpans} — reads it,
|
|
1300
|
+
* and so does every fragment a finding quotes, so no identified form can be
|
|
1301
|
+
* made of comment text.
|
|
1302
|
+
*
|
|
1303
|
+
* A character no CEL token can carry stops neither: it is copied through and
|
|
1304
|
+
* the walk steps over it one **code unit** at a time, which keeps this a mask
|
|
1305
|
+
* rather than a second lexical gate — text that is not CEL at all (`a === b`,
|
|
1306
|
+
* `${x}`) still has to reach {@link findNonCelTokens} with its `=` and `$`
|
|
1307
|
+
* visible. Every write is by code unit and every blank is one space per unit,
|
|
1308
|
+
* so both results are exactly as long as the input and an offset means the same
|
|
1309
|
+
* thing in all three texts.
|
|
1310
|
+
*/
|
|
1311
|
+
function maskCelCommentsAndStrings(text) {
|
|
1312
|
+
// `split('')` is by UTF-16 code unit. `[...text]` is by code point, which
|
|
1313
|
+
// would blank an astral character to a *single* space and shrink the mask by
|
|
1314
|
+
// one unit against the original — see {@link CelDialectText}.
|
|
1315
|
+
const source = text.split('');
|
|
1316
|
+
const masked = text.split('');
|
|
1317
|
+
let index = 0;
|
|
1318
|
+
while (index < text.length) {
|
|
1319
|
+
if (text[index] === '/' && text[index + 1] === '/') {
|
|
1320
|
+
const end = celSpecTokenEnd(text, index);
|
|
1321
|
+
for (let at = index; at < end; at += 1) {
|
|
1322
|
+
source[at] = ' ';
|
|
1323
|
+
masked[at] = ' ';
|
|
1324
|
+
}
|
|
1325
|
+
index = end;
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
const stringEnd = celStringLiteralEnd(text, index);
|
|
1329
|
+
if (stringEnd > index) {
|
|
1330
|
+
for (let at = index; at < stringEnd; at += 1)
|
|
1331
|
+
masked[at] = ' ';
|
|
1332
|
+
index = stringEnd;
|
|
1333
|
+
continue;
|
|
1334
|
+
}
|
|
1335
|
+
const end = celSpecTokenEnd(text, index);
|
|
1336
|
+
index = end > index ? end : index + 1;
|
|
1337
|
+
}
|
|
1338
|
+
return { source: source.join(''), masked: masked.join('') };
|
|
1339
|
+
}
|
|
1340
|
+
/** Run cel-js's parser and the lexical coverage scan over the same text. */
|
|
1341
|
+
function celJsReads(expression) {
|
|
1342
|
+
let parsed = false;
|
|
1343
|
+
try {
|
|
1344
|
+
parsed = parse(expression).isSuccess === true;
|
|
1345
|
+
}
|
|
1346
|
+
catch {
|
|
1347
|
+
parsed = false;
|
|
1348
|
+
}
|
|
1349
|
+
return { parsed, unlexableAt: firstUnlexableOffset(expression) };
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* cel-js accepted the **whole** text: every character is carried by a CEL token
|
|
1353
|
+
* and the parser accepted the token stream.
|
|
1354
|
+
*
|
|
1355
|
+
* This — never `parse().isSuccess` on its own — is what "cel-js parses it" means
|
|
1356
|
+
* anywhere a verdict is drawn from it, whether that is a divergence proof or the
|
|
1357
|
+
* gate that decides an expression is established grammatical.
|
|
1358
|
+
*/
|
|
1359
|
+
function celJsAcceptsWhole(expression) {
|
|
1360
|
+
const reading = celJsReads(expression);
|
|
1361
|
+
return reading.parsed && reading.unlexableAt < 0;
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* How deep the swallowed-span check will recurse before giving up.
|
|
1365
|
+
*
|
|
1366
|
+
* A bracketed receiver span nested inside another bracketed receiver span costs
|
|
1367
|
+
* one level — `(("x".size()).b).c` is two — and each level asks cel-js about a
|
|
1368
|
+
* strictly shorter piece of text, so the recursion terminates on its own. The
|
|
1369
|
+
* cap bounds the *cost* rather than the termination: without it an adversarial
|
|
1370
|
+
* chain of nested receivers would re-parse a prefix of the expression once per
|
|
1371
|
+
* level, which is the quadratic shape this module has already had to fix once.
|
|
1372
|
+
* Real emitted status CEL reaches one level; giving up past eight only means no
|
|
1373
|
+
* divergence is proven, which is the safe direction.
|
|
1374
|
+
*/
|
|
1375
|
+
const CEL_DIALECT_MAX_SPAN_DEPTH = 8;
|
|
1376
|
+
/**
|
|
1377
|
+
* Whether cel-js accepts the span a `receiver` rewrite is about to swallow.
|
|
1378
|
+
*
|
|
1379
|
+
* The rewrite replaces a whole bracketed span with one identifier, so the text
|
|
1380
|
+
* cel-js is asked about no longer contains it. Without this check the proof
|
|
1381
|
+
* inherits exactly the defect it was built to rule out: `(a &&).b`, `size(a +).b`
|
|
1382
|
+
* and `[1, ,2].size()` each rewrite to something cel-js parses — `__typekro_recv0.b`
|
|
1383
|
+
* — while the part that vanished is ungrammatical on any engine, so cel-go
|
|
1384
|
+
* refuses the original as readily as cel-js does and the "divergence" is a false
|
|
1385
|
+
* positive against strict mode.
|
|
1386
|
+
*
|
|
1387
|
+
* The span is held to the same bar as the whole expression, recursively: cel-js
|
|
1388
|
+
* accepts it outright, or a proof of its own establishes it. The recursion is
|
|
1389
|
+
* needed because a span can carry its own shortfall — `("x".size()).b` swallows
|
|
1390
|
+
* `("x".size())`, which cel-js cannot parse for a reason that *is* a divergence.
|
|
1391
|
+
*
|
|
1392
|
+
* Spans that carry no bracket — a string-literal receiver, an `r`/`b` prefix, a
|
|
1393
|
+
* triple-quoted literal, an exponent float, a number/bool/null receiver — are a
|
|
1394
|
+
* single literal token with no subexpression inside it to be ungrammatical, and
|
|
1395
|
+
* the lexical coverage scan over the original has already established that each
|
|
1396
|
+
* is a well-formed token. Everything a bracket could hide is checked: the
|
|
1397
|
+
* parenthesized group, the global call's argument list, and the list literal.
|
|
1398
|
+
*/
|
|
1399
|
+
function celJsAcceptsSwallowedSpan(text, rewrite, depth) {
|
|
1400
|
+
if (rewrite.as !== 'receiver')
|
|
1401
|
+
return true;
|
|
1402
|
+
const span = celDialectText(text.source.slice(rewrite.start, rewrite.end));
|
|
1403
|
+
if (!/[([{]/.test(span.masked))
|
|
1404
|
+
return true;
|
|
1405
|
+
if (depth >= CEL_DIALECT_MAX_SPAN_DEPTH)
|
|
1406
|
+
return false;
|
|
1407
|
+
if (celJsAcceptsWhole(span.reported))
|
|
1408
|
+
return true;
|
|
1409
|
+
// Every receiver rewrite requires a postfix operator after its span, so `span`
|
|
1410
|
+
// is strictly shorter than `text` and the recursion cannot revisit it.
|
|
1411
|
+
const rewritten = rewriteAwayCelJsLimitations(span, buildBracketIndex(span.masked), depth + 1);
|
|
1412
|
+
return rewritten.applied > 0 && celJsAcceptsWhole(rewritten.text);
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* Rewrite every identified cel-js shortfall out of an expression, leaving the
|
|
1416
|
+
* rest of it byte for byte as it was.
|
|
1417
|
+
*
|
|
1418
|
+
* This is the evidence half of the `cel-js-rejects-spec-cel` bucket. A cel-js
|
|
1419
|
+
* parse failure plus a shortfall *somewhere* in the text proves nothing on its
|
|
1420
|
+
* own: the failure may be caused by a genuine syntax error elsewhere — `"x".size() +`
|
|
1421
|
+
* has a string-literal receiver in it and is also simply unfinished, and cel-go
|
|
1422
|
+
* rejects it as readily as cel-js does. Divergence needs the stronger claim that
|
|
1423
|
+
* the identified shortfalls are the *only* reason cel-js refuses the text, and
|
|
1424
|
+
* the way to establish that is to take them away and ask cel-js again.
|
|
1425
|
+
*
|
|
1426
|
+
* Each round re-runs the detector over the current text and applies the
|
|
1427
|
+
* outermost non-overlapping rewrites, right to left so the spans of the
|
|
1428
|
+
* not-yet-applied rewrites stay valid; the text is re-masked between rounds
|
|
1429
|
+
* because a rewrite can change where the string literals are. Overlapping
|
|
1430
|
+
* shortfalls — a string receiver inside a parenthesized receiver — resolve
|
|
1431
|
+
* outermost-first, and whatever is left over is picked up by the next round.
|
|
1432
|
+
*
|
|
1433
|
+
* A `receiver` rewrite over a bracketed span *swallows* that span: `(a && b).c`
|
|
1434
|
+
* becomes `__typekro_recv0.c`, and whatever was between the brackets is gone
|
|
1435
|
+
* from the text cel-js is asked about. That makes the same mistake the whole
|
|
1436
|
+
* proof exists to avoid, one level down — the parse says nothing about the part
|
|
1437
|
+
* it never saw — so a bracketed span is swallowed only once cel-js has been
|
|
1438
|
+
* shown to accept the span itself; see {@link celJsAcceptsSwallowedSpan}.
|
|
1439
|
+
*/
|
|
1440
|
+
function rewriteAwayCelJsLimitations(expression, brackets, depth = 0) {
|
|
1441
|
+
// The rewrite runs on `source` — comments already blanked — so no splice can
|
|
1442
|
+
// land inside a comment and no comment can survive into the proof text.
|
|
1443
|
+
let current = expression;
|
|
1444
|
+
let text = current.source;
|
|
1445
|
+
let currentBrackets = brackets;
|
|
1446
|
+
let placeholders = 0;
|
|
1447
|
+
let applied = 0;
|
|
1448
|
+
for (let round = 0; round < CEL_DIALECT_MAX_REWRITE_ROUNDS; round += 1) {
|
|
1449
|
+
const candidates = findSpecCelCelJsRejects(current, currentBrackets)
|
|
1450
|
+
.map((limitation) => limitation.rewrite)
|
|
1451
|
+
.filter((rewrite) => rewrite !== undefined && rewrite.end > rewrite.start)
|
|
1452
|
+
.filter((rewrite) => celJsAcceptsSwallowedSpan(current, rewrite, depth))
|
|
1453
|
+
// Outermost first at a shared start, so a containing span wins and the
|
|
1454
|
+
// contained one is skipped rather than splitting the container in two.
|
|
1455
|
+
.sort((left, right) => left.start - right.start || right.end - left.end);
|
|
1456
|
+
const chosen = [];
|
|
1457
|
+
for (const candidate of candidates) {
|
|
1458
|
+
const overlaps = chosen.some((taken) => candidate.start < taken.end && taken.start < candidate.end);
|
|
1459
|
+
if (!overlaps)
|
|
1460
|
+
chosen.push(candidate);
|
|
1461
|
+
}
|
|
1462
|
+
if (chosen.length === 0)
|
|
1463
|
+
break;
|
|
1464
|
+
// Numbered left to right, so a reader of the proof meets `__typekro_recv0`
|
|
1465
|
+
// before `__typekro_recv1`; `chosen` is already in source order.
|
|
1466
|
+
const replacements = chosen.map((rewrite) => {
|
|
1467
|
+
const width = rewrite.end - rewrite.start;
|
|
1468
|
+
if (rewrite.as === 'receiver')
|
|
1469
|
+
return `${CEL_JS_RECEIVER_PLACEHOLDER}${placeholders++}`;
|
|
1470
|
+
const filler = Math.max(1, width - 2);
|
|
1471
|
+
return rewrite.as === 'string'
|
|
1472
|
+
? `"${'x'.repeat(filler)}"`
|
|
1473
|
+
: `0.${'0'.repeat(filler)}`;
|
|
1474
|
+
});
|
|
1475
|
+
// A splice has no boundary of its own: it sits between two characters the
|
|
1476
|
+
// tokenizer has already paired with their own tokens, and a replacement
|
|
1477
|
+
// that abuts an `IDENT` or digit character *joins* it. `x"y".size()` is the
|
|
1478
|
+
// worked example — splicing the placeholder in for the string literal
|
|
1479
|
+
// yields `x__typekro_recv0.size()`, one identifier where the original had
|
|
1480
|
+
// an identifier next to a string literal. One space keeps the boundary and
|
|
1481
|
+
// changes no CEL production, whitespace being allowed between any two
|
|
1482
|
+
// tokens. A neighbour that is itself being replaced is consulted at its
|
|
1483
|
+
// replacement, since that is what will be adjacent.
|
|
1484
|
+
const padding = chosen.map((rewrite, index) => {
|
|
1485
|
+
const replacement = replacements[index];
|
|
1486
|
+
const previousSpan = chosen[index - 1];
|
|
1487
|
+
const nextSpan = chosen[index + 1];
|
|
1488
|
+
const before = previousSpan !== undefined && previousSpan.end === rewrite.start
|
|
1489
|
+
? replacements[index - 1].slice(-1)
|
|
1490
|
+
: (text[rewrite.start - 1] ?? '');
|
|
1491
|
+
const after = nextSpan !== undefined && nextSpan.start === rewrite.end
|
|
1492
|
+
? replacements[index + 1].slice(0, 1)
|
|
1493
|
+
: (text[rewrite.end] ?? '');
|
|
1494
|
+
return {
|
|
1495
|
+
left: IDENT_CHARACTER.test(before) ? ' ' : '',
|
|
1496
|
+
// A closing `"` ends its token whatever follows it, so a string filler
|
|
1497
|
+
// cannot fuse to the right. A number filler can: `0.000` followed by
|
|
1498
|
+
// `e5`, or by another digit, is one `FLOAT_LIT`.
|
|
1499
|
+
right: !replacement.endsWith('"') && IDENT_CHARACTER.test(after) ? ' ' : '',
|
|
1500
|
+
};
|
|
1501
|
+
});
|
|
1502
|
+
// Where each span lands in the rewritten text, accumulated left to right.
|
|
1503
|
+
let shift = 0;
|
|
1504
|
+
const rewritten = chosen.map((rewrite, index) => {
|
|
1505
|
+
const replacement = replacements[index];
|
|
1506
|
+
const { left, right } = padding[index];
|
|
1507
|
+
const start = rewrite.start + shift + left.length;
|
|
1508
|
+
shift += left.length + replacement.length + right.length - (rewrite.end - rewrite.start);
|
|
1509
|
+
return {
|
|
1510
|
+
before: { start: rewrite.start, end: rewrite.end },
|
|
1511
|
+
after: { start, end: start + replacement.length },
|
|
1512
|
+
};
|
|
1513
|
+
});
|
|
1514
|
+
// Applied right to left: a `receiver` rewrite changes the width of the text,
|
|
1515
|
+
// and splicing from the end keeps every earlier span indexing what it named.
|
|
1516
|
+
const original = text;
|
|
1517
|
+
for (let index = chosen.length - 1; index >= 0; index -= 1) {
|
|
1518
|
+
const rewrite = chosen[index];
|
|
1519
|
+
const { left, right } = padding[index];
|
|
1520
|
+
text =
|
|
1521
|
+
text.slice(0, rewrite.start) +
|
|
1522
|
+
left +
|
|
1523
|
+
replacements[index] +
|
|
1524
|
+
right +
|
|
1525
|
+
text.slice(rewrite.end);
|
|
1526
|
+
}
|
|
1527
|
+
// Padding is a guess about where two tokens could fuse; this is the proof
|
|
1528
|
+
// that none did. A round that moved a token boundary anywhere outside its
|
|
1529
|
+
// own spans is refused outright rather than carried forward, so the caller
|
|
1530
|
+
// sees `applied: 0` and the expression falls to `cel-js-parse-failure`.
|
|
1531
|
+
if (!rewriteKeepsTokenBoundaries(original, text, rewritten)) {
|
|
1532
|
+
return { text: expression.source, applied: 0 };
|
|
1533
|
+
}
|
|
1534
|
+
applied += chosen.length;
|
|
1535
|
+
current = celDialectText(text);
|
|
1536
|
+
currentBrackets = buildBracketIndex(current.masked);
|
|
1537
|
+
}
|
|
1538
|
+
return { text, applied };
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Whether a rewrite round left the token stream alone outside its own spans.
|
|
1542
|
+
*
|
|
1543
|
+
* The proof a `cel-js-rejects-spec-cel` divergence stands on is "cel-js parses
|
|
1544
|
+
* this text, which differs from the original only where a confirmed shortfall
|
|
1545
|
+
* was replaced by a same-production spelling of it". String concatenation does
|
|
1546
|
+
* not respect that claim on its own: a replacement that abuts an `IDENT` or
|
|
1547
|
+
* digit character merges with it, and the rewrite then lexes a *different token
|
|
1548
|
+
* stream* from the original outside the spans it was licensed to change.
|
|
1549
|
+
* `x"y".size()` is an identifier next to a string literal, which no CEL
|
|
1550
|
+
* production joins; splicing the literal out gives `x__typekro_recv0.size()`,
|
|
1551
|
+
* a single identifier with a member call on it that cel-js parses happily. Left
|
|
1552
|
+
* unchecked, that reported a divergence against text cel-go rejects outright —
|
|
1553
|
+
* a false positive against strict mode, which is the one direction this module
|
|
1554
|
+
* may not fail in.
|
|
1555
|
+
*
|
|
1556
|
+
* So the boundary is verified rather than assumed. Both texts are tokenized
|
|
1557
|
+
* with the spec tokenizer {@link firstUnlexableOffset} walks with, and the round
|
|
1558
|
+
* holds only when all three hold:
|
|
1559
|
+
*
|
|
1560
|
+
* - no token straddles the edge of a rewritten span, on either side. A token
|
|
1561
|
+
* that touches a span lies wholly inside it, and no token reaches across two.
|
|
1562
|
+
* - the tokens *outside* the spans are the same sequence, character for
|
|
1563
|
+
* character, in the same order.
|
|
1564
|
+
* - each rewritten span is **exactly one token** in the result — the single
|
|
1565
|
+
* `IDENT` placeholder, `STRING_LIT` or `FLOAT_LIT` the substitution promised.
|
|
1566
|
+
*
|
|
1567
|
+
* Failing any of them means the splice said something about the text the
|
|
1568
|
+
* rewrite rule did not license, so the round is thrown away and no divergence
|
|
1569
|
+
* is proven. That is the safe direction: the expression is reported as
|
|
1570
|
+
* `cel-js-parse-failure` instead, which claims nothing beyond direct mode.
|
|
1571
|
+
*/
|
|
1572
|
+
function rewriteKeepsTokenBoundaries(before, after, spans) {
|
|
1573
|
+
const beforeTokens = celSpecTokens(before);
|
|
1574
|
+
const afterTokens = celSpecTokens(after);
|
|
1575
|
+
if (beforeTokens === undefined || afterTokens === undefined)
|
|
1576
|
+
return false;
|
|
1577
|
+
const outside = (tokens, side) => {
|
|
1578
|
+
const kept = [];
|
|
1579
|
+
for (const token of tokens) {
|
|
1580
|
+
const touched = spans.filter((span) => {
|
|
1581
|
+
const at = side(span);
|
|
1582
|
+
return token.start < at.end && at.start < token.end;
|
|
1583
|
+
});
|
|
1584
|
+
if (touched.length === 0) {
|
|
1585
|
+
kept.push(token);
|
|
1586
|
+
continue;
|
|
1587
|
+
}
|
|
1588
|
+
// A token reaching across two spans, or past the edge of the one it
|
|
1589
|
+
// touches, is a token the splice created or destroyed.
|
|
1590
|
+
if (touched.length > 1)
|
|
1591
|
+
return undefined;
|
|
1592
|
+
const at = side(touched[0]);
|
|
1593
|
+
if (token.start < at.start || token.end > at.end)
|
|
1594
|
+
return undefined;
|
|
1595
|
+
}
|
|
1596
|
+
return kept;
|
|
1597
|
+
};
|
|
1598
|
+
const keptBefore = outside(beforeTokens, (span) => span.before);
|
|
1599
|
+
const keptAfter = outside(afterTokens, (span) => span.after);
|
|
1600
|
+
if (keptBefore === undefined || keptAfter === undefined)
|
|
1601
|
+
return false;
|
|
1602
|
+
if (keptBefore.length !== keptAfter.length)
|
|
1603
|
+
return false;
|
|
1604
|
+
for (let index = 0; index < keptBefore.length; index += 1) {
|
|
1605
|
+
const left = keptBefore[index];
|
|
1606
|
+
const right = keptAfter[index];
|
|
1607
|
+
if (before.slice(left.start, left.end) !== after.slice(right.start, right.end))
|
|
1608
|
+
return false;
|
|
1609
|
+
}
|
|
1610
|
+
return spans.every((span) => afterTokens.some((token) => token.start === span.after.start && token.end === span.after.end));
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* The rewrite, when cel-js accepts it whole — i.e. when it proves the divergence.
|
|
1614
|
+
*
|
|
1615
|
+
* "Accepts it whole" is {@link celJsAcceptsWhole} rather than `parse()`: a
|
|
1616
|
+
* rewrite that only "parses" because cel-js's lexer dropped a character it has
|
|
1617
|
+
* no token for proves nothing about the character it dropped, and the text the
|
|
1618
|
+
* proof is about is the text including that character. The original is held to
|
|
1619
|
+
* the same bar, so a proof is never built on top of an expression that is not
|
|
1620
|
+
* itself lexically CEL.
|
|
1621
|
+
*/
|
|
1622
|
+
function celJsParseProof(text, brackets) {
|
|
1623
|
+
if (firstUnlexableOffset(text.reported) >= 0)
|
|
1624
|
+
return undefined;
|
|
1625
|
+
const rewritten = rewriteAwayCelJsLimitations(text, brackets);
|
|
1626
|
+
return rewritten.applied > 0 && celJsAcceptsWhole(rewritten.text) ? rewritten.text : undefined;
|
|
1627
|
+
}
|
|
1628
|
+
/**
|
|
1629
|
+
* Offset of the first character of `expression` no CEL token can carry, or `-1`
|
|
1630
|
+
* when the spec's lexical grammar covers the text end to end.
|
|
1631
|
+
*
|
|
1632
|
+
* Exported for the same reason {@link celDialectParseProof} is: the coverage
|
|
1633
|
+
* scan is half of what "cel-js parsed it" means here, so it has to be checkable
|
|
1634
|
+
* from outside rather than taken on trust. `-1` is the precondition for every
|
|
1635
|
+
* verdict this module draws from a cel-js parse — see
|
|
1636
|
+
* {@link firstUnlexableOffset} for why `parse()` alone is not enough.
|
|
1637
|
+
*/
|
|
1638
|
+
export function celDialectLexicalGap(expression) {
|
|
1639
|
+
return firstUnlexableOffset(expression.trim());
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* The rewritten expression a `cel-js-rejects-spec-cel` divergence stands on, or
|
|
1643
|
+
* `undefined` when there is none.
|
|
1644
|
+
*
|
|
1645
|
+
* Exported so the proof can be reproduced rather than taken on trust: the
|
|
1646
|
+
* returned text differs from `expression` only where a confirmed cel-js
|
|
1647
|
+
* shortfall was replaced by a same-production spelling cel-js has, cel-js's
|
|
1648
|
+
* lexer carries every character of it and cel-js parses it. An expression with
|
|
1649
|
+
* no identified shortfall, one whose own text is not lexically CEL, or one where
|
|
1650
|
+
* taking the shortfalls away still leaves text cel-js refuses, yields
|
|
1651
|
+
* `undefined` — and that is exactly the case the check refuses to call a
|
|
1652
|
+
* divergence.
|
|
1653
|
+
*/
|
|
1654
|
+
export function celDialectParseProof(expression) {
|
|
1655
|
+
const trimmed = expression.trim();
|
|
1656
|
+
if (trimmed.length === 0 || trimmed.length > CEL_DIALECT_MAX_EXPRESSION_LENGTH)
|
|
1657
|
+
return undefined;
|
|
1658
|
+
const text = celDialectText(trimmed);
|
|
1659
|
+
const brackets = buildBracketIndex(text.masked);
|
|
1660
|
+
if (findSpecCelCelJsRejects(text, brackets).length === 0)
|
|
1661
|
+
return undefined;
|
|
1662
|
+
return celJsParseProof(text, brackets);
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Text that is not CEL under the spec's own grammar, on any engine.
|
|
1666
|
+
*
|
|
1667
|
+
* The list is built from the spec rather than from an engine, and only from
|
|
1668
|
+
* *absences*: a character or sequence the lexical grammar has no token for, or
|
|
1669
|
+
* a production that cannot be completed. Nothing here is inferred from cel-js
|
|
1670
|
+
* having failed — indeed this runs whether cel-js parsed or not, because its
|
|
1671
|
+
* lexer silently drops a character it has no token for and hands `a === b` to
|
|
1672
|
+
* its parser as `a == b`.
|
|
1673
|
+
*
|
|
1674
|
+
* A trailing `.length` is deliberately *not* in this set. It is a plain
|
|
1675
|
+
* `Member "." SELECTOR`, grammatical on both engines; whether the field exists
|
|
1676
|
+
* is a question about a type this module cannot see.
|
|
1677
|
+
*
|
|
1678
|
+
* @param parsed Whether cel-js's *parser* accepted the token stream. The
|
|
1679
|
+
* unpaired-`?` check is gated on a failure only because a successful parse
|
|
1680
|
+
* already proves every ternary is closed — `?` and `:` are both cel-js tokens,
|
|
1681
|
+
* so neither can be among the characters its lexer dropped — which makes
|
|
1682
|
+
* running the check wasted work rather than unsound.
|
|
1683
|
+
* @param unlexableAt Offset of the first character no CEL token can carry, from
|
|
1684
|
+
* {@link firstUnlexableOffset}, or `-1`. Reported here because "the lexical
|
|
1685
|
+
* grammar has no token for this character" is a fact about the spec, not
|
|
1686
|
+
* about an engine, which is the standard the rest of this bucket is held to.
|
|
1687
|
+
*/
|
|
1688
|
+
function findNonCelTokens(text, parsed, unlexableAt) {
|
|
1689
|
+
// Comments are blanked out of both, so a `$` or an `=` written in one is not
|
|
1690
|
+
// a leak: `// a === b` is a comment on any conformant CEL lexer.
|
|
1691
|
+
const expression = text.source;
|
|
1692
|
+
const masked = text.masked;
|
|
1693
|
+
const found = [];
|
|
1694
|
+
const add = (at, length, reason) => {
|
|
1695
|
+
found.push({ at, fragment: expression.slice(at, at + Math.max(length, 8)).trim(), reason });
|
|
1696
|
+
};
|
|
1697
|
+
if (unlexableAt >= 0) {
|
|
1698
|
+
const character = expression[unlexableAt];
|
|
1699
|
+
add(unlexableAt, 8, character === '"' || character === "'"
|
|
1700
|
+
? 'an unterminated string literal — `STRING_LIT` has to close with the delimiter it opened with, and a single-quoted form cannot span a newline'
|
|
1701
|
+
: `\`${character}\` — a character the CEL lexical grammar has no token for: it is in neither \`IDENT\`, nor any literal form, nor the punctuation list`);
|
|
1702
|
+
}
|
|
1703
|
+
// The spec's punctuation list is `() [] {} . , ? : || && ! < <= >= > == != in
|
|
1704
|
+
// + - * / %`. There is no bare `=` in it and CEL has no assignment, so once
|
|
1705
|
+
// the four comparison operators that contain one are taken out, a remaining
|
|
1706
|
+
// `=` is a character no CEL token can carry: `===`, `!==`, `=>`, `a = b`.
|
|
1707
|
+
const withoutComparisons = masked.replace(/[=!<>]=/g, ' ');
|
|
1708
|
+
const stray = withoutComparisons.indexOf('=');
|
|
1709
|
+
if (stray >= 0) {
|
|
1710
|
+
add(Math.max(0, stray - 2), 6, '`=` outside `==`, `!=`, `<=` or `>=` — CEL has no assignment operator and no `=` token, so `===`, `!==` and `=>` are all JavaScript');
|
|
1711
|
+
}
|
|
1712
|
+
// `IDENT` is `[_a-zA-Z][_a-zA-Z0-9]*` and `$` is in neither it nor the
|
|
1713
|
+
// punctuation list, so a `${` is an un-substituted template placeholder.
|
|
1714
|
+
const template = masked.indexOf('${');
|
|
1715
|
+
if (template >= 0) {
|
|
1716
|
+
add(template, 8, '`${` — a template placeholder that was never substituted; `$` is not a CEL character');
|
|
1717
|
+
}
|
|
1718
|
+
// `Expr = ConditionalOr ["?" ConditionalOr ":" Expr]` is the only production
|
|
1719
|
+
// that consumes a `?`, and it always consumes a `:` with it. A top-level `?`
|
|
1720
|
+
// with no top-level `:` to close it cannot be parsed by any CEL grammar —
|
|
1721
|
+
// which is what `a?.b`, `a?[0]` and `a.?b` each reduce to, the `?` of
|
|
1722
|
+
// JavaScript optional chaining having no ternary behind it.
|
|
1723
|
+
if (!parsed) {
|
|
1724
|
+
let depth = 0;
|
|
1725
|
+
let questions = 0;
|
|
1726
|
+
let colons = 0;
|
|
1727
|
+
let firstQuestion = -1;
|
|
1728
|
+
for (let index = 0; index < masked.length; index += 1) {
|
|
1729
|
+
const character = masked[index];
|
|
1730
|
+
if (character === '(' || character === '[' || character === '{')
|
|
1731
|
+
depth += 1;
|
|
1732
|
+
else if (character === ')' || character === ']' || character === '}')
|
|
1733
|
+
depth -= 1;
|
|
1734
|
+
else if (depth === 0 && character === '?') {
|
|
1735
|
+
questions += 1;
|
|
1736
|
+
if (firstQuestion < 0)
|
|
1737
|
+
firstQuestion = index;
|
|
1738
|
+
}
|
|
1739
|
+
else if (depth === 0 && character === ':')
|
|
1740
|
+
colons += 1;
|
|
1741
|
+
}
|
|
1742
|
+
if (questions > colons && firstQuestion >= 0) {
|
|
1743
|
+
add(firstQuestion, 8, '`?` with no `:` to close the conditional — CEL spells optional access `a.b` behind a `has()` guard or a full `? :`, never as JavaScript optional chaining');
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
return found.sort((left, right) => left.at - right.at);
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* Check one emitted status CEL expression against both dialects.
|
|
1750
|
+
*
|
|
1751
|
+
* @param expression The emitted CEL, without any `${...}` wrapper.
|
|
1752
|
+
* @param field The status leaf path the expression belongs to.
|
|
1753
|
+
*/
|
|
1754
|
+
export function checkCelDialectCompatibility(expression, field) {
|
|
1755
|
+
const findings = [];
|
|
1756
|
+
const trimmed = expression.trim();
|
|
1757
|
+
if (trimmed.length === 0)
|
|
1758
|
+
return findings;
|
|
1759
|
+
// Budget gate, before either half. Both halves are linear in the length of
|
|
1760
|
+
// the expression, so an expression that has run away makes the check run away
|
|
1761
|
+
// with it — a 6MB one costs ~6.5s and, having no denylisted form in it,
|
|
1762
|
+
// reports nothing for the trouble. Refuse to spend serialization time on it
|
|
1763
|
+
// and report the size itself, which is the real defect.
|
|
1764
|
+
if (trimmed.length > CEL_DIALECT_MAX_EXPRESSION_LENGTH) {
|
|
1765
|
+
findings.push(finding('expression-too-large', field,
|
|
1766
|
+
// Carry a bounded prefix rather than megabytes of text into the report.
|
|
1767
|
+
`${trimmed.slice(0, 200)}…`, undefined, `the expression is ${trimmed.length} characters, past the ${CEL_DIALECT_MAX_EXPRESSION_LENGTH} character dual-dialect analysis budget, so neither half of the check was run. An expression this size cannot be served by either engine: cel-js spends seconds parsing it on every direct-mode reconcile, and a ResourceGraphDefinition carrying it is past the Kubernetes object size limit`, 'Shrink the status field. An expression this large is a runaway expansion rather than authored status — most often a nested composition whose inlined status re-expands into itself; give the inner composition an explicit status field and reference that instead'));
|
|
1768
|
+
return findings;
|
|
1769
|
+
}
|
|
1770
|
+
// One lexical pass, three aligned texts: what was written, the same with
|
|
1771
|
+
// comments blanked, and that with the string literals blanked too. Every scan
|
|
1772
|
+
// below reads the one it is entitled to; see {@link CelDialectText}.
|
|
1773
|
+
const text = celDialectText(trimmed);
|
|
1774
|
+
// Every bracket pair, resolved once. Both halves of the check walk the same
|
|
1775
|
+
// text repeatedly — the receiver detectors touch every opener in it — and
|
|
1776
|
+
// rescanning forward from each one is what used to make a deeply nested
|
|
1777
|
+
// expression quadratic.
|
|
1778
|
+
const brackets = buildBracketIndex(text.masked);
|
|
1779
|
+
// Half one: what cel-js's parser can and cannot be made to say.
|
|
1780
|
+
//
|
|
1781
|
+
// It can say that *direct mode* cannot evaluate the field, and nothing more.
|
|
1782
|
+
// cel-js 0.8.2 is not a conformant CEL grammar — it rejects `"x".size()`,
|
|
1783
|
+
// `[1,2].size()`, `(a).b` and `1e3`, all of which the spec permits — so a
|
|
1784
|
+
// parse failure on its own is no evidence at all about cel-go or KRO. The
|
|
1785
|
+
// three buckets below each carry their own evidence instead, and only the one
|
|
1786
|
+
// backed by a positive, spec-cited identification of valid CEL is a
|
|
1787
|
+
// divergence.
|
|
1788
|
+
//
|
|
1789
|
+
// "cel-js parsed it" means its lexer carried every character *and* its parser
|
|
1790
|
+
// accepted the token stream. `parse()` checks only the second half — it reads
|
|
1791
|
+
// `parserInstance.errors` and discards `lexResult.errors` — so a text whose
|
|
1792
|
+
// unknown characters its lexer dropped "parses" while cel-js never saw all of
|
|
1793
|
+
// it. `unlexableAt` is the missing half; see {@link firstUnlexableOffset}.
|
|
1794
|
+
const reading = celJsReads(trimmed);
|
|
1795
|
+
const parsed = reading.parsed && reading.unlexableAt < 0;
|
|
1796
|
+
// Bucket one: text no CEL grammar accepts. Built from the spec's own lexical
|
|
1797
|
+
// and syntactic grammar, never from cel-js's verdict, and so run whether or
|
|
1798
|
+
// not cel-js parsed — its lexer drops an unknown character silently, and
|
|
1799
|
+
// `a === b` reaches its parser as `a == b`.
|
|
1800
|
+
const nonCel = findNonCelTokens(text, reading.parsed, reading.unlexableAt);
|
|
1801
|
+
const leak = nonCel[0];
|
|
1802
|
+
// Hoisted out of bucket two: half two is gated on it. See the comment there.
|
|
1803
|
+
let proof;
|
|
1804
|
+
if (leak !== undefined) {
|
|
1805
|
+
findings.push(finding('not-valid-cel', field, trimmed, leak.fragment, `this is not CEL under the language grammar, whichever engine reads it: ${leak.reason}. Both engines reject it at the parse, before any type environment is consulted, so direct mode can never evaluate this status field and cel-go cannot parse it inside the KRO controller either`, 'Usually JavaScript that survived conversion. Write the CEL form instead: a has() guard rather than `?.`, an index rather than `?[`, `==` rather than `===`, and a resolved reference rather than an un-substituted template placeholder'));
|
|
1806
|
+
}
|
|
1807
|
+
else if (!parsed) {
|
|
1808
|
+
// Bucket two: a cel-js parse failure that a positive check identifies as
|
|
1809
|
+
// grammatical CEL. This may fail strict mode.
|
|
1810
|
+
//
|
|
1811
|
+
// What it claims, exactly: the CEL grammar permits the form, cel-js's
|
|
1812
|
+
// refusal is cel-js's own shortfall, and direct mode can therefore never
|
|
1813
|
+
// evaluate this field. It does *not* claim the field resolves under KRO.
|
|
1814
|
+
// Parsing is not evaluating: cel-go's type checker runs after the parse with
|
|
1815
|
+
// KRO's type environment and function set, and it rejects plenty of
|
|
1816
|
+
// grammatical CEL — `1.string()` parses, but `string` is a global conversion
|
|
1817
|
+
// function rather than a member, so the checker refuses the call; an unknown
|
|
1818
|
+
// member function goes the same way. Nothing here models that checker, so
|
|
1819
|
+
// nothing here may speak for it.
|
|
1820
|
+
//
|
|
1821
|
+
// `divergence` is still the right kind under that weaker claim. The two
|
|
1822
|
+
// engines disagree on the *form*, at the parse stage both of them have and
|
|
1823
|
+
// which needs no type environment at all: the spec's grammar admits it,
|
|
1824
|
+
// cel-go implements that grammar, and cel-js does not. Direct mode is
|
|
1825
|
+
// therefore broken on an expression the language permits, whatever cel-go's
|
|
1826
|
+
// checker later decides — so the emitted CEL is defective for one of the two
|
|
1827
|
+
// targets TypeKro serializes for, which is what strict mode exists to catch.
|
|
1828
|
+
//
|
|
1829
|
+
// Finding a known cel-js shortfall *somewhere* in the text is not enough to
|
|
1830
|
+
// get here. A parse failure has exactly one cause the whole expression can
|
|
1831
|
+
// be blamed on, and an expression that carries a shortfall may also simply
|
|
1832
|
+
// be ungrammatical elsewhere — `"x".size() +`, `(a).b ==`, `"x".size())`.
|
|
1833
|
+
// cel-go rejects those too, so calling them divergences would fail strict
|
|
1834
|
+
// mode on genuinely invalid CEL. The stronger claim is the one that has to
|
|
1835
|
+
// hold: rewrite the identified shortfalls into spellings cel-js does have,
|
|
1836
|
+
// leaving everything else untouched, and ask cel-js again. Only if the
|
|
1837
|
+
// rewrite parses is the shortfall the *only* obstruction, which is what
|
|
1838
|
+
// makes the rest of the expression grammatical CEL and cel-go's acceptance
|
|
1839
|
+
// of it a fact rather than an inference.
|
|
1840
|
+
const limitation = findSpecCelCelJsRejects(text, brackets)[0];
|
|
1841
|
+
proof = limitation === undefined ? undefined : celJsParseProof(text, brackets);
|
|
1842
|
+
if (limitation !== undefined && proof !== undefined) {
|
|
1843
|
+
findings.push(finding('cel-js-rejects-spec-cel', field, trimmed, limitation.fragment,
|
|
1844
|
+
// The proof keeps the original's offsets, so a blanked comment leaves a
|
|
1845
|
+
// run of spaces in it. The quoted form is trimmed: that changes no
|
|
1846
|
+
// token, and keeps it something a reader can paste into `parse()`.
|
|
1847
|
+
`cel-js cannot parse this, but the CEL grammar permits it: ${limitation.reason} (cel-spec doc/langdef.md, "Syntax"). Replacing only that form with a spelling cel-js does have — \`${excerpt(proof.trim())}\` — makes cel-js parse the whole expression, so nothing else in it is ungrammatical and the refusal is cel-js's shortfall alone. Direct mode can therefore never evaluate this field. Whether KRO evaluates it is a further question this check does not model: cel-go parses the form, but its type checker then runs with KRO's type environment and function set and may still reject it`, 'Rewrite the receiver as an identifier chain — bind the literal or parenthesized value to a resource field, or use the global form of the call (`size(x)` rather than `x.size()`) — until cel-js supports the spec form'));
|
|
1848
|
+
}
|
|
1849
|
+
else {
|
|
1850
|
+
// Bucket three: cel-js cannot parse it and nothing above explains why —
|
|
1851
|
+
// either no known shortfall was identified, or one was and taking it away
|
|
1852
|
+
// still left text cel-js refuses, which means something else in the
|
|
1853
|
+
// expression is at fault and no divergence is established. The only sound
|
|
1854
|
+
// claim either way is about direct mode.
|
|
1855
|
+
const unexplained = limitation === undefined
|
|
1856
|
+
? 'Neither the non-CEL token scan nor the confirmed cel-js shortfalls account for the refusal'
|
|
1857
|
+
: `A confirmed cel-js shortfall was identified in it (${limitation.reason}), but rewriting that form into one cel-js does have still leaves the expression unparseable, so the shortfall is not the whole reason and nothing here is established beyond direct mode`;
|
|
1858
|
+
findings.push(finding('cel-js-parse-failure', field, trimmed, undefined, `cel-js cannot parse this expression, so direct mode cannot evaluate this field; the CEL specification may still permit it, and the controller may still serve it. ${unexplained}. Verify against the spec grammar; if it is valid CEL, this is a cel-js limitation worth reporting upstream`, 'Check the expression against cel-spec doc/langdef.md. If the grammar permits it, only direct mode is certainly affected — whether Kro mode serves the field also depends on cel-go\'s type checker under KRO\'s environment, which this check does not model. Otherwise fix the emitted CEL'));
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
// Half two: the curated cel-go/cel-js divergence denylist, gated on the
|
|
1862
|
+
// expression being established grammatical.
|
|
1863
|
+
//
|
|
1864
|
+
// Every rule here is regex- and bracket-mask-based rather than tree-based, so
|
|
1865
|
+
// none of them *needs* a parse to run — and that is exactly why the gate has
|
|
1866
|
+
// to be explicit. What each rule needs is not a parse tree but the structure
|
|
1867
|
+
// it reads off the text: `has-index-argument` wants the argument list of a
|
|
1868
|
+
// `has(` to be a real bracket pair, `heterogeneous-map-literal` wants `{`…`}`
|
|
1869
|
+
// to be a map literal, and `guard-after-use-in-logical-chain` wants the
|
|
1870
|
+
// top-level `&&`/`||` split to be the expression's actual operator chain. On
|
|
1871
|
+
// ungrammatical text none of that is established: `a?.b && has(list[0].f)` and
|
|
1872
|
+
// `foo(((( && has(list[0].f)` are not CEL at all, yet both used to report a
|
|
1873
|
+
// `has-index-argument` divergence and so could fail strict mode on text cel-go
|
|
1874
|
+
// rejects outright — the exact class of false positive this check exists to
|
|
1875
|
+
// avoid. cel-js merely failing to parse something is *not* that case, which is
|
|
1876
|
+
// the point the gate has to be careful about: cel-js is not a conformant
|
|
1877
|
+
// grammar, so its refusal alone establishes nothing either way.
|
|
1878
|
+
//
|
|
1879
|
+
// So the gate is grammaticality, established one of two ways:
|
|
1880
|
+
//
|
|
1881
|
+
// - cel-js accepted the whole expression — its lexer carried every character
|
|
1882
|
+
// and its parser took the token stream. cel-js's grammar is a subset of the
|
|
1883
|
+
// spec's, so anything it accepts is CEL.
|
|
1884
|
+
// - the rewrite proof succeeded. The proof text is grammatical CEL, and the
|
|
1885
|
+
// original differs from it only inside the rewritten spans — each of which
|
|
1886
|
+
// is itself either a single well-formed literal token or a bracketed span
|
|
1887
|
+
// cel-js has separately been shown to accept (see
|
|
1888
|
+
// {@link celJsAcceptsSwallowedSpan}). So the original is grammatical too.
|
|
1889
|
+
//
|
|
1890
|
+
// Under a proof the rules run over the **original** text, not the proof text.
|
|
1891
|
+
// That is both sound and necessary. Sound, because a rewrite substitutes a
|
|
1892
|
+
// same-production spelling for a balanced span: a `receiver` rewrite replaces
|
|
1893
|
+
// a whole bracket pair (or a quoted token) with an identifier and the `string`
|
|
1894
|
+
// and `float` rewrites replace one literal token with another, so no bracket
|
|
1895
|
+
// pair outside a rewritten span is opened, closed or re-paired, and no
|
|
1896
|
+
// top-level `&&`/`||`/`?`/`:` is added or removed — every operator a rewrite
|
|
1897
|
+
// takes away was inside a bracket pair and so was never part of the top-level
|
|
1898
|
+
// chain. Necessary, because the rules must be able to read *inside* the
|
|
1899
|
+
// rewritten spans: `has(list[0].f).x` collapses to `__typekro_recv0.x`, and
|
|
1900
|
+
// the `has()` the rule is looking for survives only in the original.
|
|
1901
|
+
//
|
|
1902
|
+
// On text that is neither parsed nor proven, nothing here runs — note-kind
|
|
1903
|
+
// rules included. A note about a map literal in text that is not CEL describes
|
|
1904
|
+
// a structure that is not there, and the expression is already reported by
|
|
1905
|
+
// half one.
|
|
1906
|
+
if (!(parsed || proof !== undefined))
|
|
1907
|
+
return findings;
|
|
1908
|
+
checkHasIndexArgument(text, field, findings, brackets);
|
|
1909
|
+
checkHeterogeneousMapLiteral(text, field, findings, brackets);
|
|
1910
|
+
checkInOnListEntry(text, field, findings);
|
|
1911
|
+
const blanked = blankLazyRegions(text.masked, brackets);
|
|
1912
|
+
checkLogicalChain(text, blanked, field, { start: 0, end: text.masked.length }, [], findings, 0, brackets);
|
|
1913
|
+
return findings;
|
|
1914
|
+
}
|
|
1915
|
+
/** Strip the KRO `${...}` wrapper from an emitted status value. */
|
|
1916
|
+
function unwrapKroExpression(value) {
|
|
1917
|
+
const match = /^\$\{([\s\S]*)\}$/.exec(value.trim());
|
|
1918
|
+
return match?.[1];
|
|
1919
|
+
}
|
|
1920
|
+
/**
|
|
1921
|
+
* Check every emitted status CEL expression in a serialized status map.
|
|
1922
|
+
*
|
|
1923
|
+
* Accepts the nested shape `serializeStatusMappingsToCel` produces: leaves are
|
|
1924
|
+
* `${...}`-wrapped CEL strings, and objects/arrays nest arbitrarily. Values
|
|
1925
|
+
* that carry no CEL are skipped, so plain literal status fields cost nothing.
|
|
1926
|
+
*/
|
|
1927
|
+
export function collectStatusCelDialectFindings(statusCel) {
|
|
1928
|
+
const findings = [];
|
|
1929
|
+
const walk = (value, path) => {
|
|
1930
|
+
if (typeof value === 'string') {
|
|
1931
|
+
const expression = unwrapKroExpression(value);
|
|
1932
|
+
if (expression !== undefined)
|
|
1933
|
+
findings.push(...checkCelDialectCompatibility(expression, path));
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
if (Array.isArray(value)) {
|
|
1937
|
+
value.forEach((item, index) => walk(item, `${path}[${index}]`));
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
if (value !== null && typeof value === 'object') {
|
|
1941
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
1942
|
+
walk(nested, path === '' ? key : `${path}.${key}`);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
};
|
|
1946
|
+
for (const [key, value] of Object.entries(statusCel))
|
|
1947
|
+
walk(value, key);
|
|
1948
|
+
return findings;
|
|
1949
|
+
}
|
|
1950
|
+
/**
|
|
1951
|
+
* Longest expression or fragment a report will quote, in characters.
|
|
1952
|
+
*
|
|
1953
|
+
* The analysis budget already keeps `checkCelDialectCompatibility` from
|
|
1954
|
+
* returning an oversized `expression`, but the formatter is exported and takes
|
|
1955
|
+
* findings from wherever the caller got them — so it bounds what it quotes on
|
|
1956
|
+
* its own rather than trusting its input. A report is read by a human; past a
|
|
1957
|
+
* couple of lines per finding the excerpt stops helping and starts being the
|
|
1958
|
+
* thing that fills the log.
|
|
1959
|
+
*/
|
|
1960
|
+
export const CEL_DIALECT_MAX_EXCERPT_LENGTH = 400;
|
|
1961
|
+
/** Quote at most {@link CEL_DIALECT_MAX_EXCERPT_LENGTH} characters of a snippet. */
|
|
1962
|
+
function excerpt(text) {
|
|
1963
|
+
if (text.length <= CEL_DIALECT_MAX_EXCERPT_LENGTH)
|
|
1964
|
+
return text;
|
|
1965
|
+
// `slice` counts UTF-16 code units like everything else here, so the cut can
|
|
1966
|
+
// land between the two halves of a surrogate pair and render the last
|
|
1967
|
+
// character as a replacement glyph. Step back one unit when it does.
|
|
1968
|
+
const lead = text.charCodeAt(CEL_DIALECT_MAX_EXCERPT_LENGTH - 1);
|
|
1969
|
+
const cut = lead >= 0xd800 && lead <= 0xdbff
|
|
1970
|
+
? CEL_DIALECT_MAX_EXCERPT_LENGTH - 1
|
|
1971
|
+
: CEL_DIALECT_MAX_EXCERPT_LENGTH;
|
|
1972
|
+
return `${text.slice(0, cut)}… (${text.length} characters)`;
|
|
1973
|
+
}
|
|
1974
|
+
/** How a finding's verdict reads at the head of its report entry. */
|
|
1975
|
+
function verdict(found) {
|
|
1976
|
+
if (found.dialect === 'unchecked')
|
|
1977
|
+
return 'not checked';
|
|
1978
|
+
if (found.dialect === 'both')
|
|
1979
|
+
return 'rejected by both dialects';
|
|
1980
|
+
// A cel-js parse failure is a certainty about cel-js and says nothing at all
|
|
1981
|
+
// about the other engine, so it reads as neither "rejected" (which would
|
|
1982
|
+
// imply a verdict was reached on the form) nor "may be rejected".
|
|
1983
|
+
if (found.rule === 'cel-js-parse-failure')
|
|
1984
|
+
return 'not parseable by cel-js, cel-go unknown';
|
|
1985
|
+
return `${found.kind === 'divergence' ? 'rejected by' : 'may be rejected by'} ${found.dialect}`;
|
|
1986
|
+
}
|
|
1987
|
+
/** Render findings as a multi-line report naming the leaf, dialect and expression. */
|
|
1988
|
+
export function formatCelDialectFindings(findings) {
|
|
1989
|
+
return findings
|
|
1990
|
+
.map((found) => ` status.${found.field}: ${verdict(found)} [${found.rule}, ${found.kind}]\n` +
|
|
1991
|
+
` ${found.message}\n` +
|
|
1992
|
+
(found.fragment ? ` at: ${excerpt(found.fragment)}\n` : '') +
|
|
1993
|
+
` expression: ${excerpt(found.expression)}\n` +
|
|
1994
|
+
` fix: ${found.suggestion}`)
|
|
1995
|
+
.join('\n');
|
|
1996
|
+
}
|
|
1997
|
+
//# sourceMappingURL=cel-dialect.js.map
|