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.
- package/README.md +2 -1
- package/catalogues/core-enrichment.yaml +52 -1
- package/dist/adapters/graphify-cli.js +0 -0
- package/dist/adapters/likec4-cli.js +0 -0
- package/dist/adapters/mcp-cli.js +0 -0
- package/dist/adapters/visual/client.js +0 -1
- package/dist/adapters/visual/protocol-contract.d.ts +118 -35
- package/dist/adapters/visual/protocol-contract.js +1 -11
- package/dist/adapters/visual/protocol.d.ts +8 -1
- package/dist/adapters/visual/protocol.js +86 -46
- package/dist/adapters/visual/request.d.ts +37 -0
- package/dist/adapters/visual/request.js +131 -0
- package/dist/adapters/visual/session-server.d.ts +12 -14
- package/dist/adapters/visual/session-server.js +641 -283
- package/dist/adapters/visual/session-store.d.ts +28 -28
- package/dist/adapters/visual/session-store.js +32 -93
- package/dist/adapters/visual/wire.d.ts +36 -10
- package/dist/adapters/visual-cli.d.ts +3 -0
- package/dist/adapters/visual-cli.js +66 -1
- package/dist/apply-command.d.ts +10 -0
- package/dist/apply-command.js +399 -247
- package/dist/cli.js +0 -0
- package/dist/compiler.d.ts +13 -0
- package/dist/compiler.js +36 -2
- package/dist/graph-projection.d.ts +62 -0
- package/dist/graph-projection.js +222 -0
- package/dist/interrogate-command.d.ts +2 -0
- package/dist/interrogate-command.js +22 -0
- package/dist/kind-label.d.ts +8 -0
- package/dist/kind-label.js +8 -0
- package/dist/operations.d.ts +139 -0
- package/dist/operations.js +11 -0
- package/dist/projection.d.ts +2 -0
- package/dist/projection.js +8 -2
- package/dist/reconciliation.js +8 -7
- package/dist/source-document.d.ts +10 -0
- package/dist/source-document.js +21 -1
- package/dist/visual-app/assets/index-6oSR9RGM.js +354 -0
- package/dist/visual-app/assets/index-dwA9AOr5.css +1 -0
- package/dist/visual-app/index.html +2 -2
- package/docs/CONSUMING-YARRAMATE.md +25 -19
- package/package.json +6 -1
- package/schema/yarramate-adapter-mapping.schema.json +1 -1
- package/schema/yarramate-apply-result.schema.json +8 -2
- package/schema/yarramate-document.schema.json +1 -1
- package/schema/yarramate-operations.schema.json +203 -1
- package/schema/yarramate-projection.schema.json +12 -3
- package/schema/yarramate-question-catalogue.schema.json +15 -2
- package/schema/yarramate-visual-event.schema.json +143 -0
- package/schema/yarramate-visual-graph.schema.json +272 -0
- package/schema/yarramate-visual-layout.schema.json +41 -0
- package/schema/yarramate-visual-model.schema.json +6 -44
- package/schema/yarramate-visual-response.schema.json +170 -19
- package/schema/yarramate-visual-session-descriptor.schema.json +1 -1
- package/schema/yarramate-visual-session-request.schema.json +0 -70
- package/schema/yarramate-visual-session-started.schema.json +2 -9
- package/schema/yarramate-visual-status.schema.json +3 -2
- package/skills/yarramate-architecture/SKILL.md +20 -13
- package/skills/yarramate-architecture/references/journey-checklists.md +4 -2
- package/skills/yarramate-architecture/references/native-authoring.md +30 -0
- package/skills/yarramate-architecture/references/visual-conversations.md +149 -150
- package/dist/adapters/visual/likec4-compiler.d.ts +0 -55
- package/dist/adapters/visual/likec4-compiler.js +0 -460
- package/dist/visual-app/assets/index-CGvC8e3R.css +0 -1
- package/dist/visual-app/assets/index-CXuW1tOj.js +0 -209
package/dist/apply-command.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
73
|
-
// `relationships:`), creating or converting
|
|
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
|
|
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,
|
|
222
|
-
const { map, sequence } =
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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}
|
|
356
|
+
locate(`Operation ${index} deletes "${id}", which does not exist in ${operation.document}`),
|
|
309
357
|
]);
|
|
310
358
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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}
|
|
407
|
+
locate(`Operation ${index} updates the evidence of ${address}, which ${operation.document} does not record as a mapping`),
|
|
332
408
|
]);
|
|
333
409
|
}
|
|
334
|
-
source =
|
|
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
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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}
|
|
419
|
+
locate(`Operation ${index} removes "${key}", which is not set on ${address}`),
|
|
360
420
|
]);
|
|
361
421
|
}
|
|
362
|
-
|
|
363
|
-
|
|
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}
|
|
479
|
+
locate(`Operation ${index} removes "${key}", which is not set on "${id}"`),
|
|
366
480
|
]);
|
|
367
481
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
389
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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,
|
|
545
|
+
ref: qualify(documentId, reference),
|
|
424
546
|
subject,
|
|
425
|
-
field:
|
|
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
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
return [
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
489
|
-
|
|
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
|
|
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 =
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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 ${
|
|
672
|
+
stdout: `Applied ${applied} operation${applied === 1 ? '' : 's'} to ${result.documents.join(', ')}\n`,
|
|
521
673
|
stderr: '',
|
|
522
674
|
};
|
|
523
675
|
}
|