@frontera-sdk/cli 1.43.6 → 1.43.8

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.
@@ -65,45 +65,65 @@ export interface DefinitionBundle {
65
65
  * carries no key at all, and the schemas are strict.
66
66
  */
67
67
  sharedProperties?: BundleNamed[]
68
+ /** Optional for the same reason `sharedProperties` is. */
69
+ semanticTypes?: BundleNamed[]
68
70
  /** v2 only. Absent on a v1 bundle rather than empty — the schemas are strict. */
69
71
  actions?: BundleNamed[]
70
72
  }
71
73
 
72
74
  /**
73
75
  * The authorable kinds, in the order a reader meets them — which is also the order
74
- * `apply` writes them in, so a kind that others reference comes first. A property
75
- * names its shared field, so shared fields are created before object types.
76
+ * `apply` writes them in, so a kind that others reference comes first. A property names
77
+ * its shared field, so shared fields lead; a link names two object types and a metric
78
+ * names one; an action names object types, their properties and their transitions, so
79
+ * it comes last.
76
80
  *
77
- * Two things are deliberately absent, for the same reason: a file the CLI can write
78
- * but never apply is worse than no file, because it breaks the round trip the tree
79
- * promises and fails only at the end of a deployment.
81
+ * A SEMANTIC TYPE IS FIRST, and it has to be: both other carrier kinds reference one
82
+ * a shared field by `semanticType:` and a property by the same key so a tree that
83
+ * introduces a semantic type and something carrying it has to create the type before
84
+ * either. Same rule that puts shared fields ahead of object types, one level deeper.
85
+ *
86
+ * A kind is authorable only when the tree can EXPRESS it: a file the CLI can write but
87
+ * never apply is worse than no file, because it breaks the round trip the tree promises
88
+ * and fails only at the end of a deployment. Two things a file may still NAME are not
89
+ * themselves authorable here, and both are named rather than hidden because a tree
90
+ * carrying either is portable only to a deployment that already has it.
80
91
  *
81
92
  * **Source systems** have no route of their own — `createDraftObjectType` synthesises
82
- * one for an organization's first object type and nothing else ever creates one.
93
+ * one for an organization's first object type and nothing else ever creates one. An
94
+ * action's `effect.authoritySourceSystem` therefore names something this tree cannot
95
+ * itself create, and on a second deployment that name may resolve to nothing.
96
+ *
97
+ * **Lifecycles** are the same shape of gap one level down. `objectTypeCreateBody`
98
+ * refuses a file that declares one — the create route cannot express it — so an
99
+ * action's `lifecycle_transition` change names a transition the tree cannot author.
100
+ * Against the draft it was pulled from that resolves; against a fresh organization it
101
+ * does not, and the refusal lands mid-apply.
83
102
  *
84
- * **Actions** carry several uuid references the projection cannot turn into names:
85
- * `subject.objectTypeId`, `effect.authoritySourceSystemId` and `compensationActionId`
86
- * at least. Emitting them as raw uuids makes the tree unportable the identifiers are
87
- * per-deployment and the create route takes the contract whole, so there is nowhere
88
- * to resolve them back. `blueprint create|update|delete action` still authors them
89
- * imperatively; only the FILE path is withheld, and withheld visibly rather than
90
- * shipped broken.
103
+ * **Actions** joined the list once the service learned to resolve their references by
104
+ * name and to mint the ids they own — see `action-reference-resolution` and
105
+ * `action-identity`. Before that they carried uuids that were correct in the
106
+ * deployment that minted them and wrong in every other.
91
107
  */
92
108
  export const ARTIFACT_KINDS = [
109
+ 'semantic-type',
93
110
  'shared-field',
94
111
  'object-type',
95
112
  'link-type',
96
113
  'metric',
114
+ 'action',
97
115
  ] as const
98
116
 
99
117
  export type ArtifactKind = (typeof ARTIFACT_KINDS)[number]
100
118
 
101
119
  /** `object-type` → `object-types/`. Plural directory, singular command word. */
