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.
@@ -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. An answer enriches what is
15
- // there it never silently shrinks it (removals stay Git edits).
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
- const appendBlockItem = (document, collection, item) => {
21
- document.addIn([collection], item);
22
- const sequence = document.getIn([collection], true);
23
- if (isSeq(sequence)) {
24
- sequence.flow = false;
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 findItem = (document, collection, id) => {
28
- const sequence = document.get(collection, true);
29
- if (!isSeq(sequence))
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
- return sequence.items.find((item) => typeof item === 'object' &&
32
- item !== null &&
33
- 'get' in item &&
34
- item.get('id') === id);
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
- const applyFields = (document, item, fields, scalars, lists) => {
37
- for (const key of scalars) {
38
- if (fields[key] !== undefined)
39
- item.set(key, fields[key]);
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
- for (const key of lists) {
42
- const additions = fields[key];
43
- if (additions === undefined || additions.length === 0)
44
- continue;
45
- const existing = item.get(key);
46
- if (existing === undefined) {
47
- item.set(key, additions);
48
- }
49
- else if (isSeq(existing)) {
50
- for (const entry of additions) {
51
- existing.items.push(document.createNode(entry));
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 parsed = new Map();
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 document = parsed.get(absolute);
116
- if (document === undefined) {
117
- document = parseDocument(readFileSync(absolute, 'utf8'));
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
- appendBlockItem(document, 'concepts', operation.concept);
242
+ source = appendCollectionItem(source, 'concepts', operation.concept);
122
243
  counts.addedConcepts += 1;
123
244
  }
124
245
  else if (operation.op === 'add-relationship') {
125
- appendBlockItem(document, 'relationships', operation.relationship);
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 item = findItem(document, collection, payload.id);
134
- if (item === undefined) {
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} updates "${payload.id}", which does not exist in ${operation.document}`),
265
+ locate(`Operation ${index} both sets and removes "${contradiction}" on "${id}"`),
137
266
  ]);
138
267
  }
139
- applyFields(document, item, payload, operation.op === 'update-concept'
140
- ? SCALAR_CONCEPT_FIELDS
141
- : SCALAR_RELATIONSHIP_FIELDS, operation.op === 'update-concept'
142
- ? LIST_CONCEPT_FIELDS
143
- : LIST_RELATIONSHIP_FIELDS);
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 = [...parsed.keys()]
317
+ const touched = [...candidates.keys()]
171
318
  .map((absolute) => workspaceDocuments.get(absolute))
172
319
  .sort();
173
320
  if (json) {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yarramate",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Tool-neutral semantic architecture engine and guided methodology",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -139,6 +139,14 @@
139
139
  "remainingSubjects": {
140
140
  "type": "integer",
141
141
  "minimum": 1
142
+ },
143
+ "openSubjects": {
144
+ "type": "array",
145
+ "minItems": 1,
146
+ "items": {
147
+ "type": "string",
148
+ "minLength": 1
149
+ }
142
150
  }
143
151
  }
144
152
  }
@@ -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. The interview is complete when `design` says so.
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.