yarramate 0.18.0 → 0.19.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.
Files changed (65) hide show
  1. package/README.md +2 -1
  2. package/catalogues/core-enrichment.yaml +52 -1
  3. package/dist/adapters/graphify-cli.js +0 -0
  4. package/dist/adapters/likec4-cli.js +0 -0
  5. package/dist/adapters/mcp-cli.js +0 -0
  6. package/dist/adapters/visual/client.js +0 -1
  7. package/dist/adapters/visual/protocol-contract.d.ts +118 -35
  8. package/dist/adapters/visual/protocol-contract.js +1 -11
  9. package/dist/adapters/visual/protocol.d.ts +8 -1
  10. package/dist/adapters/visual/protocol.js +86 -46
  11. package/dist/adapters/visual/request.d.ts +37 -0
  12. package/dist/adapters/visual/request.js +131 -0
  13. package/dist/adapters/visual/session-server.d.ts +12 -14
  14. package/dist/adapters/visual/session-server.js +641 -283
  15. package/dist/adapters/visual/session-store.d.ts +28 -28
  16. package/dist/adapters/visual/session-store.js +32 -93
  17. package/dist/adapters/visual/wire.d.ts +36 -10
  18. package/dist/adapters/visual-cli.d.ts +3 -0
  19. package/dist/adapters/visual-cli.js +66 -1
  20. package/dist/apply-command.d.ts +10 -0
  21. package/dist/apply-command.js +399 -247
  22. package/dist/cli.js +0 -0
  23. package/dist/compiler.d.ts +13 -0
  24. package/dist/compiler.js +36 -2
  25. package/dist/graph-projection.d.ts +62 -0
  26. package/dist/graph-projection.js +222 -0
  27. package/dist/interrogate-command.d.ts +2 -0
  28. package/dist/interrogate-command.js +22 -0
  29. package/dist/kind-label.d.ts +8 -0
  30. package/dist/kind-label.js +8 -0
  31. package/dist/operations.d.ts +139 -0
  32. package/dist/operations.js +11 -0
  33. package/dist/projection.d.ts +2 -0
  34. package/dist/projection.js +8 -2
  35. package/dist/reconciliation.js +8 -7
  36. package/dist/source-document.d.ts +10 -0
  37. package/dist/source-document.js +21 -1
  38. package/dist/visual-app/assets/index-6oSR9RGM.js +354 -0
  39. package/dist/visual-app/assets/index-dwA9AOr5.css +1 -0
  40. package/dist/visual-app/index.html +2 -2
  41. package/docs/CONSUMING-YARRAMATE.md +25 -19
  42. package/package.json +6 -1
  43. package/schema/yarramate-adapter-mapping.schema.json +1 -1
  44. package/schema/yarramate-apply-result.schema.json +8 -2
  45. package/schema/yarramate-document.schema.json +1 -1
  46. package/schema/yarramate-operations.schema.json +203 -1
  47. package/schema/yarramate-projection.schema.json +12 -3
  48. package/schema/yarramate-question-catalogue.schema.json +15 -2
  49. package/schema/yarramate-visual-event.schema.json +143 -0
  50. package/schema/yarramate-visual-graph.schema.json +272 -0
  51. package/schema/yarramate-visual-layout.schema.json +41 -0
  52. package/schema/yarramate-visual-model.schema.json +6 -44
  53. package/schema/yarramate-visual-response.schema.json +170 -19
  54. package/schema/yarramate-visual-session-descriptor.schema.json +1 -1
  55. package/schema/yarramate-visual-session-request.schema.json +0 -70
  56. package/schema/yarramate-visual-session-started.schema.json +2 -9
  57. package/schema/yarramate-visual-status.schema.json +3 -2
  58. package/skills/yarramate-architecture/SKILL.md +20 -13
  59. package/skills/yarramate-architecture/references/journey-checklists.md +4 -2
  60. package/skills/yarramate-architecture/references/native-authoring.md +30 -0
  61. package/skills/yarramate-architecture/references/visual-conversations.md +149 -150
  62. package/dist/adapters/visual/likec4-compiler.d.ts +0 -55
  63. package/dist/adapters/visual/likec4-compiler.js +0 -460
  64. package/dist/visual-app/assets/index-CGvC8e3R.css +0 -1
  65. package/dist/visual-app/assets/index-CXuW1tOj.js +0 -209
@@ -4,12 +4,18 @@ import { isMap, isScalar, isSeq, parseDocument, stringify, } from 'yaml';
4
4
  import Ajv2020Module from 'ajv/dist/2020.js';
5
5
  import { diagnosticJson, humanDiagnostics, usage, } from './cli-support.js';
6
6
  import { compileWorkspace } from './compiler.js';
7
+ import { evaluateEvidence, loadEvidence } from './evidence.js';
7
8
  import { loadSourceDocument, locateSourcePath, } from './source-document.js';
8
9
  import { loadWorkspaceManifest } from './workspace.js';
9
10
  import operationsSchema from '../schema/yarramate-operations.schema.json' with { type: 'json'
10
11
  };
