@frontera-sdk/cli 1.52.39 → 1.52.40
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/README.md +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +48 -4
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +331 -31
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +875 -88
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +320 -13
- package/src/commands/local/sync.ts +102 -5
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +52 -8
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +112 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +211 -21
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +180 -7
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +346 -35
- package/src/local/local-dev-sync.ts +136 -7
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +205 -7
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +141 -16
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +38 -8
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +1 -1
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +11 -0
- package/src/local/local-sync-push.ts +10 -7
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +343 -32
- package/src/local/local-sync-snapshot.ts +15 -10
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- package/src/vendor/sdk-sources.json +24 -21
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { actionSemanticConflicts, semanticFactsOf } from './action-semantic-fit'
|
|
2
|
+
import type { AuthoredFile } from './model'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The rules a governed Action file must follow that its shape alone does not
|
|
6
|
+
* show, checked against the tree before anything is written.
|
|
7
|
+
*
|
|
8
|
+
* Each rule is one the platform enforces, said here with the file's path and
|
|
9
|
+
* what to write instead, so an author learns it from `blueprint plan` rather
|
|
10
|
+
* than from a refusal at apply or, worse, at deploy:
|
|
11
|
+
*
|
|
12
|
+
* - `refused`: the Action draft route refuses the file (the service's Action
|
|
13
|
+
* contract: `action-definition-schema.ts`, `action-reference-resolution.ts`,
|
|
14
|
+
* and `action-semantic-fit.ts`: a value the set property's semantic type
|
|
15
|
+
* refuses, unless the Action is still as the workspace gave it: then it is
|
|
16
|
+
* `inherited`, a warning).
|
|
17
|
+
* `blueprint apply` stops before its first write, so a run never lands half.
|
|
18
|
+
* - `undeployable`: the file applies, but the write path the local runtime
|
|
19
|
+
* derives for it (`ontology-edit-plan.ts`) or the deployment's checks
|
|
20
|
+
* (`action-binding-service.ts`, `mutation-plan-schema.ts`) refuse it. Said as
|
|
21
|
+
* a warning: the Blueprint is still usable, only that Action does not run.
|
|
22
|
+
*
|
|
23
|
+
* Only what those validators check is checked here; anything else is left to
|
|
24
|
+
* them, so this is never stricter than the platform.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface ActionRuleProblems {
|
|
28
|
+
refused: string[]
|
|
29
|
+
undeployable: string[]
|
|
30
|
+
/** The Actions with a `refused` problem, by apiName. */
|
|
31
|
+
refusedActions: string[]
|
|
32
|
+
/**
|
|
33
|
+
* A semantic-type conflict in an Action still as the workspace gave it
|
|
34
|
+
* (`inheritedFromPull`): not a refusal. It is applied as the workspace runs
|
|
35
|
+
* it, and said as a warning, with whose fix it is.
|
|
36
|
+
*/
|
|
37
|
+
inherited?: string[]
|
|
38
|
+
/** The Actions with an `inherited` line, by apiName: written to the local runtime as inherited. */
|
|
39
|
+
inheritedActions?: string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Is this Action's conflict, resting on these files, the workspace's? (`inheritedFromPull`) */
|
|
43
|
+
export type InheritedConflict = (action: string, refs: readonly string[]) => boolean
|
|
44
|
+
|
|
45
|
+
/** What starts every inherited conflict's line, so a reader of the apply's output can tell it from an adaptation. */
|
|
46
|
+
export const INHERITED_FROM_PULL = 'inherited from the workspace (unchanged since the pull)'
|
|
47
|
+
|
|
48
|
+
/** An inherited conflict's line: what it is, that it runs, and whose fix it is. */
|
|
49
|
+
export function inheritedConflictLine(message: string): string {
|
|
50
|
+
return `${INHERITED_FROM_PULL}: ${message} It is applied as the workspace runs it, and a request with such a value is refused when it is submitted; the Action's owner fixes its contract in the workspace.`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type Doc = Record<string, unknown>
|
|
54
|
+
|
|
55
|
+
const isRecord = (v: unknown): v is Doc => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
56
|
+
const list = (v: unknown): Doc[] => (Array.isArray(v) ? v.filter(isRecord) : [])
|
|
57
|
+
|
|
58
|
+
/** An input's or output's apiName (`apiNameSchema`). */
|
|
59
|
+
export const ACTION_MEMBER_NAME = /^[a-z][A-Za-z0-9]{0,99}$/
|
|
60
|
+
/** An outcome's, criterion's or postcondition's code (`stableCodeSchema`). */
|
|
61
|
+
export const ACTION_STABLE_CODE = /^[a-z][a-z0-9_]{0,99}$/
|
|
62
|
+
|
|
63
|
+
const VALUE_KINDS = new Set(['string', 'number', 'boolean', 'date', 'timestamp', 'json', 'object_reference', 'geopoint'])
|
|
64
|
+
const VALUE_KEYS: Record<string, string[]> = {
|
|
65
|
+
string: ['kind', 'minLength', 'maxLength', 'pattern', 'enum'],
|
|
66
|
+
number: ['kind', 'integer', 'minimum', 'maximum'],
|
|
67
|
+
object_reference: ['kind', 'objectType', 'objectTypeId'],
|
|
68
|
+
}
|
|
69
|
+
const EDITS = new Set(['create', 'update', 'delete'])
|
|
70
|
+
|
|
71
|
+
const isNonNegativeInteger = (v: unknown) => typeof v === 'number' && Number.isInteger(v) && v >= 0
|
|
72
|
+
|
|
73
|
+
const PATTERN_MAX_LENGTH = 256
|
|
74
|
+
const PATTERN_MAX_REPEAT = 64
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Why a string pattern is outside the subset the platform accepts, or null:
|
|
78
|
+
* the service's `isSafeBlueprintActionPattern`, said as a reason. It must match
|
|
79
|
+
* the whole value (^…$), use no groups or alternation, and have at most one
|
|
80
|
+
* variable-width repetition (`*`, `+`, `?`, `{m,n}`) of at most 64.
|
|
81
|
+
*/
|
|
82
|
+
export function patternProblem(pattern: string): string | null {
|
|
83
|
+
if (pattern.length < 2 || pattern.length > PATTERN_MAX_LENGTH) return `must be 2 to ${PATTERN_MAX_LENGTH} characters`
|
|
84
|
+
if (!pattern.startsWith('^') || !pattern.endsWith('$')) return 'must match the whole value: start it with ^ and end it with $'
|
|
85
|
+
if (pattern !== pattern.normalize('NFC') || /[\u0000-\u001f\u007f]/.test(pattern)) return 'must be plain NFC text with no control characters'
|
|
86
|
+
try {
|
|
87
|
+
new RegExp(pattern)
|
|
88
|
+
} catch {
|
|
89
|
+
return 'is not a valid regular expression'
|
|
90
|
+
}
|
|
91
|
+
const body = pattern.slice(1, -1)
|
|
92
|
+
let i = 0
|
|
93
|
+
let width = 0
|
|
94
|
+
let variable = 0
|
|
95
|
+
while (i < body.length) {
|
|
96
|
+
const c = body[i]!
|
|
97
|
+
if (c === '\\') {
|
|
98
|
+
const escaped = body[i + 1]
|
|
99
|
+
if (!escaped || !/[dDwWsS^$\\.*+?()[\]{}|\-]/.test(escaped)) return `uses an escape (\\${escaped ?? ''}) outside the accepted set: \\d \\w \\s and their capitals, or an escaped symbol`
|
|
100
|
+
i += 2
|
|
101
|
+
} else if (c === '[') {
|
|
102
|
+
let j = i + 1
|
|
103
|
+
if (body[j] === '^') j += 1
|
|
104
|
+
const first = j
|
|
105
|
+
let closed = false
|
|
106
|
+
while (j < body.length) {
|
|
107
|
+
if (body[j] === '\\') {
|
|
108
|
+
if (j + 1 >= body.length) return 'ends inside a character class'
|
|
109
|
+
j += 2
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
if (body[j] === ']') {
|
|
113
|
+
closed = true
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
j += 1
|
|
117
|
+
}
|
|
118
|
+
if (!closed || j === first) return 'has an unclosed or empty character class'
|
|
119
|
+
i = j + 1
|
|
120
|
+
} else {
|
|
121
|
+
if (c === '(' || c === ')' || c === '|') return 'cannot use groups or alternation ( ( ) | ); write one character class per position'
|
|
122
|
+
if (/[*+?{}^$\]]/.test(c)) return `has a stray "${c}": a repetition follows a character or class, and ^ $ only open and close it`
|
|
123
|
+
i += 1
|
|
124
|
+
}
|
|
125
|
+
let repeat = 1
|
|
126
|
+
if (body[i] === '{') {
|
|
127
|
+
const close = body.indexOf('}', i + 1)
|
|
128
|
+
const q = close < 0 ? null : /^(\d+)(?:,(\d*))?$/.exec(body.slice(i + 1, close))
|
|
129
|
+
if (!q) return 'has a malformed {m,n} repetition'
|
|
130
|
+
const min = Number(q[1])
|
|
131
|
+
const ranged = q[2] !== undefined
|
|
132
|
+
const max = ranged && q[2] !== '' ? Number(q[2]) : min
|
|
133
|
+
if (min > max || max > PATTERN_MAX_REPEAT) return `repeats more than ${PATTERN_MAX_REPEAT} times, or {m,n} has m above n`
|
|
134
|
+
if (ranged) variable += 1
|
|
135
|
+
repeat = ranged && q[2] === '' ? min : max
|
|
136
|
+
i = close + 1
|
|
137
|
+
} else if (body[i] === '*' || body[i] === '+' || body[i] === '?') {
|
|
138
|
+
variable += 1
|
|
139
|
+
i += 1
|
|
140
|
+
}
|
|
141
|
+
if (variable > 1) return 'has more than one variable-width repetition (* + ? {m,n}); keep one, and fix the other widths ({4})'
|
|
142
|
+
width += repeat
|
|
143
|
+
if (width > PATTERN_MAX_LENGTH) return `can match more than ${PATTERN_MAX_LENGTH} characters`
|
|
144
|
+
}
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** `BLUEPRINT_ACTION_VALUE_MAX_BYTES`: the most an Action value may carry. */
|
|
149
|
+
const VALUE_MAX_BYTES = 65_536
|
|
150
|
+
|
|
151
|
+
function isIsoDate(value: string): boolean {
|
|
152
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
|
153
|
+
const date = new Date(`${value}T00:00:00.000Z`)
|
|
154
|
+
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isIsoTimestamp(value: string): boolean {
|
|
158
|
+
const match = /^(\d{4}-\d{2}-\d{2})T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.exec(value)
|
|
159
|
+
if (match === null || !isIsoDate(match[1]!)) return false
|
|
160
|
+
const time = Date.parse(value)
|
|
161
|
+
return !Number.isNaN(time) && /^\d{4}-/.test(new Date(time).toISOString())
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const bytes = (value: string) => new TextEncoder().encode(value).length
|
|
165
|
+
const show = (value: unknown) => JSON.stringify(value)
|
|
166
|
+
|
|
167
|
+
/** The note an `object_reference` field with a default gets: a portable file names no record's UUID. */
|
|
168
|
+
export const OBJECT_REFERENCE_DEFAULT_PROBLEM = 'takes no portable default (only a record\'s UUID fits); leave it off, or make it required'
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Why a field's default does not fit its value type, or null: the service's
|
|
172
|
+
* `defaultMatchesField`, said as a reason. A default is a JSON scalar; `null`
|
|
173
|
+
* fits only a json field. An `object_reference` takes only a record's UUID,
|
|
174
|
+
* which differs per runtime, so a portable file carries none.
|
|
175
|
+
*/
|
|
176
|
+
export function defaultProblem(value: unknown, vt: Doc): string | null {
|
|
177
|
+
const kind = vt.kind
|
|
178
|
+
if (kind === 'object_reference') return OBJECT_REFERENCE_DEFAULT_PROBLEM
|
|
179
|
+
if (kind === 'geopoint') return 'takes no default: a point is { lat, lon }, and a default is a single value; leave it off'
|
|
180
|
+
if (value !== null && !['boolean', 'number', 'string'].includes(typeof value)) return `must be a single value (text, a number or true/false), not ${Array.isArray(value) ? 'a list' : 'an object'}`
|
|
181
|
+
if (value === null && kind !== 'json') return `is null, which only a json field takes; leave the default off for "no value"`
|
|
182
|
+
if (typeof value === 'number' && (!Number.isFinite(value) || Object.is(value, -0))) return 'must be a finite number other than -0'
|
|
183
|
+
switch (kind) {
|
|
184
|
+
case 'string': {
|
|
185
|
+
if (typeof value !== 'string') return `must be text for a string field (it is ${show(value)})`
|
|
186
|
+
if (value !== value.normalize('NFC')) return 'must be NFC-normalized text'
|
|
187
|
+
if (bytes(value) > VALUE_MAX_BYTES) return `is longer than ${VALUE_MAX_BYTES} bytes`
|
|
188
|
+
if (isNonNegativeInteger(vt.minLength) && value.length < (vt.minLength as number)) return `${show(value)} is shorter than minLength ${String(vt.minLength)}`
|
|
189
|
+
if (isNonNegativeInteger(vt.maxLength) && value.length > (vt.maxLength as number)) return `${show(value)} is longer than maxLength ${String(vt.maxLength)}`
|
|
190
|
+
if (Array.isArray(vt.enum) && !vt.enum.includes(value)) return `${show(value)} is not one of its enum values (${vt.enum.map(show).join(', ')})`
|
|
191
|
+
if (typeof vt.pattern === 'string' && patternProblem(vt.pattern) === null) {
|
|
192
|
+
const match = new RegExp(vt.pattern).exec(value)
|
|
193
|
+
if (!match || match.index !== 0 || match[0].length !== value.length) return `${show(value)} does not match its pattern ${vt.pattern}`
|
|
194
|
+
}
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
case 'number':
|
|
198
|
+
if (typeof value !== 'number') return `must be a number for a number field (it is ${show(value)})`
|
|
199
|
+
if (vt.integer === true && !Number.isInteger(value)) return `${value} is not a whole number, and the field is integer: true`
|
|
200
|
+
if (typeof vt.minimum === 'number' && value < vt.minimum) return `${value} is below minimum ${vt.minimum}`
|
|
201
|
+
if (typeof vt.maximum === 'number' && value > vt.maximum) return `${value} is above maximum ${vt.maximum}`
|
|
202
|
+
return null
|
|
203
|
+
case 'boolean':
|
|
204
|
+
return typeof value === 'boolean' ? null : `must be true or false for a boolean field (it is ${show(value)})`
|
|
205
|
+
case 'date':
|
|
206
|
+
return typeof value === 'string' && isIsoDate(value) ? null : `must be a date, YYYY-MM-DD (it is ${show(value)})`
|
|
207
|
+
case 'timestamp':
|
|
208
|
+
return typeof value === 'string' && isIsoTimestamp(value) ? null : `must be an ISO 8601 time with a zone, like 2026-10-18T09:00:00Z (it is ${show(value)})`
|
|
209
|
+
case 'json':
|
|
210
|
+
return bytes(JSON.stringify(value)) > VALUE_MAX_BYTES ? `is longer than ${VALUE_MAX_BYTES} bytes` : null
|
|
211
|
+
default:
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function fieldProblems(fields: Doc[], at: string, out: string[]): void {
|
|
217
|
+
fields.forEach((field, i) => {
|
|
218
|
+
const where = `${at}[${i}]`
|
|
219
|
+
const name = field.apiName
|
|
220
|
+
if (typeof name === 'string' && !ACTION_MEMBER_NAME.test(name)) out.push(`${where}.apiName "${name}" must be camelCase: a lowercase letter, then letters and digits`)
|
|
221
|
+
if (field.required === true && Object.prototype.hasOwnProperty.call(field, 'default')) {
|
|
222
|
+
out.push(`${where} is required and has a default; a required field cannot carry one — drop the default, or make it required: false`)
|
|
223
|
+
}
|
|
224
|
+
const vt = field.valueType
|
|
225
|
+
if (!isRecord(vt)) return
|
|
226
|
+
const kind = typeof vt.kind === 'string' ? vt.kind : ''
|
|
227
|
+
if (!VALUE_KINDS.has(kind)) return
|
|
228
|
+
const allowed = VALUE_KEYS[kind] ?? ['kind']
|
|
229
|
+
for (const key of Object.keys(vt)) {
|
|
230
|
+
if (!allowed.includes(key)) out.push(`${where}.valueType.${key} is not part of a ${kind} value type (it takes ${allowed.filter((k) => k !== 'kind' && k !== 'objectTypeId').join(', ') || 'only kind'})`)
|
|
231
|
+
}
|
|
232
|
+
if (kind === 'number') {
|
|
233
|
+
if (typeof vt.integer !== 'boolean') out.push(`${where}.valueType.integer is required for a number: true for a whole number, false otherwise`)
|
|
234
|
+
for (const bound of ['minimum', 'maximum'] as const) {
|
|
235
|
+
if (vt[bound] === undefined) continue
|
|
236
|
+
if (typeof vt[bound] === 'string') out.push(`${where}.valueType.${bound} must be a number (${bound}: ${Number(vt[bound]) || 0}), not the text "${String(vt[bound])}" — decimal strings are a semantic type's spelling, not an Action's`)
|
|
237
|
+
else if (typeof vt[bound] !== 'number') out.push(`${where}.valueType.${bound} must be a number`)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (kind === 'string') {
|
|
241
|
+
for (const bound of ['minLength', 'maxLength'] as const) {
|
|
242
|
+
if (vt[bound] !== undefined && !isNonNegativeInteger(vt[bound])) out.push(`${where}.valueType.${bound} must be a whole number, 0 or more`)
|
|
243
|
+
}
|
|
244
|
+
if (typeof vt.pattern === 'string') {
|
|
245
|
+
const why = patternProblem(vt.pattern)
|
|
246
|
+
if (why) out.push(`${where}.valueType.pattern ${why}`)
|
|
247
|
+
}
|
|
248
|
+
if (Array.isArray(vt.enum) && new Set(vt.enum).size !== vt.enum.length) out.push(`${where}.valueType.enum lists a value twice; each value once`)
|
|
249
|
+
}
|
|
250
|
+
if (kind === 'object_reference' && typeof vt.objectType !== 'string' && typeof vt.objectTypeId !== 'string') {
|
|
251
|
+
out.push(`${where}.valueType needs objectType: the object type it points at, by apiName`)
|
|
252
|
+
}
|
|
253
|
+
// A required field's default is already said above; this is what an optional one's must fit.
|
|
254
|
+
if (field.required !== true && Object.prototype.hasOwnProperty.call(field, 'default')) {
|
|
255
|
+
const why = defaultProblem(field.default, vt)
|
|
256
|
+
if (why) out.push(`${where}.default ${why}`)
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function codeProblems(items: Doc[], at: string, what: string, out: string[]): void {
|
|
262
|
+
items.forEach((item, i) => {
|
|
263
|
+
if (typeof item.code === 'string' && !ACTION_STABLE_CODE.test(item.code)) {
|
|
264
|
+
out.push(`${at}[${i}].code "${item.code}" must be lowercase snake_case (${what}): a lowercase letter, then lowercase letters, digits or _`)
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
interface ObjectTypeFacts {
|
|
270
|
+
primaryKey?: string
|
|
271
|
+
/** Each property's value type, by apiName. */
|
|
272
|
+
valueTypes?: ReadonlyMap<string, string>
|
|
273
|
+
/** `backing.editable`: what a governed Action may write; null when the file declares none. */
|
|
274
|
+
editable?: ReadonlySet<string> | null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The input kinds that may set a property of each base type. */
|
|
278
|
+
const FITS: Record<string, string[]> = {
|
|
279
|
+
string: ['string', 'object_reference'],
|
|
280
|
+
number: ['number'],
|
|
281
|
+
date: ['date'],
|
|
282
|
+
timestamp: ['timestamp'],
|
|
283
|
+
boolean: ['boolean'],
|
|
284
|
+
geopoint: ['geopoint'],
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Every rule problem in the tree's Action files, by file. `inheritedConflict` says which semantic-type conflicts are the workspace's. */
|
|
288
|
+
export function actionRuleProblems(files: AuthoredFile[], inheritedConflict?: InheritedConflict): ActionRuleProblems {
|
|
289
|
+
const types = new Map<string, ObjectTypeFacts>()
|
|
290
|
+
for (const f of files) {
|
|
291
|
+
if (f.kind !== 'object-type') continue
|
|
292
|
+
const properties = list(f.document.properties)
|
|
293
|
+
const backing = isRecord(f.document.backing) ? f.document.backing : {}
|
|
294
|
+
types.set(f.apiName, {
|
|
295
|
+
primaryKey: typeof f.document.primaryKey === 'string' ? f.document.primaryKey : undefined,
|
|
296
|
+
valueTypes: new Map(properties.flatMap((p) => (typeof p.apiName === 'string' && typeof p.valueType === 'string' ? [[p.apiName, p.valueType] as const] : []))),
|
|
297
|
+
editable: Array.isArray(backing.editable) ? new Set(backing.editable.map(String)) : null,
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
const refused: string[] = []
|
|
301
|
+
const undeployable: string[] = []
|
|
302
|
+
const refusedActions: string[] = []
|
|
303
|
+
const inherited: string[] = []
|
|
304
|
+
const inheritedActions: string[] = []
|
|
305
|
+
// The semantic types of the properties an Action sets (the draft route's `addedSemanticConflicts`):
|
|
306
|
+
// refused when the person made the conflict, said as a warning when the workspace's Action has it.
|
|
307
|
+
const facts = semanticFactsOf(files)
|
|
308
|
+
for (const file of files) {
|
|
309
|
+
if (file.kind !== 'action') continue
|
|
310
|
+
const r: string[] = []
|
|
311
|
+
const u: string[] = []
|
|
312
|
+
const i: string[] = []
|
|
313
|
+
checkAction(file.document, types, r, u)
|
|
314
|
+
for (const c of actionSemanticConflicts(file.document, facts)) {
|
|
315
|
+
if (inheritedConflict?.(file.apiName, c.refs)) i.push(`${c.path}: ${inheritedConflictLine(c.message)}`)
|
|
316
|
+
else r.push(`${c.path}: ${c.message}`)
|
|
317
|
+
}
|
|
318
|
+
if (r.length) refusedActions.push(file.apiName)
|
|
319
|
+
else if (i.length) inheritedActions.push(file.apiName)
|
|
320
|
+
refused.push(...r.map((p) => `${file.path}: ${p}`))
|
|
321
|
+
undeployable.push(...u.map((p) => `${file.path}: ${p}`))
|
|
322
|
+
inherited.push(...i.map((p) => `${file.path}: ${p}`))
|
|
323
|
+
}
|
|
324
|
+
return { refused, undeployable, refusedActions, ...(inherited.length ? { inherited, inheritedActions } : {}) }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** One Action document: what the draft route refuses (`refused`) and what will not deploy (`undeployable`). */
|
|
328
|
+
export function checkAction(doc: Doc, types: ReadonlyMap<string, ObjectTypeFacts>, refused: string[], undeployable: string[]): void {
|
|
329
|
+
const subject = isRecord(doc.subject) ? doc.subject : {}
|
|
330
|
+
const effect = isRecord(doc.effect) ? doc.effect : {}
|
|
331
|
+
const concurrency = isRecord(doc.concurrency) ? doc.concurrency : {}
|
|
332
|
+
const mode = subject.mode
|
|
333
|
+
const effectKind = typeof effect.kind === 'string' ? effect.kind : ''
|
|
334
|
+
const strategy = concurrency.strategy
|
|
335
|
+
const inputs = list(doc.inputs)
|
|
336
|
+
const outcomes = list(doc.businessOutcomes)
|
|
337
|
+
|
|
338
|
+
fieldProblems(inputs, 'inputs', refused)
|
|
339
|
+
outcomes.forEach((o, i) => fieldProblems(list(o.output), `businessOutcomes[${i}].output`, refused))
|
|
340
|
+
codeProblems(outcomes, 'businessOutcomes', 'an outcome code', refused)
|
|
341
|
+
codeProblems(list(doc.submissionCriteria), 'submissionCriteria', 'a criterion code', refused)
|
|
342
|
+
outcomes.forEach((o, i) => codeProblems(list(o.postconditions), `businessOutcomes[${i}].postconditions`, 'a postcondition code', refused))
|
|
343
|
+
|
|
344
|
+
// A criterion, an approval condition or a postcondition reads only inputs that always have a value.
|
|
345
|
+
const optional = new Set(inputs.filter((f) => typeof f.apiName === 'string' && f.required !== true && !Object.prototype.hasOwnProperty.call(f, 'default')).map((f) => f.apiName as string))
|
|
346
|
+
if (optional.size) {
|
|
347
|
+
const reads = (where: string, node: unknown) => {
|
|
348
|
+
for (const field of new Set(inputReads(node))) {
|
|
349
|
+
if (optional.has(field)) refused.push(`${where} reads input ${field}, which is optional with no default: make it required: true, or give it a default`)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
list(doc.submissionCriteria).forEach((c, i) => reads(`submissionCriteria[${i}]`, c.assertion))
|
|
353
|
+
const approval = isRecord(doc.governance) && isRecord(doc.governance.approval) ? doc.governance.approval : null
|
|
354
|
+
list(approval?.stages).forEach((s, i) => reads(`governance.approval.stages[${i}].when`, s.when))
|
|
355
|
+
outcomes.forEach((o, i) => list(o.postconditions).forEach((pc, j) => reads(`businessOutcomes[${i}].postconditions[${j}]`, pc.assertion)))
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (mode === 'existing') {
|
|
359
|
+
if (effectKind === 'create') refused.push('subject.mode is existing but effect.kind is create: an Action on an existing record updates, deletes, transitions or runs a command; use subject.mode: create to create one')
|
|
360
|
+
if (strategy !== undefined && strategy !== 'expected_version') refused.push(`concurrency.strategy must be expected_version for an existing subject (it is ${String(strategy)})`)
|
|
361
|
+
} else if (mode === 'create') {
|
|
362
|
+
if (effectKind && effectKind !== 'create') refused.push(`subject.mode is create, so effect.kind must be create (it is ${effectKind})`)
|
|
363
|
+
if (strategy !== undefined && strategy !== 'create_if_absent' && strategy !== 'append_once') refused.push(`concurrency.strategy must be append_once for a create (it is ${String(strategy)})`)
|
|
364
|
+
}
|
|
365
|
+
// One open request per record: only on an existing record, and only that value.
|
|
366
|
+
if (concurrency.openRequests !== undefined) {
|
|
367
|
+
if (concurrency.openRequests !== 'one_per_subject') refused.push(`concurrency.openRequests must be one_per_subject (it is ${JSON.stringify(concurrency.openRequests)})`)
|
|
368
|
+
else if (mode !== 'existing') refused.push('concurrency.openRequests: one_per_subject keeps one open request per existing record, so it takes subject.mode: existing')
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Every change names the object type it is on; the property, transition and input by name.
|
|
372
|
+
const sets: Array<{ property: string; value: Doc; where: string; outcome: number }> = []
|
|
373
|
+
outcomes.forEach((o, i) => {
|
|
374
|
+
const changes = list(o.expectedChanges)
|
|
375
|
+
if (o.effect === 'confirmed_not_applied' && changes.length) refused.push(`businessOutcomes[${i}] is confirmed_not_applied, which declares no expected changes: move them to a confirmed_applied outcome`)
|
|
376
|
+
changes.forEach((c, j) => {
|
|
377
|
+
const where = `businessOutcomes[${i}].expectedChanges[${j}]`
|
|
378
|
+
const named = typeof c.objectType === 'string' || typeof c.objectTypeId === 'string'
|
|
379
|
+
if (!named && ['property_set', 'object_created', 'object_deleted', 'lifecycle_transition'].includes(String(c.kind))) {
|
|
380
|
+
const subjectType = typeof subject.objectType === 'string' ? subject.objectType : '<ObjectType>'
|
|
381
|
+
refused.push(`${where} (${String(c.kind)}) needs objectType: the object type it changes, by apiName (objectType: ${subjectType})`)
|
|
382
|
+
}
|
|
383
|
+
if (c.kind === 'property_set') {
|
|
384
|
+
if (typeof c.property !== 'string' && typeof c.propertyId !== 'string') refused.push(`${where} needs property: the property it sets, by apiName`)
|
|
385
|
+
const value = isRecord(c.value) ? c.value : null
|
|
386
|
+
if (!value) refused.push(`${where} needs value: { kind: input, field: <input apiName> }, { kind: output, field: … } or { kind: literal, value: … }`)
|
|
387
|
+
else {
|
|
388
|
+
// An optional input with no default may set a property: absent, it leaves the property unset (a create)
|
|
389
|
+
// or unchanged (an edit). Only criteria, approval conditions and postconditions need a value (above).
|
|
390
|
+
if (typeof c.property === 'string') sets.push({ property: c.property, value, where, outcome: i })
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
})
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
// The write path the local runtime derives (`deriveOntologyEditPlan`) and the deployment's
|
|
397
|
+
// checks (`action-binding-service.ts`, `mutation-plan-schema.ts`).
|
|
398
|
+
if (!EDITS.has(effectKind)) return
|
|
399
|
+
const typeName = typeof subject.objectType === 'string' ? subject.objectType : undefined
|
|
400
|
+
const subjectType = typeName ? types.get(typeName) : undefined
|
|
401
|
+
const pk = subjectType?.primaryKey
|
|
402
|
+
const byName = new Map(inputs.flatMap((f) => (typeof f.apiName === 'string' ? [[f.apiName, f] as const] : [])))
|
|
403
|
+
const kindOf = (f: Doc | undefined) => (f && isRecord(f.valueType) && typeof f.valueType.kind === 'string' ? f.valueType.kind : undefined)
|
|
404
|
+
|
|
405
|
+
// The key: an edit or delete finds its record by it; a create files the new record under it.
|
|
406
|
+
const key = pk ? byName.get(pk) : undefined
|
|
407
|
+
if (pk && !key) {
|
|
408
|
+
undeployable.push(
|
|
409
|
+
effectKind === 'create'
|
|
410
|
+
? `a create files the new record under its primary key, so it takes a required "${pk}" input (valueType: { kind: string, minLength: 1 }); add one`
|
|
411
|
+
: `${effectKind === 'delete' ? 'a delete' : 'an edit'} finds its record by the primary key, so it takes a required "${pk}" input (valueType: { kind: string, minLength: 1 }); add one`,
|
|
412
|
+
)
|
|
413
|
+
} else if (key && key.required !== true) undeployable.push(`inputs: "${pk}" is the record’s key, so it must be required: true`)
|
|
414
|
+
|
|
415
|
+
if (strategy === 'expected_version') {
|
|
416
|
+
// Deploy refuses only an Action with no "expectedVersion" input to map (the plan has nothing to
|
|
417
|
+
// compare and set on). Its value type is not a deploy rule: the version is checked as a whole
|
|
418
|
+
// number from 0 when the Action is submitted, and the platform itself writes `{ kind: number }`
|
|
419
|
+
// with `integer: false` and no minimum on Actions that deploy.
|
|
420
|
+
if (!byName.get('expectedVersion')) undeployable.push('concurrency expected_version needs an "expectedVersion" input: { apiName: expectedVersion, valueType: { kind: number, integer: true, minimum: 0 }, required: true } — the record version the change was decided on (a query row’s _meta.recordVersion)')
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const changes = outcomes.flatMap((o) => list(o.expectedChanges))
|
|
424
|
+
if (effectKind === 'create') {
|
|
425
|
+
if (strategy === 'create_if_absent') undeployable.push('a create over the Blueprint’s records takes its identity from its primary key input, so concurrency.strategy must be append_once (create_if_absent has the platform mint an identity the record is never filed under)')
|
|
426
|
+
if (!changes.some((c) => c.kind === 'object_created')) undeployable.push(`a create declares the record it makes: add { kind: object_created, objectType: ${typeName ?? '<ObjectType>'} } to its outcome`)
|
|
427
|
+
}
|
|
428
|
+
if (effectKind === 'delete') {
|
|
429
|
+
if (!changes.some((c) => c.kind === 'object_deleted')) undeployable.push(`a delete declares the record it removes: add { kind: object_deleted, objectType: ${typeName ?? '<ObjectType>'} } to its outcome`)
|
|
430
|
+
for (const st of sets) undeployable.push(`${st.where} sets ${st.property}, but a delete removes the whole record and sets nothing; remove this change`)
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const feeds = new Map<string, string[]>()
|
|
435
|
+
const byLiteral = new Set(sets.filter((x) => x.value.kind === 'literal').map((x) => x.property))
|
|
436
|
+
let fromInput = 0
|
|
437
|
+
for (const st of sets) {
|
|
438
|
+
if (subjectType?.editable && !subjectType.editable.has(st.property)) {
|
|
439
|
+
undeployable.push(`${st.where} sets ${st.property}, which ${typeName}’s backing.editable doesn’t list; add it there (blueprint/object-types/${typeName}.yaml)`)
|
|
440
|
+
} else if (subjectType && subjectType.editable === null) {
|
|
441
|
+
undeployable.push(`${st.where} sets ${st.property}, but ${typeName} declares no backing.editable; list the properties its Actions write there`)
|
|
442
|
+
}
|
|
443
|
+
if (st.value.kind === 'output') {
|
|
444
|
+
undeployable.push(`${st.where} sets ${st.property} from an output; a write sets each value from an input or a constant`)
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
if (pk && st.property === pk) {
|
|
448
|
+
if (st.value.kind === 'literal') undeployable.push(`${st.where} sets the primary key ${pk} to a constant; the key comes from its "${pk}" input, so remove this change`)
|
|
449
|
+
else if (effectKind !== 'create' || st.value.field === pk) {
|
|
450
|
+
undeployable.push(
|
|
451
|
+
effectKind === 'create'
|
|
452
|
+
? `${st.where} sets the primary key ${pk}; a create already files the record under its "${pk}" input, so remove this change`
|
|
453
|
+
: `${st.where} sets the primary key ${pk}, which addresses the record and cannot be changed; remove this change`,
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
continue
|
|
457
|
+
}
|
|
458
|
+
if (st.value.kind === 'literal') continue
|
|
459
|
+
if (st.value.kind !== 'input' || typeof st.value.field !== 'string') continue
|
|
460
|
+
fromInput++
|
|
461
|
+
if (byLiteral.has(st.property)) undeployable.push(`${st.where} sets ${st.property} from input ${st.value.field}, and another change sets it to a constant; keep one`)
|
|
462
|
+
if (st.value.field === 'expectedVersion' && strategy === 'expected_version') {
|
|
463
|
+
undeployable.push(`${st.where} sets ${st.property} from expectedVersion, which already carries the record version; give ${st.property} its own input`)
|
|
464
|
+
}
|
|
465
|
+
// The input's kind fits the property's base type.
|
|
466
|
+
const base = subjectType?.valueTypes?.get(st.property)
|
|
467
|
+
const kind = kindOf(byName.get(st.value.field))
|
|
468
|
+
if (base && kind && FITS[base] && !FITS[base]!.includes(kind)) {
|
|
469
|
+
undeployable.push(`${st.where} sets ${st.property} (${base}) from input ${st.value.field} (${kind}); a ${base} property takes ${FITS[base]!.join(' or ')}`)
|
|
470
|
+
} else if (kind === 'geopoint' && base && base !== 'geopoint') {
|
|
471
|
+
undeployable.push(`${st.where} sets ${st.property} (${base}) from a geopoint input; only a geopoint property takes one`)
|
|
472
|
+
}
|
|
473
|
+
feeds.set(st.value.field, [...(feeds.get(st.value.field) ?? []), st.property])
|
|
474
|
+
}
|
|
475
|
+
for (const [input, properties] of feeds) {
|
|
476
|
+
if (new Set(properties).size > 1) undeployable.push(`input ${input} sets ${[...new Set(properties)].join(' and ')}; each input feeds one property, so give each its own input`)
|
|
477
|
+
}
|
|
478
|
+
if (fromInput === 0) {
|
|
479
|
+
undeployable.push(byLiteral.size
|
|
480
|
+
? `it sets only constants (${[...byLiteral].join(', ')}); its write path needs at least one property set from an input ({ kind: input, field: <input apiName> })`
|
|
481
|
+
: 'it sets no property from an input, so there is nothing to write: add a property_set with value { kind: input, field: <input apiName> }')
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// What an outcome checks after the write: only properties that outcome sets, and no required output.
|
|
485
|
+
outcomes.forEach((o, i) => {
|
|
486
|
+
const setHere = new Set(sets.filter((x) => x.outcome === i).map((x) => x.property))
|
|
487
|
+
list(o.postconditions).forEach((pc, j) => {
|
|
488
|
+
for (const read of observedProperties(pc.assertion)) {
|
|
489
|
+
if (!setHere.has(read)) undeployable.push(`businessOutcomes[${i}].postconditions[${j}] reads ${read}, which this outcome doesn’t set; a postcondition reads only what its outcome sets`)
|
|
490
|
+
}
|
|
491
|
+
})
|
|
492
|
+
list(o.output).forEach((f, j) => {
|
|
493
|
+
if (f.required === true) undeployable.push(`businessOutcomes[${i}].output[${j}] (${String(f.apiName)}) is required, and a write over the Blueprint’s records returns no outputs; make it required: false or remove it`)
|
|
494
|
+
})
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** The inputs an expression reads (`{ kind: input, field }`). */
|
|
499
|
+
function inputReads(node: unknown, out: string[] = []): string[] {
|
|
500
|
+
if (Array.isArray(node)) node.forEach((n) => inputReads(n, out))
|
|
501
|
+
else if (isRecord(node)) {
|
|
502
|
+
if (node.kind === 'input' && typeof node.field === 'string') out.push(node.field)
|
|
503
|
+
for (const v of Object.values(node)) if (v && typeof v === 'object') inputReads(v, out)
|
|
504
|
+
}
|
|
505
|
+
return out
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** The record properties an expression reads (`observation_property`). */
|
|
509
|
+
function observedProperties(node: unknown, out: string[] = []): string[] {
|
|
510
|
+
if (Array.isArray(node)) node.forEach((n) => observedProperties(n, out))
|
|
511
|
+
else if (isRecord(node)) {
|
|
512
|
+
if (node.kind === 'observation_property' && typeof node.property === 'string') out.push(node.property)
|
|
513
|
+
for (const v of Object.values(node)) if (v && typeof v === 'object') observedProperties(v, out)
|
|
514
|
+
}
|
|
515
|
+
return out
|
|
516
|
+
}
|