102
120
  export const KIND_DIRECTORY: Record<ArtifactKind, string> = {
121
+ 'semantic-type': 'semantic-types',
103
122
  'shared-field': 'shared-fields',
104
123
  'object-type': 'object-types',
105
124
  'link-type': 'link-types',
106
125
  metric: 'metrics',
126
+ action: 'actions',
107
127
  }
108
128
 
109
129
  export const DIRECTORY_KIND: Record<string, ArtifactKind> = Object.fromEntries(
@@ -120,10 +140,14 @@ export const DIRECTORY_KIND: Record<string, ArtifactKind> = Object.fromEntries(
120
140
  * existed. A name that cannot name a file is refused before anything is written.
121
141
  */
122
142
  export const API_NAME_RULE: Record<ArtifactKind, RegExp> = {
143
+ 'semantic-type': /^[a-z][A-Za-z0-9]{0,99}$/,
123
144
  'shared-field': /^[a-z][A-Za-z0-9]{0,99}$/,
124
145
  'object-type': /^[A-Z][A-Za-z0-9]{0,99}$/,
125
146
  'link-type': /^[a-z][A-Za-z0-9]{0,99}$/,
126
147
  metric: /^[a-z][A-Za-z0-9]{0,99}$/,
148
+ // `apiNameSchema` in `action-definition-schema.ts`, which governs the action and
149
+ // every member of it — the same camelCase rule, and the same 100-character bound.
150
+ action: /^[a-z][A-Za-z0-9]{0,99}$/,
127
151
  }
128
152
 
129
153
  export function describeApiNameRule(kind: ArtifactKind): string {
@@ -164,6 +188,13 @@ export interface LiveIndex {
164
188
  * reference a file writes that is not addressed by name on both sides.
165
189
  */
166
190
  sharedPropertyIdByApiName: Map<string, string>
191
+ /**
192
+ * The SECOND such reference, and it needs its own map rather than sharing the one
193
+ * above: `semanticType: percentage` is resolved on BOTH carrier kinds — a property
194
+ * entry and a shared field — and the two namespaces are independent, so a shared
195
+ * field and a semantic type may legitimately share an apiName.
196
+ */
197
+ semanticTypeIdByApiName: Map<string, string>
167
198
  /** The live object, by apiName — the merge reads `governance.sourceMappings` off it. */
168
199
  objectByApiName: Map<string, BundleObject>
169
200
  relationshipByApiName: Map<string, BundleRelationship>
@@ -178,6 +209,7 @@ export function emptyIndex(): LiveIndex {
178
209
  metricIdByApiName: new Map(),
179
210
  actionIdByApiName: new Map(),
180
211
  sharedPropertyIdByApiName: new Map(),
212
+ semanticTypeIdByApiName: new Map(),
181
213
  objectByApiName: new Map(),
182
214
  relationshipByApiName: new Map(),
183
215
  }
@@ -202,6 +234,9 @@ export function indexBundle(bundle: DefinitionBundle): LiveIndex {
202
234
  for (const shared of bundle.sharedProperties ?? []) {
203
235
  index.sharedPropertyIdByApiName.set(shared.apiName, shared.id)
204
236
  }
237
+ for (const semanticType of bundle.semanticTypes ?? []) {
238
+ index.semanticTypeIdByApiName.set(semanticType.apiName, semanticType.id)
239
+ }
205
240
  return index
206
241
  }
207
242
 
@@ -52,31 +52,61 @@ function rest(source: Record<string, unknown>, resolved: Set<string>): Record<st
52
52
  }
53
53
 
54
54
  /**
55
- * A property entry, with its shared field named rather than identified.
55
+ * A property entry, with its two references named rather than identified.
56
56
  *
57
- * The wire's `sharedPropertyId` is the one uuid a property carries that HAS a name
58
- * to resolve to, so it is resolved rather than stripped — a file that kept it would
59
- * be unportable in the precise sense this whole tree exists to avoid. The authored
60
- * key is `sharedField`: the tree is what a person writes, and a person writes field.
57
+ * `sharedPropertyId` and `semanticTypeId` are the uuids a property carries that HAVE
58
+ * names to resolve to, so both are resolved rather than stripped — a file that kept
59
+ * either would be unportable in the precise sense this whole tree exists to avoid.
60
+ * The authored keys are `sharedField` and `semanticType`: the tree is what a person
61
+ * writes, and a person writes field and type, not property and id.
61
62
  *
62
- * An id the bundle does not declare is a corrupt export, and it fails here rather
63
- * than writing the uuid: a tree that round-trips a per-deployment identifier reads
64
- * as portable and is not, and the failure would then appear at the second
65
- * deployment instead of at the `pull` that produced it.
63
+ * Both are emitted, and both may be present on the wire even though the bundle
64
+ * forbids an implementing property from carrying its own the projection reports
65
+ * what the draft says rather than re-deciding it.
66
66
  */
67
67
  function propertyToFile(
68
68
  property: Record<string, unknown>,
69
69
  objectApiName: string,
70
70
  sharedApiNameById: Map<string, string>,
71
+ semanticApiNameById: Map<string, string>,
71
72
  ): Record<string, unknown> {
72
- const body = rest(property, new Set(['id', 'sharedPropertyId']))
73
- const sharedPropertyId = property.sharedPropertyId
74
- if (sharedPropertyId === undefined || sharedPropertyId === null) return body
75
- const apiName = sharedApiNameById.get(String(sharedPropertyId))
73
+ const body = rest(property, new Set(['id', 'sharedPropertyId', 'semanticTypeId']))
74
+ const semanticType = resolveName(
75
+ property.semanticTypeId,
76
+ semanticApiNameById,
77
+ 'semantic type',
78
+ `Field "${String(property.apiName)}" on ${objectApiName}`,
79
+ )
80
+ const withSemanticType = semanticType === undefined ? body : { ...body, semanticType }
81
+ const sharedField = resolveName(
82
+ property.sharedPropertyId,
83
+ sharedApiNameById,
84
+ 'shared field',
85
+ `Field "${String(property.apiName)}" on ${objectApiName}`,
86
+ )
87
+ return sharedField === undefined ? withSemanticType : { ...withSemanticType, sharedField }
88
+ }
89
+
90
+ /**
91
+ * A uuid reference, as the name a file may carry — or nothing, when the bundle
92
+ * carries no reference at all.
93
+ *
94
+ * Shared by both reference kinds and both carriers, because the failure is the same
95
+ * one every time: an id the bundle does not declare is a corrupt export, and writing
96
+ * it into the file would produce a tree that round-trips here and names nothing in
97
+ * the next deployment. It fails at the `pull` that produced it instead.
98
+ */
99
+ function resolveName(
100
+ id: unknown,
101
+ apiNameById: Map<string, string>,
102
+ label: string,
103
+ subject: string,
104
+ ): string | undefined {
105
+ if (id === undefined || id === null) return undefined
106
+ const apiName = apiNameById.get(String(id))
76
107
  if (!apiName) {
77
108
  throw new CliError(
78
- `Field "${String(property.apiName)}" on ${objectApiName} references shared field `
79
- + `${String(sharedPropertyId)}, which this draft does not declare.`,
109
+ `${subject} references ${label} ${String(id)}, which this draft does not declare.`,
80
110
  {
81
111
  code: 'FAILURE',
82
112
  hint: 'The draft is inconsistent — re-read it, and report it if it persists. '
@@ -84,7 +114,7 @@ function propertyToFile(
84
114
  },
85
115
  )
86
116
  }
87
- return { ...body, sharedField: apiName }
117
+ return apiName
88
118
  }
89
119
 
90
120
  /**
@@ -130,6 +160,7 @@ function backingToFile(
130
160
  function objectToFile(
131
161
  object: BundleObject,
132
162
  sharedApiNameById: Map<string, string>,
163
+ semanticApiNameById: Map<string, string>,
133
164
  datasetNameByRevisionId?: Map<string, string>,
134
165
  ): Record<string, unknown> {
135
166
  const byId = new Map(object.properties.map((property) => [property.id, property.apiName]))
@@ -143,7 +174,7 @@ function objectToFile(
143
174
  primaryKey: byId.get(object.primaryKeyPropertyId) ?? object.primaryKeyPropertyId,
144
175
  title: byId.get(object.titlePropertyId) ?? object.titlePropertyId,
145
176
  properties: object.properties.map((property) =>
146
- propertyToFile(property, object.apiName, sharedApiNameById)),
177
+ propertyToFile(property, object.apiName, sharedApiNameById, semanticApiNameById)),
147
178
  }
148
179
  }
149
180
 
@@ -166,6 +197,133 @@ function linkToFile(link: BundleRelationship, objectApiNameById: Map<string, str
166
197
  }
167
198
  }
168
199
 
200
+ /**
201
+ * Every name an action can reference, indexed by the id the bundle stores.
202
+ *
203
+ * Built once per projection because an action reaches into three levels of another
204
+ * artifact — a type, its properties, its lifecycle transitions — and rebuilding the
205
+ * maps per action would be quadratic in the size of the draft.
206
+ */
207
+ interface ActionNames {
208
+ object: Map<string, string>
209
+ /** Property and transition ids are globally unique, so one flat map each is enough. */
210
+ property: Map<string, string>
211
+ transition: Map<string, string>
212
+ sourceSystem: Map<string, string>
213
+ action: Map<string, string>
214
+ }
215
+
216
+ function isRecord(value: unknown): value is Record<string, unknown> {
217
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
218
+ }
219
+
220
+ /**
221
+ * Replace an id field with the name it stands for.
222
+ *
223
+ * A miss LEAVES THE ID IN PLACE rather than dropping the field. A dropped reference
224
+ * would make the file parse and then mean something else — the projection's one rule
225
+ * is that what it cannot translate it carries through untouched, so the loss is
226
+ * visible in a diff instead of silent.
227
+ */
228
+ function named(
229
+ target: Record<string, unknown>,
230
+ idField: string,
231
+ nameField: string,
232
+ names: Map<string, string>,
233
+ ): void {
234
+ const id = target[idField]
235
+ if (typeof id !== 'string') return
236
+ const name = names.get(id)
237
+ if (!name) return
238
+ target[nameField] = name
239
+ delete target[idField]
240
+ }
241
+
242
+ /** `{ kind: 'input', fieldId }` → `{ kind: 'input', field }`, anywhere below `node`. */
243
+ function nameFieldRefs(node: unknown, fields: Map<string, string>, properties: Map<string, string>): void {
244
+ if (Array.isArray(node)) {
245
+ for (const entry of node) nameFieldRefs(entry, fields, properties)
246
+ return
247
+ }
248
+ if (!isRecord(node)) return
249
+ if ((node.kind === 'input' || node.kind === 'output') && typeof node.fieldId === 'string') {
250
+ named(node, 'fieldId', 'field', fields)
251
+ return
252
+ }
253
+ if (node.kind === 'observation_property' && typeof node.propertyId === 'string') {
254
+ named(node, 'propertyId', 'property', properties)
255
+ return
256
+ }
257
+ for (const value of Object.values(node)) nameFieldRefs(value, fields, properties)
258
+ }
259
+
260
+ /**
261
+ * An action as the bundle carries it → the portable file.
262
+ *
263
+ * The exact inverse of the service's `resolveActionReferences` and
264
+ * `assignActionIdentity`, and it has to stay that way: `pull` writes what `apply` must
265
+ * be able to send back. Ids the contract OWNS are stripped, because the service mints
266
+ * them; ids it REFERENCES become names, because names mean the same thing in every
267
+ * deployment and ids do not.
268
+ */
269
+ function actionToFile(action: Record<string, unknown>, names: ActionNames): Record<string, unknown> {
270
+ const file = structuredClone(action)
271
+ delete file.id
272
+
273
+ if (isRecord(file.subject)) named(file.subject, 'objectTypeId', 'objectType', names.object)
274
+ if (isRecord(file.effect)) {
275
+ named(file.effect, 'authoritySourceSystemId', 'authoritySourceSystem', names.sourceSystem)
276
+ named(file.effect, 'compensationActionId', 'compensationAction', names.action)
277
+ }
278
+
279
+ // The action's own inputs, named before anything can reference them.
280
+ const inputNames = new Map<string, string>()
281
+ for (const input of Array.isArray(file.inputs) ? file.inputs : []) {
282
+ if (!isRecord(input)) continue
283
+ if (typeof input.id === 'string' && typeof input.apiName === 'string') {
284
+ inputNames.set(input.id, input.apiName)
285
+ }
286
+ delete input.id
287
+ if (isRecord(input.valueType)) named(input.valueType, 'objectTypeId', 'objectType', names.object)
288
+ }
289
+
290
+ for (const criterion of Array.isArray(file.submissionCriteria) ? file.submissionCriteria : []) {
291
+ if (!isRecord(criterion)) continue
292
+ delete criterion.id
293
+ // Criteria see inputs only — an output does not exist until the action has run.
294
+ nameFieldRefs(criterion.assertion, inputNames, names.property)
295
+ }
296
+
297
+ for (const outcome of Array.isArray(file.businessOutcomes) ? file.businessOutcomes : []) {
298
+ if (!isRecord(outcome)) continue
299
+ // Output fields are scoped to their outcome, exactly as the bundle validates them.
300
+ const outputNames = new Map<string, string>()
301
+ for (const field of Array.isArray(outcome.output) ? outcome.output : []) {
302
+ if (!isRecord(field)) continue
303
+ if (typeof field.id === 'string' && typeof field.apiName === 'string') {
304
+ outputNames.set(field.id, field.apiName)
305
+ }
306
+ delete field.id
307
+ if (isRecord(field.valueType)) named(field.valueType, 'objectTypeId', 'objectType', names.object)
308
+ }
309
+ const scope = new Map([...inputNames, ...outputNames])
310
+ for (const change of Array.isArray(outcome.expectedChanges) ? outcome.expectedChanges : []) {
311
+ if (!isRecord(change)) continue
312
+ named(change, 'objectTypeId', 'objectType', names.object)
313
+ named(change, 'propertyId', 'property', names.property)
314
+ named(change, 'transitionId', 'transition', names.transition)
315
+ nameFieldRefs(change.value, scope, names.property)
316
+ }
317
+ for (const postcondition of Array.isArray(outcome.postconditions) ? outcome.postconditions : []) {
318
+ if (!isRecord(postcondition)) continue
319
+ delete postcondition.id
320
+ nameFieldRefs(postcondition.assertion, scope, names.property)
321
+ }
322
+ }
323
+
324
+ return file
325
+ }
326
+
169
327
  /**
170
328
  * Live draft → the file tree a person edits. Lossless but for the resolved ids.
171
329
  *
@@ -184,18 +342,36 @@ export function toFiles(
184
342
  const sharedApiNameById = new Map(
185
343
  (bundle.sharedProperties ?? []).map((shared) => [shared.id, shared.apiName]),
186
344
  )
345
+ const semanticApiNameById = new Map(
346
+ (bundle.semanticTypes ?? []).map((semanticType) => [semanticType.id, semanticType.apiName]),
347
+ )
187
348
  const files: AuthoredFile[] = []
188
349
  const push = (kind: AuthoredFile['kind'], apiName: string, document: Record<string, unknown>) => {
189
350
  files.push({ kind, apiName, path: `${KIND_DIRECTORY[kind]}/${apiName}.yaml`, document })
190
351
  }
191
352
 
353
+ for (const semanticType of bundle.semanticTypes ?? []) {
354
+ // Metadata plus one rule, and no reference of its own: stripping the id is the
355
+ // whole projection. First, because both carriers name one.
356
+ push('semantic-type', semanticType.apiName, rest(semanticType, new Set(['id'])))
357
+ }
192
358
  for (const shared of bundle.sharedProperties ?? []) {
193
- // A shared field owns no backing and references nothing, so stripping its id
194
- // is the whole projection: what is left is portable as it stands.
195
- push('shared-field', shared.apiName, rest(shared, new Set(['id'])))
359
+ // A shared field owns no backing, and the one reference it does carry is a
360
+ // semantic type resolved to its apiName here rather than stripped. Stripping
361
+ // was the likelier leak: this copy took out `id` alone, so the reference would
362
+ // have travelled into the file as a per-deployment uuid.
363
+ const semanticType = resolveName(
364
+ shared.semanticTypeId, semanticApiNameById, 'semantic type', `Shared field "${shared.apiName}"`,
365
+ )
366
+ push('shared-field', shared.apiName, {
367
+ ...rest(shared, new Set(['id', 'semanticTypeId'])),
368
+ ...(semanticType === undefined ? {} : { semanticType }),
369
+ })
196
370
  }
197
371
  for (const object of bundle.objects) {
198
- push('object-type', object.apiName, objectToFile(object, sharedApiNameById, datasetNameByRevisionId))
372
+ push('object-type', object.apiName, objectToFile(
373
+ object, sharedApiNameById, semanticApiNameById, datasetNameByRevisionId,
374
+ ))
199
375
  }
200
376
  for (const link of bundle.relationships) {
201
377
  push('link-type', link.apiName, linkToFile(link, objectApiNameById, propertyApiNameById))
@@ -213,6 +389,26 @@ export function toFiles(
213
389
  })
214
390
  }
215
391
 
392
+ // v1 has no `actions` key at all — see `carriesActions`. An empty array here would
393
+ // write an `actions/` directory a v1 draft can never accept back.
394
+ if (bundle.actions) {
395
+ const names: ActionNames = {
396
+ object: objectApiNameById,
397
+ property: propertyApiNameById,
398
+ transition: new Map(
399
+ bundle.objects.flatMap((object) => {
400
+ const lifecycle = (object as { lifecycle?: { transitions?: Array<{ id: string; apiName: string }> } }).lifecycle
401
+ return (lifecycle?.transitions ?? []).map((entry) => [entry.id, entry.apiName] as const)
402
+ }),
403
+ ),
404
+ sourceSystem: new Map(bundle.sourceSystems.map((entry) => [entry.id, entry.apiName])),
405
+ action: new Map(bundle.actions.map((entry) => [entry.id, entry.apiName])),
406
+ }
407
+ for (const action of bundle.actions) {
408
+ push('action', action.apiName, actionToFile(action as Record<string, unknown>, names))
409
+ }
410
+ }
411
+
216
412
  return { schemaVersion: bundle.schemaVersion, files: files.sort(byPath) }
217
413
  }
218
414
 
@@ -40,10 +40,13 @@ export function renderPlan(plan: Plan, options: { prune: boolean } = { prune: fa
40
40
  })
41
41
  }
42
42
 
43
+ const withheld = withheldNote(plan)
44
+
43
45
  if (rows.length === 0) {
44
- return plan.unchanged === 0
46
+ const nothing = plan.unchanged === 0
45
47
  ? 'No artifacts, and nothing to do.'
46
48
  : `No changes. ${plan.unchanged} artifact${plan.unchanged === 1 ? ' matches' : 's match'} the draft.`
49
+ return withheld ? `${nothing}\n${withheld}` : nothing
47
50
  }
48
51
 
49
52
  const kindWidth = Math.max(...rows.map((row) => row.kind.length))
@@ -55,9 +58,29 @@ export function renderPlan(plan: Plan, options: { prune: boolean } = { prune: fa
55
58
  if (plan.unchanged > 0) {
56
59
  lines.push(` = ${plan.unchanged} unchanged`)
57
60
  }
61
+ if (withheld) lines.push(withheld)
58
62
  return lines.join('\n')
59
63
  }
60
64
 
65
+ /**
66
+ * The prunes a missing directory withheld, and how to mean them.
67
+ *
68
+ * Printed on every plan, with or without `--prune`: the withholding is not about the
69
+ * flag, and a reader who deleted `metrics/` outright needs to know the artifacts are
70
+ * still live either way.
71
+ */
72
+ function withheldNote(plan: Plan): string {
73
+ const withheld = plan.withheld ?? []
74
+ if (withheld.length === 0) return ''
75
+ const kinds = [...new Set(withheld.map((operation) => operation.kind))]
76
+ return withheld
77
+ .map((operation) => ` · ${operation.kind} ${operation.apiName} withheld — this tree has no ${operation.kind}s/ directory`)
78
+ .join('\n')
79
+ + `\n A missing directory means "not modelled here", not "delete these". To mean `
80
+ + `the delete, keep ${kinds.map((kind) => `${kind}s/`).join(' and ')} in the tree — `
81
+ + `git needs a .gitkeep to carry an empty one — then re-run with --prune.`
82
+ }
83
+
61
84
  /** The `!` lines: a bound type whose dataset has moved to a different contract. */
62
85
  export function renderDrift(
63
86
  drift: Array<{ objectApiName: string; datasetName: string; currentDigest: string }>,
@@ -1,4 +1,5 @@
1
- import type { ArtifactKind } from './model'
1
+ import { CliError } from '../errors'
2
+ import { ARTIFACT_KINDS, type ArtifactKind } from './model'
2
3
 
3
4
  /**
4
5
  * A starting file per kind, complete enough to apply as written once the placeholders
@@ -9,7 +10,82 @@ import type { ArtifactKind } from './model'
9
10
  * thing they write omits three more. Every field the create route requires is here,
10
11
  * including the ones with dull answers.
11
12
  */
12
- export function scaffold(kind: ArtifactKind, apiName: string): Record<string, unknown> {
13
+
14
+ /**
15
+ * The semantic-type starting points, each expressible with the facets this slice
16
+ * ships — no `pattern`, so no uuid or email template pretends to exist.
17
+ *
18
+ * A fixed library rather than a prompt: the CLI does not prompt (see `prompt.ts`),
19
+ * because an agent's stdin is never a TTY and a command that asked would hang. So the
20
+ * choice is a flag with a deterministic default, and every template writes a rule the
21
+ * service accepts unedited.
22
+ */
23
+ export const SEMANTIC_TYPE_TEMPLATES = {
24
+ /** A quantity that cannot go below zero — the commonest numeric constraint there is. */
25
+ 'non-negative': {
26
+ dataType: 'number',
27
+ // Bounds are canonical decimal STRINGS on the wire, not numbers: one spelling per
28
+ // value, so a rule can be compared as text and `1` and `1.0` cannot both exist.
29
+ rule: { minimum: '0' },
30
+ },
31
+ percentage: {
32
+ dataType: 'number',
33
+ rule: { minimum: '0', maximum: '100' },
34
+ },
35
+ 'status-enum': {
36
+ dataType: 'string',
37
+ rule: { enum: ['active', 'inactive'] },
38
+ },
39
+ 'code-length': {
40
+ dataType: 'string',
41
+ rule: { minLength: 1, maxLength: 64 },
42
+ },
43
+ } as const satisfies Record<string, { dataType: string; rule: Record<string, unknown> }>
44
+
45
+ export type SemanticTypeTemplate = keyof typeof SEMANTIC_TYPE_TEMPLATES
46
+
47
+ export const SEMANTIC_TYPE_TEMPLATE_NAMES = Object.keys(
48
+ SEMANTIC_TYPE_TEMPLATES,
49
+ ) as SemanticTypeTemplate[]
50
+
51
+ /**
52
+ * What `new semantic-type` writes without `--template`.
53
+ *
54
+ * Deliberately NOT inferred from the apiName: a `percentage` that scaffolded a number
55
+ * and a `percentComplete` that scaffolded a string would be the same command behaving
56
+ * two ways, and the reader could not tell which they were about to get. One default,
57
+ * always, and the flag is how the other three are asked for.
58
+ */
59
+ export const DEFAULT_SEMANTIC_TYPE_TEMPLATE: SemanticTypeTemplate = 'code-length'
60
+
61
+ export function isSemanticTypeTemplate(value: string): value is SemanticTypeTemplate {
62
+ return Object.prototype.hasOwnProperty.call(SEMANTIC_TYPE_TEMPLATES, value)
63
+ }
64
+
65
+ export interface ScaffoldOptions {
66
+ template?: SemanticTypeTemplate
67
+ }
68
+
69
+ export function scaffold(
70
+ kind: ArtifactKind,
71
+ apiName: string,
72
+ options: ScaffoldOptions = {},
73
+ ): Record<string, unknown> {
74
+ if (kind === 'semantic-type') {
75
+ const template = SEMANTIC_TYPE_TEMPLATES[options.template ?? DEFAULT_SEMANTIC_TYPE_TEMPLATE]
76
+ return {
77
+ apiName,
78
+ displayName: apiName,
79
+ description: 'CHANGE_ME',
80
+ // The family the rule is read under. The rule objects are kind-free — a number
81
+ // rule's `{ minimum, maximum }` is the same two keys a date rule uses — so this
82
+ // is what says which of the four schemas the rule is judged against, and moving
83
+ // one without the other is refused.
84
+ dataType: template.dataType,
85
+ rule: { ...template.rule },
86
+ }
87
+ }
88
+
13
89
  if (kind === 'object-type') {
14
90
  return {
15
91
  apiName,
@@ -64,16 +140,76 @@ export function scaffold(kind: ArtifactKind, apiName: string): Record<string, un
64
140
  }
65
141
  }
66
142
 
67
- // A metric names the type it measures and the measures it computes. Both are
68
- // required by the create route, so a scaffold without them would contradict this
69
- // command's own promise that what it writes validates as written.
70
- return {
71
- apiName,
72
- displayName: apiName,
73
- objectType: 'CHANGE_ME',
74
- definition: {
75
- measures: [{ agg: 'count', label: 'Count' }],
76
- dimensions: [],
77
- },
143
+ if (kind === 'action') {
144
+ // Every reference is a NAME. The service resolves them against the draft and mints
145
+ // the ids the contract owns, so nothing here carries a uuid — which is exactly
146
+ // what makes the file applicable to a second deployment.
147
+ return {
148
+ apiName,
149
+ displayName: apiName,
150
+ description: 'CHANGE_ME',
151
+ subject: { objectType: 'CHANGE_ME', mode: 'existing' },
152
+ effect: {
153
+ kind: 'update',
154
+ // Named, but note it cannot be authored from this tree: source systems have no
155
+ // route, and one is synthesised per organization. On a second deployment this
156
+ // name may resolve to nothing.
157
+ authoritySourceSystem: 'CHANGE_ME',
158
+ description: 'CHANGE_ME',
159
+ },
160
+ inputs: [],
161
+ // `min(1)` on the create route: an action that declares no outcome cannot say
162
+ // what it did, so a scaffold without one would not apply as written.
163
+ businessOutcomes: [{
164
+ code: 'applied',
165
+ displayName: 'Applied',
166
+ description: 'CHANGE_ME',
167
+ effect: 'confirmed_applied',
168
+ expectedChanges: [],
169
+ postconditions: [],
170
+ output: [],
171
+ }],
172
+ concurrency: { strategy: 'expected_version' },
173
+ impact: { maxSubjects: 1 },
174
+ submissionCriteria: [],
175
+ governance: {
176
+ // Shaped, not `CHANGE_ME`: this one is validated by
177
+ // `/^[a-z][a-z0-9_-]{0,63}:[a-z][a-z0-9_-]{0,63}$/`, so a bare placeholder is
178
+ // the one field in this file that cannot apply as written — and it fails
179
+ // server-side, after the plan was confirmed.
180
+ invokeCapability: 'changeme:invoke',
181
+ approval: { mode: 'none' },
182
+ reason: 'optional',
183
+ sensitivity: 'internal',
184
+ },
185
+ }
186
+ }
187
+
188
+ if (kind === 'metric') {
189
+ // A metric names the type it measures and the measures it computes. Both are
190
+ // required by the create route, so a scaffold without them would contradict this
191
+ // command's own promise that what it writes validates as written.
192
+ return {
193
+ apiName,
194
+ displayName: apiName,
195
+ objectType: 'CHANGE_ME',
196
+ definition: {
197
+ measures: [{ agg: 'count', label: 'Count' }],
198
+ dimensions: [],
199
+ },
200
+ }
78
201
  }
202
+
203
+ // NOT a fall-through to the metric arm, which is what stood here. A kind without a
204
+ // template used to write metric YAML under that kind's directory: the file
205
+ // validated as nothing, `readTree` accepted it, and the reader found out at `apply`
206
+ // — three commands later, in someone else's organization.
207
+ return unhandledKind(kind)
208
+ }
209
+
210
+ function unhandledKind(kind: never): never {
211
+ throw new CliError(`"${String(kind)}" has no scaffold.`, {
212
+ code: 'USAGE',
213
+ hint: `Expected one of: ${ARTIFACT_KINDS.join(', ')}.`,
214
+ })
79
215
  }