11
12
  const Ajv2020 = Ajv2020Module.default;
12
- const validateOperations = new Ajv2020({ allErrors: true }).compile(operationsSchema);
13
+ // `discriminator` routes a batch entry to the single branch its `op` names, so
14
+ // one malformed operation reports one fault instead of nine near-misses.
15
+ const validateOperations = new Ajv2020({
16
+ allErrors: true,
17
+ discriminator: true,
18
+ }).compile(operationsSchema);
13
19
  // Scalar fields replace; list fields append; `remove` retracts (ADR 0062).
14
20
  // An answer enriches what is there and may explicitly take back what it
15
21
  // asserted — it never silently shrinks anything.
@@ -17,6 +23,11 @@ const SCALAR_CONCEPT_FIELDS = ['kind', 'name', 'description', 'status', 'owner']
17
23
  const LIST_CONCEPT_FIELDS = ['aka', 'constraints', 'references', 'presentIn', 'attestations', 'distinctFrom', 'supersedes'];
18
24
  const SCALAR_RELATIONSHIP_FIELDS = ['kind', 'from', 'to', 'name', 'description', 'status', 'mode', 'content'];
19
25
  const LIST_RELATIONSHIP_FIELDS = ['references', 'presentIn'];
26
+ // An overlay entry's address is the pair (target, key); everything else it
27
+ // carries is editable. `uri` and `message` sit one level down, inside the
28
+ // entry's own `evidence:` mapping.
29
+ const SCALAR_OBSERVATION_FIELDS = ['result', 'value'];
30
+ const EVIDENCE_FIELDS = ['uri', 'message'];
20
31
  // ---------------------------------------------------------------------------
21
32
  // The splice layer. Every operation becomes a minimal text edit against the
22
33
  // document's current source, so bytes the batch never touched stay
@@ -69,8 +80,9 @@ const itemFieldInsertAt = (source, map) => {
69
80
  : nodeRange(lastPair.value)[1];
70
81
  return afterContentLine(source, anchor);
71
82
  };
