yarramate 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/apply-command.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
|
-
import { isSeq, parseDocument } from 'yaml';
|
|
3
|
+
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';
|
|
@@ -11,48 +11,170 @@ import operationsSchema from '../schema/yarramate-operations.schema.json' with {
|
|
|
11
11
|
};
|
|
12
12
|
const Ajv2020 = Ajv2020Module.default;
|
|
13
13
|
const validateOperations = new Ajv2020({ allErrors: true }).compile(operationsSchema);
|
|
14
|
-
// Scalar fields replace; list fields append
|
|
15
|
-
//
|
|
14
|
+
// Scalar fields replace; list fields append; `remove` retracts (ADR 0062).
|
|
15
|
+
// An answer enriches what is there and may explicitly take back what it
|
|
16
|
+
// asserted — it never silently shrinks anything.
|
|
16
17
|
const SCALAR_CONCEPT_FIELDS = ['kind', 'name', 'description', 'status', 'owner'];
|
|
17
18
|
const LIST_CONCEPT_FIELDS = ['constraints', 'references', 'presentIn', 'attestations'];
|
|
18
19
|
const SCALAR_RELATIONSHIP_FIELDS = ['kind', 'from', 'to', 'name', 'description', 'status', 'mode', 'content'];
|
|
19
20
|
const LIST_RELATIONSHIP_FIELDS = ['references', 'presentIn'];
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// The splice layer. Every operation becomes a minimal text edit against the
|
|
23
|
+
// document's current source, so bytes the batch never touched stay
|
|
24
|
+
// byte-identical — an apply diff is exactly the answer it landed (#114).
|
|
25
|
+
// The atomic compile gate below validates the spliced text itself, so any
|
|
26
|
+
// splice defect rejects the batch loudly instead of corrupting a document.
|
|
27
|
+
const lineStartOf = (source, offset) => source.lastIndexOf('\n', offset - 1) + 1;
|
|
28
|
+
const indentAt = (source, offset) => offset - lineStartOf(source, offset);
|
|
29
|
+
// End of the last line a node occupies, extended through its newline.
|
|
30
|
+
const lineEndAfter = (source, offset) => {
|
|
31
|
+
const newline = source.indexOf('\n', Math.max(offset - 1, 0));
|
|
32
|
+
return newline === -1 ? source.length : newline + 1;
|
|
33
|
+
};
|
|
34
|
+
const reindent = (text, indent) => text.split('\n').join(`\n${' '.repeat(indent)}`);
|
|
35
|
+
// A plain value as YAML source. lineWidth 0 keeps strings we author on one
|
|
36
|
+
// line; genuinely multi-line strings become block scalars and are re-indented
|
|
37
|
+
// by the caller.
|
|
38
|
+
const valueText = (value) => stringify(value, { lineWidth: 0 }).trimEnd();
|
|
39
|
+
const pairFor = (map, key) => map.items.find((pair) => isScalar(pair.key) && pair.key.value === key);
|
|
40
|
+
const nodeRange = (node) => {
|
|
41
|
+
const range = node.range;
|
|
42
|
+
if (range === undefined) {
|
|
43
|
+
throw new Error('YAML node has no source range');
|
|
44
|
+
}
|
|
45
|
+
return range;
|
|
46
|
+
};
|
|
47
|
+
// Renders `items` as block sequence entries at the given marker indent.
|
|
48
|
+
const sequenceEntries = (items, markerIndent) => {
|
|
49
|
+
const rendered = stringify(items, { lineWidth: 0 }).trimEnd();
|
|
50
|
+
return `${' '.repeat(markerIndent)}${reindent(rendered, markerIndent)}`;
|
|
51
|
+
};
|
|
52
|
+
const splice = (source, start, end, text) => source.slice(0, start) + text + source.slice(end);
|
|
53
|
+
// Insert a newline-terminated block at a line boundary, tolerating a
|
|
54
|
+
// source that does not end in a newline.
|
|
55
|
+
const insertBlock = (source, insertAt, block) => splice(source, insertAt, insertAt, insertAt > 0 && source[insertAt - 1] !== '\n' ? `\n${block}` : block);
|
|
56
|
+
// Node ranges may extend past their trailing newline to the start of the
|
|
57
|
+
// next line; anchor insertions on the last CONTENT line instead.
|
|
58
|
+
const afterContentLine = (source, offset) => {
|
|
59
|
+
let anchor = offset;
|
|
60
|
+
while (anchor > 0 && source[anchor - 1] === '\n')
|
|
61
|
+
anchor -= 1;
|
|
62
|
+
return lineEndAfter(source, anchor);
|
|
63
|
+
};
|
|
64
|
+
// Where a new field of an item lands: after the last line of the item's
|
|
65
|
+
// final pair.
|
|
66
|
+
const itemFieldInsertAt = (source, map) => {
|
|
67
|
+
const lastPair = map.items[map.items.length - 1];
|
|
68
|
+
const anchor = lastPair.value === null || lastPair.value === undefined
|
|
69
|
+
? nodeRange(lastPair.key)[2]
|
|
70
|
+
: nodeRange(lastPair.value)[1];
|
|
71
|
+
return afterContentLine(source, anchor);
|
|
72
|
+
};
|
|
73
|
+
// Appends one item to a top-level block collection (`concepts:` or
|
|
74
|
+
// `relationships:`), creating or converting the collection when needed.
|
|
75
|
+
const appendCollectionItem = (source, collection, item) => {
|
|
76
|
+
const document = parseDocument(source);
|
|
77
|
+
const root = document.contents;
|
|
78
|
+
if (!isMap(root))
|
|
79
|
+
throw new Error('Document root is not a mapping');
|
|
80
|
+
const pair = pairFor(root, collection);
|
|
81
|
+
if (pair === undefined) {
|
|
82
|
+
const base = source.endsWith('\n') ? source : `${source}\n`;
|
|
83
|
+
return `${base}${collection}:\n${sequenceEntries([item], 2)}\n`;
|
|
84
|
+
}
|
|
85
|
+
const sequence = pair.value;
|
|
86
|
+
if (isSeq(sequence) && !sequence.flow && sequence.items.length > 0) {
|
|
87
|
+
const lastItem = sequence.items[sequence.items.length - 1];
|
|
88
|
+
const markerIndent = indentAt(source, nodeRange(lastItem)[0]) - 2;
|
|
89
|
+
const insertAt = lineEndAfter(source, nodeRange(lastItem)[2]);
|
|
90
|
+
return insertBlock(source, insertAt, `${sequenceEntries([item], markerIndent)}\n`);
|
|
25
91
|
}
|
|
92
|
+
if (!isSeq(sequence)) {
|
|
93
|
+
// `concepts:` with no value at all: append the block under the key.
|
|
94
|
+
const insertAt = lineEndAfter(source, nodeRange(pair.key)[2]);
|
|
95
|
+
return insertBlock(source, insertAt, `${sequenceEntries([item], 2)}\n`);
|
|
96
|
+
}
|
|
97
|
+
// Empty or flow collection: replace the whole value with a block
|
|
98
|
+
// sequence carrying any existing entries plus the new one, consuming
|
|
99
|
+
// the space that separated it from the key.
|
|
100
|
+
const existing = sequence.items.length > 0
|
|
101
|
+
? sequence.toJSON()
|
|
102
|
+
: [];
|
|
103
|
+
let [start] = nodeRange(sequence);
|
|
104
|
+
const valueEnd = nodeRange(sequence)[1];
|
|
105
|
+
while (start > 0 && source[start - 1] === ' ')
|
|
106
|
+
start -= 1;
|
|
107
|
+
return splice(source, start, valueEnd, `\n${sequenceEntries([...existing, item], 2)}`);
|
|
26
108
|
};
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
109
|
+
const itemMap = (source, collection, id) => {
|
|
110
|
+
const document = parseDocument(source);
|
|
111
|
+
const root = document.contents;
|
|
112
|
+
if (!isMap(root))
|
|
113
|
+
return undefined;
|
|
114
|
+
const pair = pairFor(root, collection);
|
|
115
|
+
if (pair === undefined || !isSeq(pair.value))
|
|
30
116
|
return undefined;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
117
|
+
const found = pair.value.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));
|
|
122
|
+
return found === undefined ? undefined : { map: found };
|
|
35
123
|
};
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
124
|
+
// The indent item fields sit at, read off the item's own first field.
|
|
125
|
+
const fieldIndentOf = (source, map) => indentAt(source, nodeRange(map.items[0].key)[0]);
|
|
126
|
+
const setScalarField = (source, map, key, value) => {
|
|
127
|
+
const indent = fieldIndentOf(source, map);
|
|
128
|
+
const rendered = reindent(valueText(value), indent + 2);
|
|
129
|
+
const pair = pairFor(map, key);
|
|
130
|
+
if (pair === undefined) {
|
|
131
|
+
return insertBlock(source, itemFieldInsertAt(source, map), `${' '.repeat(indent)}${key}: ${rendered}\n`);
|
|
40
132
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
133
|
+
const [start, valueEnd] = nodeRange(pair.value);
|
|
134
|
+
return splice(source, start, valueEnd, rendered);
|
|
135
|
+
};
|
|
136
|
+
const appendListField = (source, map, key, additions) => {
|
|
137
|
+
const indent = fieldIndentOf(source, map);
|
|
138
|
+
const pair = pairFor(map, key);
|
|
139
|
+
if (pair === undefined) {
|
|
140
|
+
return insertBlock(source, itemFieldInsertAt(source, map), `${' '.repeat(indent)}${key}:\n${sequenceEntries(additions, indent + 2)}\n`);
|
|
141
|
+
}
|
|
142
|
+
const sequence = pair.value;
|
|
143
|
+
if (isSeq(sequence) && !sequence.flow && sequence.items.length > 0) {
|
|
144
|
+
const lastItem = sequence.items[sequence.items.length - 1];
|
|
145
|
+
const markerIndent = indentAt(source, nodeRange(lastItem)[0]) - 2;
|
|
146
|
+
const insertAt = lineEndAfter(source, nodeRange(lastItem)[2]);
|
|
147
|
+
return insertBlock(source, insertAt, `${sequenceEntries(additions, markerIndent)}\n`);
|
|
54
148
|
}
|
|
149
|
+
const existing = isSeq(sequence) && sequence.items.length > 0
|
|
150
|
+
? sequence.toJSON()
|
|
151
|
+
: [];
|
|
152
|
+
const merged = [...existing, ...additions];
|
|
153
|
+
const [start, valueEnd] = isSeq(sequence)
|
|
154
|
+
? nodeRange(sequence)
|
|
155
|
+
: nodeRange(pair.value);
|
|
156
|
+
if (isSeq(sequence) && sequence.flow) {
|
|
157
|
+
const flow = stringify(merged, {
|
|
158
|
+
collectionStyle: 'flow',
|
|
159
|
+
lineWidth: 0,
|
|
160
|
+
}).trimEnd();
|
|
161
|
+
return splice(source, start, valueEnd, flow);
|
|
162
|
+
}
|
|
163
|
+
return splice(source, start, valueEnd, `\n${sequenceEntries(merged, indent + 2)}`);
|
|
164
|
+
};
|
|
165
|
+
// Retraction (#115): delete the field's whole entry, from the start of its
|
|
166
|
+
// key line through the end of its value's last line.
|
|
167
|
+
const removeField = (source, map, key) => {
|
|
168
|
+
const pair = pairFor(map, key);
|
|
169
|
+
if (pair === undefined)
|
|
170
|
+
return undefined;
|
|
171
|
+
const start = lineStartOf(source, nodeRange(pair.key)[0]);
|
|
172
|
+
const valueEnd = pair.value === null || pair.value === undefined
|
|
173
|
+
? nodeRange(pair.key)[2]
|
|
174
|
+
: nodeRange(pair.value)[2];
|
|
175
|
+
return splice(source, start, lineEndAfter(source, valueEnd), '');
|
|
55
176
|
};
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
56
178
|
export function runApplyCommand(options, cwd) {
|
|
57
179
|
const json = options.includes('--json');
|
|
58
180
|
const rest = options.filter((option) => option !== '--json');
|
|
@@ -91,7 +213,7 @@ export function runApplyCommand(options, cwd) {
|
|
|
91
213
|
// Documents are addressed by their manifest paths; an operation aimed
|
|
92
214
|
// anywhere else is rejected before anything is touched.
|
|
93
215
|
const workspaceDocuments = new Map(workspace.documents.map((path) => [resolve(cwd, path), path]));
|
|
94
|
-
const
|
|
216
|
+
const candidates = new Map();
|
|
95
217
|
const counts = {
|
|
96
218
|
addedConcepts: 0,
|
|
97
219
|
addedRelationships: 0,
|
|
@@ -112,35 +234,63 @@ export function runApplyCommand(options, cwd) {
|
|
|
112
234
|
locate(`Operation ${index} targets "${operation.document}", which is not a document of workspace "${workspace.id}"`),
|
|
113
235
|
]);
|
|
114
236
|
}
|
|
115
|
-
let
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
parsed.set(absolute, document);
|
|
237
|
+
let source = candidates.get(absolute);
|
|
238
|
+
if (source === undefined) {
|
|
239
|
+
source = readFileSync(absolute, 'utf8');
|
|
119
240
|
}
|
|
120
241
|
if (operation.op === 'add-concept') {
|
|
121
|
-
|
|
242
|
+
source = appendCollectionItem(source, 'concepts', operation.concept);
|
|
122
243
|
counts.addedConcepts += 1;
|
|
123
244
|
}
|
|
124
245
|
else if (operation.op === 'add-relationship') {
|
|
125
|
-
|
|
246
|
+
source = appendCollectionItem(source, 'relationships', operation.relationship);
|
|
126
247
|
counts.addedRelationships += 1;
|
|
127
248
|
}
|
|
128
249
|
else {
|
|
129
250
|
const collection = operation.op === 'update-concept' ? 'concepts' : 'relationships';
|
|
130
|
-
const payload = operation.op === 'update-concept'
|
|
251
|
+
const payload = (operation.op === 'update-concept'
|
|
131
252
|
? operation.concept
|
|
132
|
-
: operation.relationship;
|
|
133
|
-
const
|
|
134
|
-
|
|
253
|
+
: operation.relationship);
|
|
254
|
+
const scalars = operation.op === 'update-concept'
|
|
255
|
+
? SCALAR_CONCEPT_FIELDS
|
|
256
|
+
: SCALAR_RELATIONSHIP_FIELDS;
|
|
257
|
+
const lists = operation.op === 'update-concept'
|
|
258
|
+
? LIST_CONCEPT_FIELDS
|
|
259
|
+
: LIST_RELATIONSHIP_FIELDS;
|
|
260
|
+
const id = payload.id;
|
|
261
|
+
const removals = operation.remove ?? [];
|
|
262
|
+
const contradiction = removals.find((key) => payload[key] !== undefined);
|
|
263
|
+
if (contradiction !== undefined) {
|
|
135
264
|
return failed([
|
|
136
|
-
locate(`Operation ${index}
|
|
265
|
+
locate(`Operation ${index} both sets and removes "${contradiction}" on "${id}"`),
|
|
137
266
|
]);
|
|
138
267
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
268
|
+
const located = itemMap(source, collection, id);
|
|
269
|
+
if (located === undefined) {
|
|
270
|
+
return failed([
|
|
271
|
+
locate(`Operation ${index} updates "${id}", which does not exist in ${operation.document}`),
|
|
272
|
+
]);
|
|
273
|
+
}
|
|
274
|
+
for (const key of scalars) {
|
|
275
|
+
if (payload[key] === undefined)
|
|
276
|
+
continue;
|
|
277
|
+
source = setScalarField(source, itemMap(source, collection, id).map, key, payload[key]);
|
|
278
|
+
}
|
|
279
|
+
for (const key of lists) {
|
|
280
|
+
const additions = payload[key];
|
|
281
|
+
if (additions === undefined || additions.length === 0)
|
|
282
|
+
continue;
|
|
283
|
+
source = appendListField(source, itemMap(source, collection, id).map, key, additions);
|
|
284
|
+
}
|
|
285
|
+
for (const key of removals) {
|
|
286
|
+
const removed = removeField(source, itemMap(source, collection, id).map, key);
|
|
287
|
+
if (removed === undefined) {
|
|
288
|
+
return failed([
|
|
289
|
+
locate(`Operation ${index} removes "${key}", which is not set on "${id}"`),
|
|
290
|
+
]);
|
|
291
|
+
}
|
|
292
|
+
source = removed;
|
|
293
|
+
}
|
|
144
294
|
if (operation.op === 'update-concept') {
|
|
145
295
|
counts.updatedConcepts += 1;
|
|
146
296
|
}
|
|
@@ -148,13 +298,10 @@ export function runApplyCommand(options, cwd) {
|
|
|
148
298
|
counts.updatedRelationships += 1;
|
|
149
299
|
}
|
|
150
300
|
}
|
|
301
|
+
candidates.set(absolute, source);
|
|
151
302
|
}
|
|
152
303
|
// The atomic gate: the whole candidate workspace must compile before a
|
|
153
304
|
// single byte is written; any diagnostic rejects the entire batch.
|
|
154
|
-
const candidates = new Map([...parsed.entries()].map(([absolute, document]) => [
|
|
155
|
-
absolute,
|
|
156
|
-
document.toString({ lineWidth: 0 }),
|
|
157
|
-
]));
|
|
158
305
|
const compilation = compileWorkspace([...workspace.profiles, ...workspace.documents].map((path) => {
|
|
159
306
|
const absolute = resolve(cwd, path);
|
|
160
307
|
return {
|
|
@@ -167,7 +314,7 @@ export function runApplyCommand(options, cwd) {
|
|
|
167
314
|
for (const [absolute, source] of candidates) {
|
|
168
315
|
writeFileSync(absolute, source, 'utf8');
|
|
169
316
|
}
|
|
170
|
-
const touched = [...
|
|
317
|
+
const touched = [...candidates.keys()]
|
|
171
318
|
.map((absolute) => workspaceDocuments.get(absolute))
|
|
172
319
|
.sort();
|
|
173
320
|
if (json) {
|
package/dist/design-command.js
CHANGED
|
@@ -56,6 +56,10 @@ const selectStep = (report, subjectFilter) => {
|
|
|
56
56
|
...(subjects.length > 1
|
|
57
57
|
? { remainingSubjects: subjects.length - 1 }
|
|
58
58
|
: {}),
|
|
59
|
+
// The full roster sharing this question (#116): when one policy
|
|
60
|
+
// answer covers many subjects, the harness can collect it once
|
|
61
|
+
// and land one apply batch instead of interviewing N times.
|
|
62
|
+
openSubjects: subjects.map(({ id }) => id),
|
|
59
63
|
};
|
|
60
64
|
}
|
|
61
65
|
}
|
package/package.json
CHANGED
|
@@ -264,6 +264,22 @@
|
|
|
264
264
|
"type": "object"
|
|
265
265
|
}
|
|
266
266
|
]
|
|
267
|
+
},
|
|
268
|
+
"remove": {
|
|
269
|
+
"type": "array",
|
|
270
|
+
"minItems": 1,
|
|
271
|
+
"items": {
|
|
272
|
+
"enum": [
|
|
273
|
+
"name",
|
|
274
|
+
"description",
|
|
275
|
+
"status",
|
|
276
|
+
"owner",
|
|
277
|
+
"constraints",
|
|
278
|
+
"references",
|
|
279
|
+
"presentIn",
|
|
280
|
+
"attestations"
|
|
281
|
+
]
|
|
282
|
+
}
|
|
267
283
|
}
|
|
268
284
|
}
|
|
269
285
|
},
|
|
@@ -294,6 +310,21 @@
|
|
|
294
310
|
"type": "object"
|
|
295
311
|
}
|
|
296
312
|
]
|
|
313
|
+
},
|
|
314
|
+
"remove": {
|
|
315
|
+
"type": "array",
|
|
316
|
+
"minItems": 1,
|
|
317
|
+
"items": {
|
|
318
|
+
"enum": [
|
|
319
|
+
"name",
|
|
320
|
+
"description",
|
|
321
|
+
"status",
|
|
322
|
+
"mode",
|
|
323
|
+
"content",
|
|
324
|
+
"references",
|
|
325
|
+
"presentIn"
|
|
326
|
+
]
|
|
327
|
+
}
|
|
297
328
|
}
|
|
298
329
|
}
|
|
299
330
|
}
|
|
@@ -129,7 +129,11 @@ yarramate design .yarramate/workspace.yaml
|
|
|
129
129
|
.yarramate/workspace.yaml`), then re-run `design` — the next question is
|
|
130
130
|
recomputed from the model, so the loop is resumable across sessions and
|
|
131
131
|
agents with no handover. Use `--subject <id>` to focus the interview on
|
|
132
|
-
one element.
|
|
132
|
+
one element. When a step reports many `openSubjects` sharing one
|
|
133
|
+
question (ownership is the classic case), do not interview N times:
|
|
134
|
+
collect the policy answer once — "who owns what, by area" — and land it
|
|
135
|
+
across every listed subject as one apply batch. The interview is
|
|
136
|
+
complete when `design` says so.
|
|
133
137
|
5. Create:
|
|
134
138
|
- an alternatives projection for the decision;
|
|
135
139
|
- a bounded target projection for implementation agents.
|