72
- // Appends one item to a top-level block collection (`concepts:` or
73
- // `relationships:`), creating or converting the collection when needed.
83
+ // Appends one item to a top-level block collection (`concepts:`,
84
+ // `relationships:` or an overlay's `observations:`), creating or converting
85
+ // the collection when needed.
74
86
  const appendCollectionItem = (source, collection, item) => {
75
87
  const document = parseDocument(source);
76
88
  const root = document.contents;
@@ -105,7 +117,17 @@ const appendCollectionItem = (source, collection, item) => {
105
117
  start -= 1;
106
118
  return splice(source, start, valueEnd, `\n${sequenceEntries([...existing, item], 2)}`);
107
119
  };
108
- const itemMap = (source, collection, id) => {
120
+ const byId = (id) => (fields) => fields.id === id;
121
+ // An overlay entry has no `id`: it is addressed by the pair (target, key) -
122
+ // what `reconcile` already treats as unique per document (ADR 0075, and the
123
+ // YM803 duplicate-target diagnostic). A keyless address matches the keyless
124
+ // entry, the presence claim for that target, and never stands in for every
125
+ // key the target carries.
126
+ const byObservation = (target) => (fields) => fields.subject === target.subject &&
127
+ fields.claim === target.claim &&
128
+ fields.key === target.key;
129
+ const observationAddress = (target) => `"${target.subject ?? target.claim}"${target.key === undefined ? '' : ` (${target.key})`}`;
130
+ const itemMatching = (source, collection, matches) => {
109
131
  const document = parseDocument(source);
110
132
  const root = document.contents;
111
133
  if (!isMap(root))
@@ -114,15 +136,19 @@ const itemMap = (source, collection, id) => {
114
136
  if (pair === undefined || !isSeq(pair.value))
115
137
  return undefined;
116
138
  const sequence = pair.value;
117
- const found = sequence.items.find((candidate) => isMap(candidate) &&
118
- candidate.items.some((field) => isScalar(field.key) &&
119
- field.key.value === 'id' &&
120
- isScalar(field.value) &&
121
- field.value.value === id));
139
+ const found = sequence.items.find((candidate) => isMap(candidate) && matches(candidate.toJSON()));
122
140
  return found === undefined
123
141
  ? undefined
124
142
  : { map: found, sequence };
125
143
  };
144
+ const itemMap = (source, collection, id) => itemMatching(source, collection, byId(id));
145
+ // An observation's locator is a mapping inside the entry; edits descend into
146
+ // it so a changed URI splices that one line rather than re-rendering the
147
+ // entry around it.
148
+ const nestedMap = (map, key) => {
149
+ const pair = pairFor(map, key);
150
+ return pair !== undefined && isMap(pair.value) ? pair.value : undefined;
151
+ };
126
152
  // The indent item fields sit at, read off the item's own first field.
127
153
  const fieldIndentOf = (source, map) => indentAt(source, nodeRange(map.items[0].key)[0]);
128
154
  // A flow-style item (`- { id: x, ... }`) cannot take block field lines;
@@ -218,10 +244,10 @@ const removeField = (source, map, key) => {
218
244
  // flow collection rewrites the collection value instead, and removing
219
245
  // the last item leaves an explicit empty collection: the document
220
246
  // schema requires the key.
221
- const removeCollectionItem = (source, collection, id) => {
222
- const { map, sequence } = itemMap(source, collection, id);
247
+ const removeCollectionItem = (source, collection, matches) => {
248
+ const { map, sequence } = itemMatching(source, collection, matches);
223
249
  if (sequence.flow) {
224
- const remaining = sequence.toJSON().filter((item) => item.id !== id);
250
+ const remaining = sequence.toJSON().filter((item) => !matches(item));
225
251
  const [start, valueEnd] = nodeRange(sequence);
226
252
  return splice(source, start, valueEnd, remaining.length === 0
227
253
  ? '[]'
@@ -244,280 +270,406 @@ const removeCollectionItem = (source, collection, id) => {
244
270
  }
245
271
  return splice(source, lineStartOf(source, nodeRange(map)[0]), end, '');
246
272
  };
247
- // ---------------------------------------------------------------------------
248
- export function runApplyCommand(options, cwd) {
249
- const json = options.includes('--json');
250
- const rest = options.filter((option) => option !== '--json');
251
- const [operationsPath, workspacePath] = rest;
252
- if (rest.length !== 2 ||
253
- operationsPath === undefined ||
254
- workspacePath === undefined ||
255
- rest.some((option) => option.startsWith('-'))) {
256
- return { exitCode: 2, stdout: '', stderr: usage };
257
- }
258
- try {
259
- const manifestSource = readFileSync(resolve(cwd, workspacePath), 'utf8');
260
- if (parseDocument(manifestSource).get('format') !== 'yarramate/workspace/v1') {
261
- return {
262
- exitCode: 2,
263
- stdout: '',
264
- stderr: 'apply requires an explicit workspace manifest (yarramate/workspace/v1)\n',
265
- };
273
+ // The programmatic core, split out of the CLI wrapper below so a long-lived
274
+ // caller (the visual session server) can apply a batch without shelling
275
+ // out. Takes raw sources rather than a parsed array so loadSourceDocument /
276
+ // locateSourcePath keep producing diagnostics whose pointers read
277
+ // `/operations/<i>/<field>` — that pointer is how a caller maps a rejection
278
+ // back onto whatever authored the operation. No shell-out, no temp files,
279
+ // no process access, no module-level mutable state: safe to call
280
+ // repeatedly and concurrently against different workspaces.
281
+ export const applyOperations = (operations, workspace, cwd) => {
282
+ const failed = (diagnostics) => ({
283
+ ok: false,
284
+ diagnostics,
285
+ });
286
+ const loadedWorkspace = loadWorkspaceManifest(workspace, cwd);
287
+ if (!loadedWorkspace.ok)
288
+ return failed(loadedWorkspace.diagnostics);
289
+ const resolvedWorkspace = loadedWorkspace.workspace;
290
+ const loadedOperations = loadSourceDocument(operations, validateOperations, 'Operations');
291
+ if (!loadedOperations.ok)
292
+ return failed(loadedOperations.diagnostics);
293
+ const operationList = loadedOperations.document.value.operations;
294
+ const yaml = loadedOperations.document.yaml;
295
+ const lineCounter = loadedOperations.document.lineCounter;
296
+ const operationsPath = operations.path;
297
+ // Documents are addressed by their manifest paths; an operation aimed
298
+ // anywhere else is rejected before anything is touched.
299
+ const workspaceDocuments = new Map(resolvedWorkspace.documents.map((path) => [resolve(cwd, path), path]));
300
+ // Overlay entries live in the workspace's evidence documents, addressed the
301
+ // same way. The two sets stay apart: a concept operation aimed at an
302
+ // overlay — or an observation aimed at a compiler document — is rejected
303
+ // before anything is touched.
304
+ const workspaceEvidence = new Map(resolvedWorkspace.evidence.map((path) => [resolve(cwd, path), path]));
305
+ const candidates = new Map();
306
+ const counts = {
307
+ addedConcepts: 0,
308
+ addedRelationships: 0,
309
+ updatedConcepts: 0,
310
+ updatedRelationships: 0,
311
+ deletedConcepts: 0,
312
+ deletedRelationships: 0,
313
+ addedObservations: 0,
314
+ updatedObservations: 0,
315
+ deletedObservations: 0,
316
+ };
317
+ const deletions = [];
318
+ const locateOperation = (index, message) => ({
319
+ severity: 'error',
320
+ code: 'YM912',
321
+ message,
322
+ ...locateSourcePath(operationsPath, yaml, lineCounter, ['operations', index, 'document'], `/operations/${index}/document`),
323
+ });
324
+ for (const [index, operation] of operationList.entries()) {
325
+ const absolute = resolve(cwd, operation.document);
326
+ const overlay = operation.op.endsWith('-observation');
327
+ const manifestPath = overlay
328
+ ? workspaceEvidence.get(absolute)
329
+ : workspaceDocuments.get(absolute);
330
+ const locate = (message) => locateOperation(index, message);
331
+ if (manifestPath === undefined) {
332
+ return failed([
333
+ locate(`Operation ${index} targets "${operation.document}", which is not ${overlay ? 'an evidence document' : 'a document'} of workspace "${resolvedWorkspace.id}"`),
334
+ ]);
266
335
  }
267
- const failed = (diagnostics) => ({
268
- exitCode: 1,
269
- stdout: json ? diagnosticJson(diagnostics) : humanDiagnostics(diagnostics),
270
- stderr: '',
271
- });
272
- const loadedWorkspace = loadWorkspaceManifest({ path: workspacePath, source: manifestSource }, cwd);
273
- if (!loadedWorkspace.ok)
274
- return failed(loadedWorkspace.diagnostics);
275
- const workspace = loadedWorkspace.workspace;
276
- const operationsSource = readFileSync(resolve(cwd, operationsPath), 'utf8');
277
- const loadedOperations = loadSourceDocument({ path: operationsPath, source: operationsSource }, validateOperations, 'Operations');
278
- if (!loadedOperations.ok)
279
- return failed(loadedOperations.diagnostics);
280
- const operations = loadedOperations.document.value.operations;
281
- const yaml = loadedOperations.document.yaml;
282
- const lineCounter = loadedOperations.document.lineCounter;
283
- // Documents are addressed by their manifest paths; an operation aimed
284
- // anywhere else is rejected before anything is touched.
285
- const workspaceDocuments = new Map(workspace.documents.map((path) => [resolve(cwd, path), path]));
286
- const candidates = new Map();
287
- const counts = {
288
- addedConcepts: 0,
289
- addedRelationships: 0,
290
- updatedConcepts: 0,
291
- updatedRelationships: 0,
292
- deletedConcepts: 0,
293
- deletedRelationships: 0,
294
- };
295
- const deletions = [];
296
- const locateOperation = (index, message) => ({
297
- severity: 'error',
298
- code: 'YM912',
299
- message,
300
- ...locateSourcePath(operationsPath, yaml, lineCounter, ['operations', index, 'document'], `/operations/${index}/document`),
301
- });
302
- for (const [index, operation] of operations.entries()) {
303
- const absolute = resolve(cwd, operation.document);
304
- const manifestPath = workspaceDocuments.get(absolute);
305
- const locate = (message) => locateOperation(index, message);
306
- if (manifestPath === undefined) {
336
+ let source = candidates.get(absolute);
337
+ if (source === undefined) {
338
+ source = readFileSync(absolute, 'utf8');
339
+ }
340
+ if (operation.op === 'add-concept') {
341
+ source = appendCollectionItem(source, 'concepts', operation.concept);
342
+ counts.addedConcepts += 1;
343
+ }
344
+ else if (operation.op === 'add-relationship') {
345
+ source = appendCollectionItem(source, 'relationships', operation.relationship);
346
+ counts.addedRelationships += 1;
347
+ }
348
+ else if (operation.op === 'delete-concept' ||
349
+ operation.op === 'delete-relationship') {
350
+ const collection = operation.op === 'delete-concept' ? 'concepts' : 'relationships';
351
+ const id = operation.op === 'delete-concept'
352
+ ? operation.concept.id
353
+ : operation.relationship.id;
354
+ if (itemMap(source, collection, id) === undefined) {
307
355
  return failed([
308
- locate(`Operation ${index} targets "${operation.document}", which is not a document of workspace "${workspace.id}"`),
356
+ locate(`Operation ${index} deletes "${id}", which does not exist in ${operation.document}`),
309
357
  ]);
310
358
  }
311
- let source = candidates.get(absolute);
312
- if (source === undefined) {
313
- source = readFileSync(absolute, 'utf8');
359
+ source = removeCollectionItem(source, collection, byId(id));
360
+ deletions.push({ index, absolute, id });
361
+ if (operation.op === 'delete-concept') {
362
+ counts.deletedConcepts += 1;
314
363
  }
315
- if (operation.op === 'add-concept') {
316
- source = appendCollectionItem(source, 'concepts', operation.concept);
317
- counts.addedConcepts += 1;
364
+ else {
365
+ counts.deletedRelationships += 1;
366
+ }
367
+ }
368
+ else if (operation.op === 'add-observation') {
369
+ const address = observationAddress(operation.observation);
370
+ const matches = byObservation(operation.observation);
371
+ if (itemMatching(source, 'observations', matches) !== undefined) {
372
+ return failed([
373
+ locate(`Operation ${index} adds an observation of ${address}, which ${operation.document} already records`),
374
+ ]);
318
375
  }
319
- else if (operation.op === 'add-relationship') {
320
- source = appendCollectionItem(source, 'relationships', operation.relationship);
321
- counts.addedRelationships += 1;
376
+ source = appendCollectionItem(source, 'observations', operation.observation);
377
+ counts.addedObservations += 1;
378
+ }
379
+ else if (operation.op === 'update-observation') {
380
+ const target = operation.observation;
381
+ const address = observationAddress(target);
382
+ const matches = byObservation(target);
383
+ const removals = operation.remove ?? [];
384
+ if (removals.includes('message') && target.evidence?.message !== undefined) {
385
+ return failed([
386
+ locate(`Operation ${index} both sets and removes "message" on ${address}`),
387
+ ]);
322
388
  }
323
- else if (operation.op === 'delete-concept' ||
324
- operation.op === 'delete-relationship') {
325
- const collection = operation.op === 'delete-concept' ? 'concepts' : 'relationships';
326
- const id = operation.op === 'delete-concept'
327
- ? operation.concept.id
328
- : operation.relationship.id;
329
- if (itemMap(source, collection, id) === undefined) {
389
+ if (itemMatching(source, 'observations', matches) === undefined) {
390
+ return failed([
391
+ locate(`Operation ${index} updates an observation of ${address}, which does not exist in ${operation.document}`),
392
+ ]);
393
+ }
394
+ const fields = target;
395
+ for (const key of SCALAR_OBSERVATION_FIELDS) {
396
+ if (fields[key] === undefined)
397
+ continue;
398
+ source = setScalarField(source, itemMatching(source, 'observations', matches).map, key, fields[key]);
399
+ }
400
+ for (const key of EVIDENCE_FIELDS) {
401
+ const value = target.evidence?.[key];
402
+ if (value === undefined)
403
+ continue;
404
+ const locator = nestedMap(itemMatching(source, 'observations', matches).map, 'evidence');
405
+ if (locator === undefined) {
330
406
  return failed([
331
- locate(`Operation ${index} deletes "${id}", which does not exist in ${operation.document}`),
407
+ locate(`Operation ${index} updates the evidence of ${address}, which ${operation.document} does not record as a mapping`),
332
408
  ]);
333
409
  }
334
- source = removeCollectionItem(source, collection, id);
335
- deletions.push({ index, absolute, id });
336
- if (operation.op === 'delete-concept') {
337
- counts.deletedConcepts += 1;
338
- }
339
- else {
340
- counts.deletedRelationships += 1;
341
- }
410
+ source = setScalarField(source, locator, key, value);
342
411
  }
343
- else {
344
- const collection = operation.op === 'update-concept' ? 'concepts' : 'relationships';
345
- const payload = (operation.op === 'update-concept'
346
- ? operation.concept
347
- : operation.relationship);
348
- const scalars = operation.op === 'update-concept'
349
- ? SCALAR_CONCEPT_FIELDS
350
- : SCALAR_RELATIONSHIP_FIELDS;
351
- const lists = operation.op === 'update-concept'
352
- ? LIST_CONCEPT_FIELDS
353
- : LIST_RELATIONSHIP_FIELDS;
354
- const id = payload.id;
355
- const removals = operation.remove ?? [];
356
- const contradiction = removals.find((key) => payload[key] !== undefined);
357
- if (contradiction !== undefined) {
412
+ for (const key of removals) {
413
+ const locator = nestedMap(itemMatching(source, 'observations', matches).map, 'evidence');
414
+ // Annotated: without it the assignment below feeds `source` back
415
+ // into its own inferred type through this call.
416
+ const removed = locator === undefined ? undefined : removeField(source, locator, key);
417
+ if (removed === undefined) {
358
418
  return failed([
359
- locate(`Operation ${index} both sets and removes "${contradiction}" on "${id}"`),
419
+ locate(`Operation ${index} removes "${key}", which is not set on ${address}`),
360
420
  ]);
361
421
  }
362
- const located = itemMap(source, collection, id);
363
- if (located === undefined) {
422
+ source = removed;
423
+ }
424
+ counts.updatedObservations += 1;
425
+ }
426
+ else if (operation.op === 'delete-observation') {
427
+ const address = observationAddress(operation.observation);
428
+ const matches = byObservation(operation.observation);
429
+ if (itemMatching(source, 'observations', matches) === undefined) {
430
+ return failed([
431
+ locate(`Operation ${index} deletes an observation of ${address}, which does not exist in ${operation.document}`),
432
+ ]);
433
+ }
434
+ // An overlay entry is nobody's reference target, so unlike a concept
435
+ // deletion this stages no reference-integrity check.
436
+ source = removeCollectionItem(source, 'observations', matches);
437
+ counts.deletedObservations += 1;
438
+ }
439
+ else {
440
+ const collection = operation.op === 'update-concept' ? 'concepts' : 'relationships';
441
+ const payload = (operation.op === 'update-concept'
442
+ ? operation.concept
443
+ : operation.relationship);
444
+ const scalars = operation.op === 'update-concept'
445
+ ? SCALAR_CONCEPT_FIELDS
446
+ : SCALAR_RELATIONSHIP_FIELDS;
447
+ const lists = operation.op === 'update-concept'
448
+ ? LIST_CONCEPT_FIELDS
449
+ : LIST_RELATIONSHIP_FIELDS;
450
+ const id = payload.id;
451
+ const removals = operation.remove ?? [];
452
+ const contradiction = removals.find((key) => payload[key] !== undefined);
453
+ if (contradiction !== undefined) {
454
+ return failed([
455
+ locate(`Operation ${index} both sets and removes "${contradiction}" on "${id}"`),
456
+ ]);
457
+ }
458
+ const located = itemMap(source, collection, id);
459
+ if (located === undefined) {
460
+ return failed([
461
+ locate(`Operation ${index} updates "${id}", which does not exist in ${operation.document}`),
462
+ ]);
463
+ }
464
+ for (const key of scalars) {
465
+ if (payload[key] === undefined)
466
+ continue;
467
+ source = setScalarField(source, itemMap(source, collection, id).map, key, payload[key]);
468
+ }
469
+ for (const key of lists) {
470
+ const additions = payload[key];
471
+ if (additions === undefined || additions.length === 0)
472
+ continue;
473
+ source = appendListField(source, itemMap(source, collection, id).map, key, additions);
474
+ }
475
+ for (const key of removals) {
476
+ const removed = removeField(source, itemMap(source, collection, id).map, key);
477
+ if (removed === undefined) {
364
478
  return failed([
365
- locate(`Operation ${index} updates "${id}", which does not exist in ${operation.document}`),
479
+ locate(`Operation ${index} removes "${key}", which is not set on "${id}"`),
366
480
  ]);
367
481
  }
368
- for (const key of scalars) {
369
- if (payload[key] === undefined)
370
- continue;
371
- source = setScalarField(source, itemMap(source, collection, id).map, key, payload[key]);
372
- }
373
- for (const key of lists) {
374
- const additions = payload[key];
375
- if (additions === undefined || additions.length === 0)
376
- continue;
377
- source = appendListField(source, itemMap(source, collection, id).map, key, additions);
378
- }
379
- for (const key of removals) {
380
- const removed = removeField(source, itemMap(source, collection, id).map, key);
381
- if (removed === undefined) {
382
- return failed([
383
- locate(`Operation ${index} removes "${key}", which is not set on "${id}"`),
384
- ]);
385
- }
386
- source = removed;
482
+ source = removed;
483
+ }
484
+ if (operation.op === 'update-concept') {
485
+ counts.updatedConcepts += 1;
486
+ }
487
+ else {
488
+ counts.updatedRelationships += 1;
489
+ }
490
+ }
491
+ candidates.set(absolute, source);
492
+ }
493
+ // Reference integrity for deletes (#123), evaluated against the
494
+ // post-batch state: stage everything first, then look, so a concept
495
+ // deleted together with its referring relationships in one batch
496
+ // succeeds while a target anything still points at rejects the
497
+ // whole batch. Referring sites are relationship endpoints, owner,
498
+ // constraint and identified references; projection selectors are
499
+ // deliberately unchecked — they tolerate no-match by design. The
500
+ // compile gate below stays the backstop.
501
+ if (deletions.length > 0) {
502
+ const staged = resolvedWorkspace.documents.map((path) => {
503
+ const absolute = resolve(cwd, path);
504
+ return {
505
+ absolute,
506
+ value: parseDocument(candidates.get(absolute) ?? readFileSync(absolute, 'utf8')).toJSON(),
507
+ };
508
+ });
509
+ const qualify = (documentId, reference) => reference.includes('#') ? reference : `${documentId}#${reference}`;
510
+ const referrers = staged.flatMap(({ value }) => {
511
+ const documentId = value?.id;
512
+ if (documentId === undefined)
513
+ return [];
514
+ const sites = [];
515
+ for (const concept of value?.concepts ?? []) {
516
+ const subject = `${documentId}#${concept.id}`;
517
+ if (concept.owner !== undefined) {
518
+ sites.push({
519
+ ref: qualify(documentId, concept.owner),
520
+ subject,
521
+ field: 'owner',
522
+ });
387
523
  }
388
- if (operation.op === 'update-concept') {
389
- counts.updatedConcepts += 1;
524
+ for (const constraint of concept.constraints ?? []) {
525
+ sites.push({
526
+ ref: qualify(documentId, constraint.ref),
527
+ subject,
528
+ field: 'constraints',
529
+ });
390
530
  }
391
- else {
392
- counts.updatedRelationships += 1;
531
+ for (const reference of concept.references ?? []) {
532
+ sites.push({
533
+ ref: qualify(documentId, reference.ref),
534
+ subject,
535
+ field: 'references',
536
+ });
393
537
  }
394
538
  }
395
- candidates.set(absolute, source);
396
- }
397
- // Reference integrity for deletes (#123), evaluated against the
398
- // post-batch state: stage everything first, then look, so a concept
399
- // deleted together with its referring relationships in one batch
400
- // succeeds while a target anything still points at rejects the
401
- // whole batch. Referring sites are relationship endpoints, owner,
402
- // constraint and identified references; projection selectors are
403
- // deliberately unchecked — they tolerate no-match by design. The
404
- // compile gate below stays the backstop.
405
- if (deletions.length > 0) {
406
- const staged = workspace.documents.map((path) => {
407
- const absolute = resolve(cwd, path);
408
- return {
409
- absolute,
410
- value: parseDocument(candidates.get(absolute) ?? readFileSync(absolute, 'utf8')).toJSON(),
411
- };
412
- });
413
- const qualify = (documentId, reference) => reference.includes('#') ? reference : `${documentId}#${reference}`;
414
- const referrers = staged.flatMap(({ value }) => {
415
- const documentId = value?.id;
416
- if (documentId === undefined)
417
- return [];
418
- const sites = [];
419
- for (const concept of value?.concepts ?? []) {
420
- const subject = `${documentId}#${concept.id}`;
421
- if (concept.owner !== undefined) {
539
+ for (const relationship of value?.relationships ?? []) {
540
+ const subject = `${documentId}#${relationship.id}`;
541
+ for (const endpoint of ['from', 'to']) {
542
+ const reference = relationship[endpoint];
543
+ if (reference !== undefined) {
422
544
  sites.push({
423
- ref: qualify(documentId, concept.owner),
545
+ ref: qualify(documentId, reference),
424
546
  subject,
425
- field: 'owner',
426
- });
427
- }
428
- for (const constraint of concept.constraints ?? []) {
429
- sites.push({
430
- ref: qualify(documentId, constraint.ref),
431
- subject,
432
- field: 'constraints',
433
- });
434
- }
435
- for (const reference of concept.references ?? []) {
436
- sites.push({
437
- ref: qualify(documentId, reference.ref),
438
- subject,
439
- field: 'references',
547
+ field: endpoint,
440
548
  });
441
549
  }
442
550
  }
443
- for (const relationship of value?.relationships ?? []) {
444
- const subject = `${documentId}#${relationship.id}`;
445
- for (const endpoint of ['from', 'to']) {
446
- const reference = relationship[endpoint];
447
- if (reference !== undefined) {
448
- sites.push({
449
- ref: qualify(documentId, reference),
450
- subject,
451
- field: endpoint,
452
- });
453
- }
454
- }
455
- for (const reference of relationship.references ?? []) {
456
- sites.push({
457
- ref: qualify(documentId, reference.ref),
458
- subject,
459
- field: 'references',
460
- });
461
- }
551
+ for (const reference of relationship.references ?? []) {
552
+ sites.push({
553
+ ref: qualify(documentId, reference.ref),
554
+ subject,
555
+ field: 'references',
556
+ });
462
557
  }
463
- return sites;
464
- });
465
- const documentIds = new Map(staged.map(({ absolute, value }) => [absolute, value?.id]));
466
- const violations = deletions.flatMap((deletion) => {
467
- const documentId = documentIds.get(deletion.absolute);
468
- if (documentId === undefined)
469
- return [];
470
- const target = `${documentId}#${deletion.id}`;
471
- const referring = referrers.filter((site) => site.ref === target);
472
- if (referring.length === 0)
473
- return [];
474
- return [
475
- locateOperation(deletion.index, `Operation ${deletion.index} deletes "${deletion.id}", which is still referenced by ${referring
476
- .map((site) => `"${site.subject}" (${site.field})`)
477
- .join(', ')}`),
478
- ];
479
- });
480
- if (violations.length > 0)
481
- return failed(violations);
558
+ }
559
+ return sites;
560
+ });
561
+ const documentIds = new Map(staged.map(({ absolute, value }) => [absolute, value?.id]));
562
+ const violations = deletions.flatMap((deletion) => {
563
+ const documentId = documentIds.get(deletion.absolute);
564
+ if (documentId === undefined)
565
+ return [];
566
+ const target = `${documentId}#${deletion.id}`;
567
+ const referring = referrers.filter((site) => site.ref === target);
568
+ if (referring.length === 0)
569
+ return [];
570
+ return [
571
+ locateOperation(deletion.index, `Operation ${deletion.index} deletes "${deletion.id}", which is still referenced by ${referring
572
+ .map((site) => `"${site.subject}" (${site.field})`)
573
+ .join(', ')}`),
574
+ ];
575
+ });
576
+ if (violations.length > 0)
577
+ return failed(violations);
578
+ }
579
+ // The atomic gate: the whole candidate workspace must compile before a
580
+ // single byte is written; any diagnostic rejects the entire batch.
581
+ const compilation = compileWorkspace([...resolvedWorkspace.profiles, ...resolvedWorkspace.documents].map((path) => {
582
+ const absolute = resolve(cwd, path);
583
+ return {
584
+ path,
585
+ source: candidates.get(absolute) ?? readFileSync(absolute, 'utf8'),
586
+ };
587
+ }));
588
+ if (!compilation.ok)
589
+ return failed(compilation.diagnostics);
590
+ // The overlay gate: an observation the batch authored must load and must
591
+ // evaluate against the graph the batch just proved compiles, so an entry
592
+ // naming a subject that does not exist is rejected here rather than
593
+ // discovered later by `reconcile`. Only touched overlays are evaluated —
594
+ // pre-existing drift elsewhere is reconcile's report to make, not this
595
+ // batch's failure.
596
+ for (const [absolute, path] of workspaceEvidence) {
597
+ const source = candidates.get(absolute);
598
+ if (source === undefined)
599
+ continue;
600
+ const loaded = loadEvidence({ path, source });
601
+ if (!loaded.ok)
602
+ return failed(loaded.diagnostics);
603
+ const evaluation = evaluateEvidence(compilation.graph, loaded.evidence);
604
+ if (!evaluation.ok)
605
+ return failed(evaluation.diagnostics);
606
+ }
607
+ for (const [absolute, source] of candidates) {
608
+ writeFileSync(absolute, source, 'utf8');
609
+ }
610
+ const touched = [...candidates.keys()]
611
+ .map((absolute) => workspaceDocuments.get(absolute) ?? workspaceEvidence.get(absolute))
612
+ .sort();
613
+ return {
614
+ ok: true,
615
+ result: {
616
+ format: 'yarramate/apply-result/v1',
617
+ workspace: resolvedWorkspace.id,
618
+ applied: counts,
619
+ documents: touched,
620
+ },
621
+ };
622
+ };
623
+ export function runApplyCommand(options, cwd) {
624
+ const json = options.includes('--json');
625
+ const rest = options.filter((option) => option !== '--json');
626
+ const [operationsPath, workspacePath] = rest;
627
+ if (rest.length !== 2 ||
628
+ operationsPath === undefined ||
629
+ workspacePath === undefined ||
630
+ rest.some((option) => option.startsWith('-'))) {
631
+ return { exitCode: 2, stdout: '', stderr: usage };
632
+ }
633
+ try {
634
+ const manifestSource = readFileSync(resolve(cwd, workspacePath), 'utf8');
635
+ if (parseDocument(manifestSource).get('format') !== 'yarramate/workspace/v1') {
636
+ return {
637
+ exitCode: 2,
638
+ stdout: '',
639
+ stderr: 'apply requires an explicit workspace manifest (yarramate/workspace/v1)\n',
640
+ };
482
641
  }
483
- // The atomic gate: the whole candidate workspace must compile before a
484
- // single byte is written; any diagnostic rejects the entire batch.
485
- const compilation = compileWorkspace([...workspace.profiles, ...workspace.documents].map((path) => {
486
- const absolute = resolve(cwd, path);
642
+ const operationsSource = readFileSync(resolve(cwd, operationsPath), 'utf8');
643
+ const outcome = applyOperations({ path: operationsPath, source: operationsSource }, { path: workspacePath, source: manifestSource }, cwd);
644
+ if (!outcome.ok) {
487
645
  return {
488
- path,
489
- source: candidates.get(absolute) ?? readFileSync(absolute, 'utf8'),
646
+ exitCode: 1,
647
+ stdout: json
648
+ ? diagnosticJson(outcome.diagnostics)
649
+ : humanDiagnostics(outcome.diagnostics),
650
+ stderr: '',
490
651
  };
491
- }));
492
- if (!compilation.ok)
493
- return failed(compilation.diagnostics);
494
- for (const [absolute, source] of candidates) {
495
- writeFileSync(absolute, source, 'utf8');
496
652
  }
497
- const touched = [...candidates.keys()]
498
- .map((absolute) => workspaceDocuments.get(absolute))
499
- .sort();
653
+ const { result } = outcome;
500
654
  if (json) {
501
655
  return {
502
656
  exitCode: 0,
503
- stdout: `${JSON.stringify({
504
- format: 'yarramate/apply-result/v1',
505
- workspace: workspace.id,
506
- applied: counts,
507
- documents: touched,
508
- }, null, 2)}\n`,
657
+ stdout: `${JSON.stringify(result, null, 2)}\n`,
509
658
  stderr: '',
510
659
  };
511
660
  }
512
- const applied = counts.addedConcepts +
513
- counts.addedRelationships +
514
- counts.updatedConcepts +
515
- counts.updatedRelationships +
516
- counts.deletedConcepts +
517
- counts.deletedRelationships;
661
+ const applied = result.applied.addedConcepts +
662
+ result.applied.addedRelationships +
663
+ result.applied.updatedConcepts +
664
+ result.applied.updatedRelationships +
665
+ result.applied.deletedConcepts +
666
+ result.applied.deletedRelationships +
667
+ result.applied.addedObservations +
668
+ result.applied.updatedObservations +
669
+ result.applied.deletedObservations;
518
670
  return {
519
671
  exitCode: 0,
520
- stdout: `Applied ${applied} operation${applied === 1 ? '' : 's'} to ${touched.join(', ')}\n`,
672
+ stdout: `Applied ${applied} operation${applied === 1 ? '' : 's'} to ${result.documents.join(', ')}\n`,
521
673
  stderr: '',
522
674
  };
523
675
  }