archgraph-argo 0.21.0 → 0.22.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.
|
@@ -24,6 +24,7 @@ Non-negotiable red lines (MUST). Never skip, simplify, or silently violate them;
|
|
|
24
24
|
8. Store content KG-first. See `<ContentStoragePolicy>`.
|
|
25
25
|
9. Never duplicate: reuse is the default; a create blocked as an exact or semantic duplicate must be reused, or explicitly overridden with `onConflict: "allowDuplicate"` + justification. See `<GraphDeduplication>`.
|
|
26
26
|
10. Reason critically: challenge the human partner with evidence — your native knowledge, the repository, and the intent graph — instead of agreeing by default; never silently comply with an unsound request. See `<CriticalReasoningGuideline>`.
|
|
27
|
+
11. Never lose content silently: writes that reduce existing content must be lossless (merge/delta) or explicitly acknowledged (`acknowledgeLoss`), and destructive removals are tombstoned. See `<LosslessWrite>`.
|
|
27
28
|
</CoreRules>
|
|
28
29
|
|
|
29
30
|
<Ontology>
|
|
@@ -72,6 +73,14 @@ Never add what the graph already has. Reuse is the default; there is no reject m
|
|
|
72
73
|
5. Updates are never gated. Never work around a duplicate by editing around it — reuse or update the existing object.
|
|
73
74
|
</GraphDeduplication>
|
|
74
75
|
|
|
76
|
+
<LosslessWrite>
|
|
77
|
+
Never lose stored content silently. A write must never reduce existing content unless it is lossless (merge/delta) or you explicitly acknowledge the reduction; omission never means deletion.
|
|
78
|
+
1. Structured fields merge: element testcases merge by `name`; view membership accepts a delta `{ add, remove }`; relationship attributes merge by `name`. Omitting an existing entry preserves it. Delete only with an explicit `op: "remove"` or an explicit delta remove.
|
|
79
|
+
2. Scalar text is guarded: `description`, `statement`, `document`, `name`, `view_name` are full-value fields. A reworded line is not a loss; dropping prior lines, or genuinely losing structured tokens (commit hashes, ids, versions, dates, paths — separator/space changes do NOT count), is blocked unless you pass `acknowledgeLoss: true` (and `lossJustification` when the loss is major). Read the current value first and edit as a minimal diff.
|
|
80
|
+
3. Destructive removals (`removeElement` / `removeRelationship` / `removeView`) require `acknowledgeLoss: true` — per mutation, or once for the whole set via the top-level `acknowledgeLoss` on `applySystemArchitectureMutation`; the full removed object is appended to the NDJSON tombstone ledger `design/KG/SystemArchitecture.tombstones.ndjson` (rotated by size) for recovery.
|
|
81
|
+
4. Always read the `lossless` loss report in the response (preview and apply). It lists removed text lines / testcases / members / objects. If it is blocked, fix the mutation—do not retry blindly and do not disable the gate.
|
|
82
|
+
</LosslessWrite>
|
|
83
|
+
|
|
75
84
|
<IntentArchitectureFirst>
|
|
76
85
|
1. Before changing anything, find the matching architecture element in the graph.
|
|
77
86
|
2. If it is missing, pick a View and create a reasonable element in it.
|
|
@@ -291,6 +291,29 @@ for (const tool of TOOLS) {
|
|
|
291
291
|
}
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
// Lossless write gate acknowledgement params for the update/remove helpers
|
|
295
|
+
// (see lossless-write-gate.js). add* is purely additive and left alone.
|
|
296
|
+
const LOSS_ACK_INPUT_PROPS = Object.freeze({
|
|
297
|
+
acknowledgeLoss: {
|
|
298
|
+
type: 'boolean',
|
|
299
|
+
description: 'Set true to confirm an intentional content reduction: a text rewrite that drops prior segments, or a destructive removal. Without it the lossless write gate blocks the write and returns the exact removed content.',
|
|
300
|
+
},
|
|
301
|
+
lossJustification: {
|
|
302
|
+
type: 'string',
|
|
303
|
+
description: 'Required for MAJOR text loss (large removedChars or most of the value): the reason the prior content is being intentionally replaced.',
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
for (const tool of TOOLS) {
|
|
307
|
+
if (tool && tool.name && /^(update|remove)Architecture/.test(tool.name)
|
|
308
|
+
&& tool.inputSchema && tool.inputSchema.properties) {
|
|
309
|
+
for (const [key, value] of Object.entries(LOSS_ACK_INPUT_PROPS)) {
|
|
310
|
+
if (!Object.prototype.hasOwnProperty.call(tool.inputSchema.properties, key)) {
|
|
311
|
+
tool.inputSchema.properties[key] = value;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
294
317
|
function intentElementContextInputSchema() {
|
|
295
318
|
return {
|
|
296
319
|
type: 'object',
|
|
@@ -318,6 +341,8 @@ function mutationInputSchema() {
|
|
|
318
341
|
required: ['mutations'],
|
|
319
342
|
properties: {
|
|
320
343
|
architecturePath: { type: 'string', description: 'Default: design/KG/SystemArchitecture.json' },
|
|
344
|
+
acknowledgeLoss: { type: 'boolean', description: 'Batch-level loss acknowledgement: set true to confirm every intentional content reduction in this mutation set (a text rewrite that drops prior segments, or destructive removals). Counts for all mutations — one confirmation for a whole batch.' },
|
|
345
|
+
lossJustification: { type: 'string', description: 'Batch-level justification for MAJOR text loss in this mutation set.' },
|
|
321
346
|
mutations: {
|
|
322
347
|
type: 'array',
|
|
323
348
|
minItems: 1,
|
|
@@ -348,6 +373,8 @@ function mutationInputSchema() {
|
|
|
348
373
|
view_ids: { type: 'array', minItems: 1, items: { type: 'string' } },
|
|
349
374
|
element_ids: { type: 'array', items: { type: 'string' } },
|
|
350
375
|
relationship_ids: { type: 'array', items: { type: 'string' } },
|
|
376
|
+
acknowledgeLoss: { type: 'boolean', description: 'Set true to confirm an intentional content reduction: a text rewrite that drops prior segments, or a destructive removal. Without it the lossless write gate blocks the write and returns the exact removed content.' },
|
|
377
|
+
lossJustification: { type: 'string', description: 'Required for MAJOR text loss (large removedChars or most of the value): the reason the prior content is being intentionally replaced.' },
|
|
351
378
|
},
|
|
352
379
|
additionalProperties: false,
|
|
353
380
|
},
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lossless Write Gate (无损写入门禁), framework-level.
|
|
5
|
+
*
|
|
6
|
+
* Invariant: NO SILENT LOSS. A write must never reduce stored content unless the
|
|
7
|
+
* caller explicitly acknowledges it (and, for major loss, justifies it). Two
|
|
8
|
+
* mechanisms make this possible:
|
|
9
|
+
*
|
|
10
|
+
* G1 structured fields MERGE (omission never means deletion): updateElement
|
|
11
|
+
* .testcases (by name), updateRelationship .attributes (by name),
|
|
12
|
+
* updateView membership (delta {add,remove}); explicit op:'remove' deletes.
|
|
13
|
+
* G2 scalar text GUARDED: description / statement / document / name / view_name
|
|
14
|
+
* are full-value fields; a value that drops prior segments is blocked unless
|
|
15
|
+
* the mutation carries acknowledgeLoss:true (and lossJustification for major
|
|
16
|
+
* loss). Pure additions/expansions are always allowed.
|
|
17
|
+
* G3 destructive removals ACKNOWLEDGED + RECOVERABLE: removeElement /
|
|
18
|
+
* removeRelationship / removeView require acknowledgeLoss:true and snapshot
|
|
19
|
+
* the full removed object into a tombstone ledger (nothing is truly lost).
|
|
20
|
+
* G5 every response carries a loss report (text/testcases/members/objects +
|
|
21
|
+
* the removed samples), on preview as well as apply.
|
|
22
|
+
*
|
|
23
|
+
* The gate is enforced at the single funnel buildMutationResult (apply +
|
|
24
|
+
* preview + all single-object helpers), so every MCP write interface is covered.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
|
|
30
|
+
const LOSS_ACK_FIELD = 'acknowledgeLoss';
|
|
31
|
+
const LOSS_JUSTIFICATION_FIELD = 'lossJustification';
|
|
32
|
+
|
|
33
|
+
// A text loss is "major" (justification, not just acknowledgement, required)
|
|
34
|
+
// when it removes this many characters or this share of the original text.
|
|
35
|
+
const MAJOR_REMOVED_CHARS = 200;
|
|
36
|
+
const MAJOR_REMOVED_RATIO = 0.5;
|
|
37
|
+
|
|
38
|
+
// A changed segment is a "modification" (not a loss) when its token overlap with
|
|
39
|
+
// some new segment is at least this high — "reworded, not dropped".
|
|
40
|
+
const MODIFICATION_SIMILARITY = 0.5;
|
|
41
|
+
|
|
42
|
+
// Tokens that carry structured identity (ids, numbers, hashes, paths): if one is
|
|
43
|
+
// gone it is always a loss, even when the surrounding sentence was only reworded.
|
|
44
|
+
const TOKEN_RE = /[A-Za-z0-9_@#./\\:-]+|[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/g;
|
|
45
|
+
|
|
46
|
+
// Full-value text fields per mutation type. Editing any of these can silently
|
|
47
|
+
// drop prior content, so they are loss-guarded.
|
|
48
|
+
const TEXT_FIELDS_BY_MUTATION = Object.freeze({
|
|
49
|
+
updateElement: Object.freeze(['description', 'name']),
|
|
50
|
+
updateRelationship: Object.freeze(['statement', 'name', 'description', 'document']),
|
|
51
|
+
updateView: Object.freeze(['view_name', 'description']),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function normalizeForCompare(value) {
|
|
55
|
+
return String(value === undefined || value === null ? '' : value)
|
|
56
|
+
.normalize('NFKC')
|
|
57
|
+
.replace(/\s+/g, ' ')
|
|
58
|
+
.trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeSegments(text) {
|
|
62
|
+
return String(text === undefined || text === null ? '' : text)
|
|
63
|
+
.split(/\r?\n/)
|
|
64
|
+
.map(normalizeForCompare)
|
|
65
|
+
.filter(segment => segment.length > 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function tokenize(segment) {
|
|
69
|
+
const normalized = String(segment === undefined || segment === null ? '' : segment).normalize('NFKC');
|
|
70
|
+
const matches = normalized.match(TOKEN_RE);
|
|
71
|
+
return matches ? matches.map(token => token.toLowerCase()) : [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A "structured identity" token whose silent loss matters: commit hashes, ids,
|
|
75
|
+
// versions, dates, path-like strings. Bare separators ("/") and short plain
|
|
76
|
+
// numbers are NOT structured — treating them so made benign edits false-block.
|
|
77
|
+
function isStructuredToken(token) {
|
|
78
|
+
if (!token) return false;
|
|
79
|
+
if (/^[0-9a-f]{7,40}$/i.test(token)) return true; // commit hash / long hex
|
|
80
|
+
if (/[/\\]/.test(token) && /[a-z]/i.test(token)) return true; // path-like (has a letter)
|
|
81
|
+
if (/[a-z]/i.test(token) && /\d/.test(token) && /[-_]/.test(token)) return true; // id-like AT-rules-04
|
|
82
|
+
if (/^[a-z]*\d+(?:\.\d+)+$/i.test(token)) return true; // version-like stix2.1 / v2.1 / 1.2.3
|
|
83
|
+
if (/^\d{4}-\d{2}(?:-\d{2})?$/.test(token)) return true; // date-like 2026-09-15
|
|
84
|
+
if (/^\d{5,}$/.test(token)) return true; // long numeric id
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// NFKC + strip all whitespace + lowercase: tolerant haystack for the "is this
|
|
89
|
+
// structured token still present anywhere in the new text?" substring check.
|
|
90
|
+
// This drops false positives from separator joins (769465c:bc7f418) and spacing
|
|
91
|
+
// (STIX2.1 -> STIX 2.1) while still catching a genuinely removed token.
|
|
92
|
+
function normalizeForTokenSearch(value) {
|
|
93
|
+
return String(value === undefined || value === null ? '' : value).normalize('NFKC').replace(/\s+/g, '').toLowerCase();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Separator-insensitive form: also drop the punctuation that joins structured
|
|
97
|
+
// tokens, so "A/B" -> "A、B" or "v2.1" -> "v2 1" is still recognised as the same
|
|
98
|
+
// token (same content, punctuation changed) and not mistaken for a loss.
|
|
99
|
+
function comparableTokenForm(value) {
|
|
100
|
+
return normalizeForTokenSearch(value).replace(/[/\\:.\-_]/g, '');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Sørensen–Dice coefficient over token sets (deterministic, no network).
|
|
104
|
+
function diceCoefficient(aTokens, bTokens) {
|
|
105
|
+
if (aTokens.length === 0 && bTokens.length === 0) return 1;
|
|
106
|
+
if (aTokens.length === 0 || bTokens.length === 0) return 0;
|
|
107
|
+
const a = new Set(aTokens);
|
|
108
|
+
const b = new Set(bTokens);
|
|
109
|
+
let intersection = 0;
|
|
110
|
+
for (const token of a) if (b.has(token)) intersection += 1;
|
|
111
|
+
return (2 * intersection) / (a.size + b.size);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Deterministic loss detector. A changed segment is classified as a MODIFICATION
|
|
115
|
+
// (kept, just reworded) when it is similar enough to some new segment; otherwise
|
|
116
|
+
// it is a LOSS (dropped or substantially rewritten). Structured tokens (ids,
|
|
117
|
+
// numbers, hashes, paths) that disappear are always a loss. Pure additions and
|
|
118
|
+
// reorderings report nothing.
|
|
119
|
+
function detectTextLoss(oldText, newText) {
|
|
120
|
+
const oldSegments = normalizeSegments(oldText);
|
|
121
|
+
if (oldSegments.length === 0) {
|
|
122
|
+
return { removedSegments: [], modifiedSegments: [], removedChars: 0, oldChars: 0, ratio: 0, structuredTokensRemoved: [] };
|
|
123
|
+
}
|
|
124
|
+
const newSegments = normalizeSegments(newText);
|
|
125
|
+
const newNormalized = normalizeForCompare(newText);
|
|
126
|
+
const newHaystack = comparableTokenForm(newText);
|
|
127
|
+
const newSegmentTokens = newSegments.map(tokenize);
|
|
128
|
+
const removedSegments = [];
|
|
129
|
+
const modifiedSegments = [];
|
|
130
|
+
const structuredTokensRemoved = [];
|
|
131
|
+
for (const segment of oldSegments) {
|
|
132
|
+
const segmentTokens = tokenize(segment);
|
|
133
|
+
for (const token of segmentTokens) {
|
|
134
|
+
if (isStructuredToken(token) && !newHaystack.includes(comparableTokenForm(token))) structuredTokensRemoved.push(token);
|
|
135
|
+
}
|
|
136
|
+
if (newNormalized.includes(segment)) continue; // kept verbatim
|
|
137
|
+
const best = newSegmentTokens.reduce((max, tokens) => Math.max(max, diceCoefficient(segmentTokens, tokens)), 0);
|
|
138
|
+
if (best >= MODIFICATION_SIMILARITY) modifiedSegments.push(segment);
|
|
139
|
+
else removedSegments.push(segment);
|
|
140
|
+
}
|
|
141
|
+
const removedChars = removedSegments.reduce((sum, segment) => sum + segment.length, 0);
|
|
142
|
+
const oldChars = oldSegments.reduce((sum, segment) => sum + segment.length, 0);
|
|
143
|
+
return {
|
|
144
|
+
removedSegments,
|
|
145
|
+
modifiedSegments,
|
|
146
|
+
removedChars,
|
|
147
|
+
oldChars,
|
|
148
|
+
ratio: oldChars > 0 ? removedChars / oldChars : 0,
|
|
149
|
+
structuredTokensRemoved: [...new Set(structuredTokensRemoved)],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// G1: merge testcases by their stable key (name). Untouched testcases survive;
|
|
154
|
+
// only an explicit {name, op:'remove'} deletes one.
|
|
155
|
+
function mergeTestcasesPatch(existing, patchEntries) {
|
|
156
|
+
if (!Array.isArray(patchEntries)) {
|
|
157
|
+
throw new Error('patch.testcases must be an array of { name, ... } entries');
|
|
158
|
+
}
|
|
159
|
+
const result = Array.isArray(existing) ? existing.map(entry => ({ ...entry })) : [];
|
|
160
|
+
for (const entry of patchEntries) {
|
|
161
|
+
if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string' || entry.name.trim() === '') {
|
|
162
|
+
throw new Error('patch.testcases entries must have a non-empty string name (the stable merge key)');
|
|
163
|
+
}
|
|
164
|
+
const index = result.findIndex(existingEntry => existingEntry.name === entry.name);
|
|
165
|
+
if (entry.op === 'remove') {
|
|
166
|
+
if (index >= 0) result.splice(index, 1);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const next = { ...entry };
|
|
170
|
+
delete next.op;
|
|
171
|
+
if (index >= 0) result[index] = next;
|
|
172
|
+
else result.push(next);
|
|
173
|
+
}
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// G1: merge relationship attributes by name ({ name, description }). Unmentioned
|
|
178
|
+
// attributes survive; op:'remove' deletes by name.
|
|
179
|
+
function mergeRelationshipAttributesPatch(existing, patchEntries) {
|
|
180
|
+
if (!Array.isArray(patchEntries)) {
|
|
181
|
+
throw new Error('patch.attributes must be an array of { name, ... } entries');
|
|
182
|
+
}
|
|
183
|
+
const result = Array.isArray(existing) ? existing.map(entry => ({ ...entry })) : [];
|
|
184
|
+
for (const entry of patchEntries) {
|
|
185
|
+
if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string' || entry.name.trim() === '') {
|
|
186
|
+
throw new Error('patch.attributes entries must have a non-empty string name');
|
|
187
|
+
}
|
|
188
|
+
const index = result.findIndex(attr => attr.name === entry.name);
|
|
189
|
+
if (entry.op === 'remove') {
|
|
190
|
+
if (index >= 0) result.splice(index, 1);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const next = { name: entry.name };
|
|
194
|
+
for (const field of ['value', 'description', 'content']) {
|
|
195
|
+
if (Object.prototype.hasOwnProperty.call(entry, field)) next[field] = entry[field];
|
|
196
|
+
}
|
|
197
|
+
if (index >= 0) result[index] = next;
|
|
198
|
+
else result.push(next);
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// G1: apply a view membership patch as either a full list (legacy replace) or a
|
|
204
|
+
// lossless delta { add, remove }. Returns the resulting list.
|
|
205
|
+
function applyViewMembershipPatch(current, patchValue) {
|
|
206
|
+
const base = Array.isArray(current) ? current : [];
|
|
207
|
+
if (Array.isArray(patchValue)) {
|
|
208
|
+
return { list: dedupe(patchValue), explicitRemove: null };
|
|
209
|
+
}
|
|
210
|
+
if (patchValue && typeof patchValue === 'object') {
|
|
211
|
+
const remove = Array.isArray(patchValue.remove) ? patchValue.remove : [];
|
|
212
|
+
const add = Array.isArray(patchValue.add) ? patchValue.add : [];
|
|
213
|
+
const kept = base.filter(id => !remove.includes(id));
|
|
214
|
+
return { list: dedupe([...kept, ...add]), explicitRemove: remove };
|
|
215
|
+
}
|
|
216
|
+
throw new Error('view membership patch must be an array or { add, remove }');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function dedupe(entries) {
|
|
220
|
+
const seen = new Set();
|
|
221
|
+
const result = [];
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
if (seen.has(entry)) continue;
|
|
224
|
+
seen.add(entry);
|
|
225
|
+
result.push(entry);
|
|
226
|
+
}
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function findById(entries, id) {
|
|
231
|
+
return Array.isArray(entries) ? entries.find(entry => entry && entry.id === id) : undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function findView(views, viewId) {
|
|
235
|
+
return Array.isArray(views) ? views.find(view => view && view.view_id === viewId) : undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isAcknowledged(mutation, lossAck) {
|
|
239
|
+
return (Boolean(mutation) && mutation[LOSS_ACK_FIELD] === true)
|
|
240
|
+
|| (Boolean(lossAck) && lossAck[LOSS_ACK_FIELD] === true);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function hasJustification(mutation, lossAck) {
|
|
244
|
+
const text = (mutation && mutation[LOSS_JUSTIFICATION_FIELD])
|
|
245
|
+
|| (lossAck && lossAck[LOSS_JUSTIFICATION_FIELD]);
|
|
246
|
+
return typeof text === 'string' && text.trim() !== '';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// G5+enforcement: compute every content reduction a mutation set would cause,
|
|
250
|
+
// with per-item acknowledgement state, and whether the set must be blocked.
|
|
251
|
+
// `lossAck` is an optional batch-level acknowledgement (applySystemArchitectureMutation
|
|
252
|
+
// top level) that counts for every mutation in the set.
|
|
253
|
+
function buildLossReport({ baseDocument, mutations, nextDocument, lossAck }) {
|
|
254
|
+
const base = baseDocument || {};
|
|
255
|
+
const next = nextDocument || {};
|
|
256
|
+
const report = {
|
|
257
|
+
blocked: false,
|
|
258
|
+
acknowledged: true,
|
|
259
|
+
reasons: [],
|
|
260
|
+
text: [],
|
|
261
|
+
modifications: [],
|
|
262
|
+
membersRemoved: [],
|
|
263
|
+
testcasesRemoved: [],
|
|
264
|
+
attributesRemoved: [],
|
|
265
|
+
objectsRemoved: [],
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const pushReason = (reason) => {
|
|
269
|
+
report.blocked = true;
|
|
270
|
+
report.acknowledged = false;
|
|
271
|
+
if (!report.reasons.includes(reason)) report.reasons.push(reason);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const guardText = (mutation, kind, id, field, oldValue, newValue) => {
|
|
275
|
+
const loss = detectTextLoss(oldValue, newValue);
|
|
276
|
+
if (loss.modifiedSegments.length > 0) {
|
|
277
|
+
// Reworded, not dropped: reported for transparency, never blocked.
|
|
278
|
+
report.modifications.push({ kind, id, field, modifiedLines: loss.modifiedSegments.length, sample: loss.modifiedSegments.slice(0, 3) });
|
|
279
|
+
}
|
|
280
|
+
const hasLoss = loss.removedSegments.length > 0 || loss.structuredTokensRemoved.length > 0;
|
|
281
|
+
if (!hasLoss) return;
|
|
282
|
+
// "Major" only when the removed volume is substantial; a large share of a
|
|
283
|
+
// tiny value is not major (avoids forcing justification for short edits).
|
|
284
|
+
const major = loss.removedChars > MAJOR_REMOVED_CHARS
|
|
285
|
+
|| (loss.oldChars > MAJOR_REMOVED_CHARS && loss.ratio > MAJOR_REMOVED_RATIO);
|
|
286
|
+
const acknowledged = isAcknowledged(mutation, lossAck) && (!major || hasJustification(mutation, lossAck));
|
|
287
|
+
const item = {
|
|
288
|
+
kind,
|
|
289
|
+
id,
|
|
290
|
+
field,
|
|
291
|
+
removedChars: loss.removedChars,
|
|
292
|
+
removedLines: loss.removedSegments.length,
|
|
293
|
+
structuredTokensRemoved: loss.structuredTokensRemoved,
|
|
294
|
+
ratio: Number(loss.ratio.toFixed(3)),
|
|
295
|
+
major,
|
|
296
|
+
acknowledged,
|
|
297
|
+
removedSample: loss.removedSegments.slice(0, 3),
|
|
298
|
+
};
|
|
299
|
+
report.text.push(item);
|
|
300
|
+
if (!acknowledged) {
|
|
301
|
+
const why = major && !hasJustification(mutation, lossAck)
|
|
302
|
+
? ' (major loss requires a non-empty lossJustification)'
|
|
303
|
+
: '';
|
|
304
|
+
const structured = loss.structuredTokensRemoved.length > 0
|
|
305
|
+
? ` [structured tokens removed: ${loss.structuredTokensRemoved.slice(0, 5).join(', ')}]`
|
|
306
|
+
: '';
|
|
307
|
+
pushReason(
|
|
308
|
+
`Unacknowledged text loss: ${kind} '${id}' field '${field}' would remove ${loss.removedChars} char(s) / ${loss.removedSegments.length} line(s)${structured}${why}. `
|
|
309
|
+
+ 'Read the current value first, then pass acknowledgeLoss:true (and lossJustification) to confirm an intentional rewrite, or use an additive edit that keeps the prior content.',
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
for (const mutation of mutations || []) {
|
|
315
|
+
if (mutation.type === 'updateElement' || mutation.type === 'updateRelationship') {
|
|
316
|
+
const collection = mutation.type === 'updateElement' ? 'elements' : 'relationships';
|
|
317
|
+
const kind = mutation.type === 'updateElement' ? 'element' : 'relationship';
|
|
318
|
+
const baseEntry = findById(base[collection], mutation.id);
|
|
319
|
+
const nextEntry = findById(next[collection], mutation.id);
|
|
320
|
+
if (!baseEntry || !nextEntry) continue;
|
|
321
|
+
for (const field of TEXT_FIELDS_BY_MUTATION[mutation.type]) {
|
|
322
|
+
guardText(mutation, kind, mutation.id, field, baseEntry[field], nextEntry[field]);
|
|
323
|
+
}
|
|
324
|
+
if (mutation.type === 'updateElement' && Array.isArray(baseEntry.testcases)) {
|
|
325
|
+
const nextNames = new Set((nextEntry.testcases || []).map(tc => tc && tc.name));
|
|
326
|
+
for (const tc of baseEntry.testcases) {
|
|
327
|
+
if (tc && tc.name && !nextNames.has(tc.name)) {
|
|
328
|
+
report.testcasesRemoved.push({ id: mutation.id, name: tc.name });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
} else if (mutation.type === 'updateView') {
|
|
333
|
+
const viewId = mutation.view_id || mutation.id;
|
|
334
|
+
const baseView = findView(base.views, viewId);
|
|
335
|
+
const nextView = findView(next.views, viewId);
|
|
336
|
+
if (!baseView || !nextView) continue;
|
|
337
|
+
for (const field of TEXT_FIELDS_BY_MUTATION.updateView) {
|
|
338
|
+
guardText(mutation, 'view', viewId, field, baseView[field], nextView[field]);
|
|
339
|
+
}
|
|
340
|
+
const baseElements = new Set(baseView.included_elements || []);
|
|
341
|
+
const removedElements = [...baseElements].filter(id => !(nextView.included_elements || []).includes(id));
|
|
342
|
+
const baseRelationships = new Set(baseView.included_relationships || []);
|
|
343
|
+
const removedRelationships = [...baseRelationships].filter(id => !(nextView.included_relationships || []).includes(id));
|
|
344
|
+
if (removedElements.length > 0 || removedRelationships.length > 0) {
|
|
345
|
+
// A channel with no removals is trivially covered; a channel with removals
|
|
346
|
+
// is explicit only when patched via the delta { remove } form.
|
|
347
|
+
const elementsExplicit = removedElements.length === 0
|
|
348
|
+
|| isExplicitRemovePatch(mutation.patch, 'included_elements');
|
|
349
|
+
const relationshipsExplicit = removedRelationships.length === 0
|
|
350
|
+
|| isExplicitRemovePatch(mutation.patch, 'included_relationships');
|
|
351
|
+
const explicit = elementsExplicit && relationshipsExplicit;
|
|
352
|
+
const acknowledged = explicit || isAcknowledged(mutation, lossAck);
|
|
353
|
+
report.membersRemoved.push({
|
|
354
|
+
view_id: viewId,
|
|
355
|
+
elements: removedElements,
|
|
356
|
+
relationships: removedRelationships,
|
|
357
|
+
explicit,
|
|
358
|
+
acknowledged,
|
|
359
|
+
});
|
|
360
|
+
if (!acknowledged) {
|
|
361
|
+
pushReason(
|
|
362
|
+
`Unacknowledged membership removal in view '${viewId}': ${removedElements.length} element(s) and ${removedRelationships.length} relationship(s) would be dropped. `
|
|
363
|
+
+ 'Use the delta form included_elements:{remove:[...]} for explicit removal, or pass acknowledgeLoss:true.',
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
} else if (mutation.type === 'removeElement' || mutation.type === 'removeRelationship' || mutation.type === 'removeView') {
|
|
368
|
+
const spec = {
|
|
369
|
+
removeElement: { collection: 'elements', id: mutation.id },
|
|
370
|
+
removeRelationship: { collection: 'relationships', id: mutation.id },
|
|
371
|
+
removeView: { collection: 'views', id: mutation.view_id },
|
|
372
|
+
}[mutation.type];
|
|
373
|
+
const id = spec.id;
|
|
374
|
+
const baseEntry = mutation.type === 'removeView'
|
|
375
|
+
? findView(base.views, id)
|
|
376
|
+
: findById(base[spec.collection], id);
|
|
377
|
+
const stillPresent = mutation.type === 'removeView'
|
|
378
|
+
? Boolean(findView(next.views, id))
|
|
379
|
+
: Boolean(findById(next[spec.collection], id));
|
|
380
|
+
if (!baseEntry || stillPresent) continue;
|
|
381
|
+
const acknowledged = isAcknowledged(mutation, lossAck);
|
|
382
|
+
report.objectsRemoved.push({
|
|
383
|
+
kind: mutation.type.replace(/^remove/, '').toLowerCase(),
|
|
384
|
+
id,
|
|
385
|
+
name: baseEntry.name || baseEntry.view_name,
|
|
386
|
+
acknowledged,
|
|
387
|
+
});
|
|
388
|
+
if (!acknowledged) {
|
|
389
|
+
pushReason(
|
|
390
|
+
`Unacknowledged destructive removal: ${mutation.type} '${id}' ('${baseEntry.name || baseEntry.view_name}') deletes the object permanently. `
|
|
391
|
+
+ 'Pass acknowledgeLoss:true (a full tombstone snapshot is recorded for recovery).',
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return report;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function isExplicitRemovePatch(patch, field) {
|
|
401
|
+
if (!patch || !Object.prototype.hasOwnProperty.call(patch, field)) return false;
|
|
402
|
+
const value = patch[field];
|
|
403
|
+
return Boolean(value) && !Array.isArray(value) && typeof value === 'object' && Array.isArray(value.remove);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// G3: append full removed objects to an NDJSON tombstone ledger. Each append is a
|
|
407
|
+
// single O(1) line write (no read/rewrite of prior content). When the active file
|
|
408
|
+
// exceeds the size cap (~5k entries) it is rotated aside (rename, O(1)) so the
|
|
409
|
+
// active file and its git diff stay bounded.
|
|
410
|
+
const TOMBSTONE_ACTIVE_BASENAME = 'SystemArchitecture.tombstones.ndjson';
|
|
411
|
+
const TOMBSTONE_ROTATE_BYTES = 2 * 1024 * 1024;
|
|
412
|
+
|
|
413
|
+
function tombstoneFilePath(graphAbsolutePath) {
|
|
414
|
+
return path.join(path.dirname(graphAbsolutePath), TOMBSTONE_ACTIVE_BASENAME);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function appendTombstones(graphAbsolutePath, entries, options = {}) {
|
|
418
|
+
const file = tombstoneFilePath(graphAbsolutePath);
|
|
419
|
+
const rotateBytes = Number.isFinite(options.rotateBytes) ? options.rotateBytes : TOMBSTONE_ROTATE_BYTES;
|
|
420
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
421
|
+
if (list.length === 0) return { path: file, count: 0, rotatedTo: null };
|
|
422
|
+
let rotatedTo = null;
|
|
423
|
+
try {
|
|
424
|
+
if (rotateBytes > 0 && fs.statSync(file).size >= rotateBytes) {
|
|
425
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
426
|
+
rotatedTo = path.join(path.dirname(file), `SystemArchitecture.tombstones.${stamp}.ndjson`);
|
|
427
|
+
fs.renameSync(file, rotatedTo);
|
|
428
|
+
}
|
|
429
|
+
} catch {
|
|
430
|
+
// No active file yet (first append) — nothing to rotate.
|
|
431
|
+
}
|
|
432
|
+
const at = new Date().toISOString();
|
|
433
|
+
const lines = list.map(entry => JSON.stringify({
|
|
434
|
+
at,
|
|
435
|
+
op: entry.op || ('remove' + entry.kind),
|
|
436
|
+
kind: entry.kind,
|
|
437
|
+
id: entry.id,
|
|
438
|
+
object: entry.object,
|
|
439
|
+
}));
|
|
440
|
+
fs.appendFileSync(file, `${lines.join('\n')}\n`, 'utf8');
|
|
441
|
+
return { path: file, count: list.length, rotatedTo };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Read back the NDJSON tombstone ledger (recovery / audit). Malformed lines are
|
|
445
|
+
// skipped rather than throwing, so a partially written tail never blocks reads.
|
|
446
|
+
function readTombstones(file) {
|
|
447
|
+
try {
|
|
448
|
+
return fs.readFileSync(file, 'utf8')
|
|
449
|
+
.split(/\r?\n/)
|
|
450
|
+
.filter(line => line.trim() !== '')
|
|
451
|
+
.map(line => {
|
|
452
|
+
try {
|
|
453
|
+
return JSON.parse(line);
|
|
454
|
+
} catch {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
})
|
|
458
|
+
.filter(Boolean);
|
|
459
|
+
} catch {
|
|
460
|
+
return [];
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Collect the full base objects for every global removal in a mutation set.
|
|
465
|
+
function collectRemovedObjects(baseDocument, mutations, nextDocument) {
|
|
466
|
+
const removed = [];
|
|
467
|
+
for (const mutation of mutations || []) {
|
|
468
|
+
if (mutation.type === 'removeElement') {
|
|
469
|
+
const obj = findById((baseDocument || {}).elements, mutation.id);
|
|
470
|
+
if (obj && !findById((nextDocument || {}).elements, mutation.id)) removed.push({ op: mutation.type, kind: 'element', id: mutation.id, object: obj });
|
|
471
|
+
} else if (mutation.type === 'removeRelationship') {
|
|
472
|
+
const obj = findById((baseDocument || {}).relationships, mutation.id);
|
|
473
|
+
if (obj && !findById((nextDocument || {}).relationships, mutation.id)) removed.push({ op: mutation.type, kind: 'relationship', id: mutation.id, object: obj });
|
|
474
|
+
} else if (mutation.type === 'removeView') {
|
|
475
|
+
const obj = findView((baseDocument || {}).views, mutation.view_id);
|
|
476
|
+
if (obj && !findView((nextDocument || {}).views, mutation.view_id)) removed.push({ op: mutation.type, kind: 'view', id: mutation.view_id, object: obj });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return removed;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
module.exports = {
|
|
483
|
+
LOSS_ACK_FIELD,
|
|
484
|
+
LOSS_JUSTIFICATION_FIELD,
|
|
485
|
+
MAJOR_REMOVED_CHARS,
|
|
486
|
+
MAJOR_REMOVED_RATIO,
|
|
487
|
+
TEXT_FIELDS_BY_MUTATION,
|
|
488
|
+
normalizeSegments,
|
|
489
|
+
detectTextLoss,
|
|
490
|
+
mergeTestcasesPatch,
|
|
491
|
+
mergeRelationshipAttributesPatch,
|
|
492
|
+
applyViewMembershipPatch,
|
|
493
|
+
buildLossReport,
|
|
494
|
+
appendTombstones,
|
|
495
|
+
readTombstones,
|
|
496
|
+
tombstoneFilePath,
|
|
497
|
+
TOMBSTONE_ACTIVE_BASENAME,
|
|
498
|
+
TOMBSTONE_ROTATE_BYTES,
|
|
499
|
+
collectRemovedObjects,
|
|
500
|
+
};
|
|
@@ -167,6 +167,8 @@ const {
|
|
|
167
167
|
verifyArchitectureSync,
|
|
168
168
|
} = require('./neo4j-system-architecture-store.js');
|
|
169
169
|
|
|
170
|
+
const losslessWriteGate = require('./lossless-write-gate.js');
|
|
171
|
+
|
|
170
172
|
const HANDLED_MUTATION_TYPES = new Set([
|
|
171
173
|
'addElement',
|
|
172
174
|
'updateElement',
|
|
@@ -413,11 +415,32 @@ const WORKSPACE_ROOT_PARAM = Object.freeze({
|
|
|
413
415
|
description:
|
|
414
416
|
'Optional absolute workspace root for this call. When provided it is used as-is; otherwise the server launch directory is used.',
|
|
415
417
|
});
|
|
418
|
+
// Lossless write gate: every update/remove helper accepts an explicit loss
|
|
419
|
+
// acknowledgement (see lossless-write-gate.js). add* is purely additive and is
|
|
420
|
+
// left alone. Without the acknowledgement a shrinking text edit or a destructive
|
|
421
|
+
// removal is blocked at the buildMutationResult funnel.
|
|
422
|
+
const LOSS_ACK_INPUT_PROPS = Object.freeze({
|
|
423
|
+
acknowledgeLoss: {
|
|
424
|
+
type: 'boolean',
|
|
425
|
+
description: 'Set true to confirm an intentional content reduction: a text rewrite that drops prior segments, or a destructive removal. Without it the lossless write gate blocks the write and returns the exact removed content.',
|
|
426
|
+
},
|
|
427
|
+
lossJustification: {
|
|
428
|
+
type: 'string',
|
|
429
|
+
description: 'Required for MAJOR text loss (large removedChars or most of the value): the reason the prior content is being intentionally replaced.',
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
|
|
416
433
|
for (const tool of TOOLS) {
|
|
417
434
|
const inputSchema = tool && tool.inputSchema;
|
|
418
|
-
if (inputSchema
|
|
419
|
-
|
|
420
|
-
|
|
435
|
+
if (!inputSchema || inputSchema.type !== 'object' || !inputSchema.properties) continue;
|
|
436
|
+
if (!Object.prototype.hasOwnProperty.call(inputSchema.properties, 'workspaceRoot')) {
|
|
437
|
+
inputSchema.properties.workspaceRoot = WORKSPACE_ROOT_PARAM;
|
|
438
|
+
}
|
|
439
|
+
if (tool.name && /^(update|remove)Architecture/.test(tool.name)) {
|
|
440
|
+
for (const [key, value] of Object.entries(LOSS_ACK_INPUT_PROPS)) {
|
|
441
|
+
if (!Object.prototype.hasOwnProperty.call(inputSchema.properties, key)) {
|
|
442
|
+
inputSchema.properties[key] = value;
|
|
443
|
+
}
|
|
421
444
|
}
|
|
422
445
|
}
|
|
423
446
|
}
|
|
@@ -464,6 +487,8 @@ function mutationInputSchema() {
|
|
|
464
487
|
required: ['mutations'],
|
|
465
488
|
properties: {
|
|
466
489
|
architecturePath: { type: 'string', description: `Default: ${DEFAULT_GRAPH_PATH}` },
|
|
490
|
+
acknowledgeLoss: { type: 'boolean', description: 'Batch-level loss acknowledgement: set true to confirm every intentional content reduction in this mutation set (a text rewrite that drops prior segments, or destructive removals). Counts for all mutations — one confirmation for a whole batch.' },
|
|
491
|
+
lossJustification: { type: 'string', description: 'Batch-level justification for MAJOR text loss in this mutation set.' },
|
|
467
492
|
mutations: {
|
|
468
493
|
type: 'array',
|
|
469
494
|
minItems: 1,
|
|
@@ -483,6 +508,8 @@ function mutationInputSchema() {
|
|
|
483
508
|
relationship_ids: { type: 'array', items: { type: 'string' } },
|
|
484
509
|
onConflict: { type: 'string', enum: ['reuse', 'allowDuplicate'], description: 'Dedup policy for add* mutations. reuse (default): find-or-create — attach an existing exact-natural-key match instead of creating a duplicate; a same-type semantic near-duplicate also blocks creation unless allowDuplicate is set. allowDuplicate: create a new object even if a duplicate exists, requires a non-empty justification.' },
|
|
485
510
|
justification: { type: 'string', description: 'Required when onConflict is allowDuplicate; recorded as the reason a semantically-equal duplicate is intentionally created.' },
|
|
511
|
+
acknowledgeLoss: { type: 'boolean', description: 'Set true to confirm an intentional content reduction: a text rewrite that drops prior segments, or a destructive removal. Without it the lossless write gate blocks the write and returns the exact removed content.' },
|
|
512
|
+
lossJustification: { type: 'string', description: 'Required for MAJOR text loss (large removedChars or most of the value): the reason the prior content is being intentionally replaced.' },
|
|
486
513
|
},
|
|
487
514
|
additionalProperties: false,
|
|
488
515
|
},
|
|
@@ -1248,7 +1275,7 @@ function resolveDuplicateConflict(options, candidates) {
|
|
|
1248
1275
|
return { action: 'create', justification: options.justification };
|
|
1249
1276
|
}
|
|
1250
1277
|
|
|
1251
|
-
function applyMutations(document, mutations) {
|
|
1278
|
+
function applyMutations(document, mutations, options = {}) {
|
|
1252
1279
|
const nextDocument = clone(document);
|
|
1253
1280
|
const touchedElementIds = new Set();
|
|
1254
1281
|
const touchedRelationshipIds = new Set();
|
|
@@ -1323,6 +1350,14 @@ function applyMutations(document, mutations) {
|
|
|
1323
1350
|
patch.attributes,
|
|
1324
1351
|
);
|
|
1325
1352
|
}
|
|
1353
|
+
if (Array.isArray(patch.testcases)) {
|
|
1354
|
+
// G1: merge testcases by name so an omitted testcase is preserved; only
|
|
1355
|
+
// an explicit { name, op:'remove' } deletes one.
|
|
1356
|
+
patch.testcases = losslessWriteGate.mergeTestcasesPatch(
|
|
1357
|
+
Array.isArray(element.testcases) ? element.testcases : [],
|
|
1358
|
+
patch.testcases,
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1326
1361
|
Object.assign(element, patch);
|
|
1327
1362
|
if (patchesSubdiagramViews || patchesName) {
|
|
1328
1363
|
reconcileSubdiagramViewsForElement(nextDocument, element);
|
|
@@ -1440,7 +1475,15 @@ function applyMutations(document, mutations) {
|
|
|
1440
1475
|
requirePatchDoesNotChangeRelationshipIdentityOrType(mutation.id, mutation.patch);
|
|
1441
1476
|
const oldSourceId = relationship.source_id;
|
|
1442
1477
|
const oldTargetId = relationship.target_id;
|
|
1443
|
-
|
|
1478
|
+
const relationshipPatch = clone(mutation.patch);
|
|
1479
|
+
if (Array.isArray(relationshipPatch.attributes)) {
|
|
1480
|
+
// G1: merge relationship attributes by name (unmentioned preserved).
|
|
1481
|
+
relationshipPatch.attributes = losslessWriteGate.mergeRelationshipAttributesPatch(
|
|
1482
|
+
Array.isArray(relationship.attributes) ? relationship.attributes : [],
|
|
1483
|
+
relationshipPatch.attributes,
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
Object.assign(relationship, relationshipPatch);
|
|
1444
1487
|
for (const view of nextDocument.views) {
|
|
1445
1488
|
if (!(view.included_relationships || []).includes(relationship.id)) {
|
|
1446
1489
|
continue;
|
|
@@ -1547,11 +1590,12 @@ function applyMutations(document, mutations) {
|
|
|
1547
1590
|
const oldParentId = view.parent_element_id;
|
|
1548
1591
|
const oldViewId = view.view_id;
|
|
1549
1592
|
const patch = clone(mutation.patch);
|
|
1550
|
-
if (
|
|
1551
|
-
|
|
1593
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'included_elements')) {
|
|
1594
|
+
// G1: full list (legacy replace) or lossless delta { add, remove }.
|
|
1595
|
+
patch.included_elements = losslessWriteGate.applyViewMembershipPatch(view.included_elements || [], patch.included_elements).list;
|
|
1552
1596
|
}
|
|
1553
|
-
if (
|
|
1554
|
-
patch.included_relationships =
|
|
1597
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'included_relationships')) {
|
|
1598
|
+
patch.included_relationships = losslessWriteGate.applyViewMembershipPatch(view.included_relationships || [], patch.included_relationships).list;
|
|
1555
1599
|
}
|
|
1556
1600
|
Object.assign(view, patch);
|
|
1557
1601
|
if (oldParentId !== view.parent_element_id) {
|
|
@@ -1588,6 +1632,12 @@ function applyMutations(document, mutations) {
|
|
|
1588
1632
|
touchedViewIds: Array.from(touchedViewIds),
|
|
1589
1633
|
viewLimitCheckIds: Array.from(viewLimitCheckIds),
|
|
1590
1634
|
mutationSummaries,
|
|
1635
|
+
lossReport: losslessWriteGate.buildLossReport({
|
|
1636
|
+
baseDocument: document,
|
|
1637
|
+
mutations,
|
|
1638
|
+
nextDocument,
|
|
1639
|
+
lossAck: options.lossAck,
|
|
1640
|
+
}),
|
|
1591
1641
|
};
|
|
1592
1642
|
}
|
|
1593
1643
|
|
|
@@ -1768,12 +1818,12 @@ function removeEntries(existing, removals) {
|
|
|
1768
1818
|
return (Array.isArray(existing) ? existing : []).filter(entry => !removalSet.has(entry));
|
|
1769
1819
|
}
|
|
1770
1820
|
|
|
1771
|
-
async function buildMutationResult(context, mutations, write, dependencies) {
|
|
1821
|
+
async function buildMutationResult(context, mutations, write, dependencies, lossAck) {
|
|
1772
1822
|
markPhase('mutation:' + (write ? 'apply' : 'preview'));
|
|
1773
1823
|
const beforeSummary = summarizeDocument(context.document);
|
|
1774
1824
|
let mutationResult;
|
|
1775
1825
|
try {
|
|
1776
|
-
mutationResult = applyMutations(context.document, mutations);
|
|
1826
|
+
mutationResult = applyMutations(context.document, mutations, { lossAck });
|
|
1777
1827
|
} catch (error) {
|
|
1778
1828
|
const errors = [String(error && error.message ? error.message : error)];
|
|
1779
1829
|
const failed = {
|
|
@@ -1846,13 +1896,52 @@ async function buildMutationResult(context, mutations, write, dependencies) {
|
|
|
1846
1896
|
}
|
|
1847
1897
|
}
|
|
1848
1898
|
|
|
1849
|
-
|
|
1899
|
+
// Lossless write gate: block any unacknowledged content reduction (text shrink,
|
|
1900
|
+
// membership drop, destructive removal). Preview reports it too (status failed)
|
|
1901
|
+
// so the loss is visible before writing.
|
|
1902
|
+
const lossReport = mutationResult.lossReport;
|
|
1903
|
+
const hasLoss = lossReport && (
|
|
1904
|
+
lossReport.text.length > 0
|
|
1905
|
+
|| lossReport.membersRemoved.length > 0
|
|
1906
|
+
|| lossReport.objectsRemoved.length > 0
|
|
1907
|
+
|| lossReport.testcasesRemoved.length > 0
|
|
1908
|
+
);
|
|
1909
|
+
let losslessBlocked = false;
|
|
1910
|
+
if (hasLoss) {
|
|
1911
|
+
result.lossless = lossReport;
|
|
1912
|
+
if (errors.length === 0 && lossReport.blocked) {
|
|
1913
|
+
losslessBlocked = true;
|
|
1914
|
+
result.status = 'failed';
|
|
1915
|
+
result.after = beforeSummary;
|
|
1916
|
+
result.errors = lossReport.reasons;
|
|
1917
|
+
result.guidance = addUnique(result.guidance || [], [
|
|
1918
|
+
...lossReport.reasons,
|
|
1919
|
+
'Lossless write gate: read the current value first. For an intentional text rewrite pass acknowledgeLoss:true (and lossJustification for major loss); for a destructive removal pass acknowledgeLoss:true (a tombstone snapshot is recorded). Prefer lossless forms: testcases merge by name, view membership delta { add, remove }.',
|
|
1920
|
+
]);
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
if (errors.length > 0 || semanticBlocked || losslessBlocked || !write) {
|
|
1850
1925
|
return result;
|
|
1851
1926
|
}
|
|
1852
1927
|
|
|
1853
1928
|
writeGraph(context.graphPath.absolutePath, mutationResult.document);
|
|
1854
1929
|
result.written = true;
|
|
1855
1930
|
|
|
1931
|
+
{
|
|
1932
|
+
const removedObjects = losslessWriteGate.collectRemovedObjects(context.document, mutations, mutationResult.document);
|
|
1933
|
+
if (removedObjects.length > 0) {
|
|
1934
|
+
try {
|
|
1935
|
+
const tomb = losslessWriteGate.appendTombstones(context.graphPath.absolutePath, removedObjects);
|
|
1936
|
+
result.tombstones = { status: 'passed', path: tomb.path, count: tomb.count };
|
|
1937
|
+
} catch (error) {
|
|
1938
|
+
const message = String(error && error.message ? error.message : error);
|
|
1939
|
+
result.tombstones = { status: 'failed', error: message };
|
|
1940
|
+
result.warnings = addUnique(result.warnings || [], ['tombstone snapshot failed (non-fatal): ' + message]);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1856
1945
|
// WP2791: .qea projection parallel to the Neo4j trigger — non-fatal, best-effort,
|
|
1857
1946
|
// but ALWAYS reported on the result (passed / failed / noop+reason) so a missing EA
|
|
1858
1947
|
// update is never silent.
|
|
@@ -2543,12 +2632,12 @@ async function callTool(name, args = {}, dependencies = undefined) {
|
|
|
2543
2632
|
|
|
2544
2633
|
if (name === 'previewSystemArchitectureMutation') {
|
|
2545
2634
|
const context = await loadContext(args);
|
|
2546
|
-
return toolResult(attachContextWarnings(await buildMutationResult(context, args.mutations, false, dependencies), context));
|
|
2635
|
+
return toolResult(attachContextWarnings(await buildMutationResult(context, args.mutations, false, dependencies, { acknowledgeLoss: args.acknowledgeLoss, lossJustification: args.lossJustification }), context));
|
|
2547
2636
|
}
|
|
2548
2637
|
|
|
2549
2638
|
if (name === 'applySystemArchitectureMutation') {
|
|
2550
2639
|
const context = await loadContext(args);
|
|
2551
|
-
return mutationToolResult(attachContextWarnings(await buildMutationResult(context, args.mutations, true, dependencies), context), true);
|
|
2640
|
+
return mutationToolResult(attachContextWarnings(await buildMutationResult(context, args.mutations, true, dependencies, { acknowledgeLoss: args.acknowledgeLoss, lossJustification: args.lossJustification }), context), true);
|
|
2552
2641
|
}
|
|
2553
2642
|
|
|
2554
2643
|
if (name === 'addArchitectureElement') {
|
|
@@ -2560,13 +2649,13 @@ async function callTool(name, args = {}, dependencies = undefined) {
|
|
|
2560
2649
|
if (name === 'updateArchitectureElement') {
|
|
2561
2650
|
const context = await loadContext(args);
|
|
2562
2651
|
const write = !args.dryRun;
|
|
2563
|
-
return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'updateElement', id: args.id, patch: args.patch }], write), context), write);
|
|
2652
|
+
return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'updateElement', id: args.id, patch: args.patch, acknowledgeLoss: args.acknowledgeLoss, lossJustification: args.lossJustification }], write), context), write);
|
|
2564
2653
|
}
|
|
2565
2654
|
|
|
2566
2655
|
if (name === 'removeArchitectureElement') {
|
|
2567
2656
|
const context = await loadContext(args);
|
|
2568
2657
|
const write = !args.dryRun;
|
|
2569
|
-
return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'removeElement', id: args.id, view_ids: args.view_ids }], write), context), write);
|
|
2658
|
+
return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'removeElement', id: args.id, view_ids: args.view_ids, acknowledgeLoss: args.acknowledgeLoss, lossJustification: args.lossJustification }], write), context), write);
|
|
2570
2659
|
}
|
|
2571
2660
|
|
|
2572
2661
|
if (name === 'addArchitectureRelationship') {
|
|
@@ -2578,13 +2667,13 @@ async function callTool(name, args = {}, dependencies = undefined) {
|
|
|
2578
2667
|
if (name === 'updateArchitectureRelationship') {
|
|
2579
2668
|
const context = await loadContext(args);
|
|
2580
2669
|
const write = !args.dryRun;
|
|
2581
|
-
return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'updateRelationship', id: args.id, patch: args.patch }], write), context), write);
|
|
2670
|
+
return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'updateRelationship', id: args.id, patch: args.patch, acknowledgeLoss: args.acknowledgeLoss, lossJustification: args.lossJustification }], write), context), write);
|
|
2582
2671
|
}
|
|
2583
2672
|
|
|
2584
2673
|
if (name === 'removeArchitectureRelationship') {
|
|
2585
2674
|
const context = await loadContext(args);
|
|
2586
2675
|
const write = !args.dryRun;
|
|
2587
|
-
return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'removeRelationship', id: args.id, view_ids: args.view_ids }], write), context), write);
|
|
2676
|
+
return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'removeRelationship', id: args.id, view_ids: args.view_ids, acknowledgeLoss: args.acknowledgeLoss, lossJustification: args.lossJustification }], write), context), write);
|
|
2588
2677
|
}
|
|
2589
2678
|
|
|
2590
2679
|
if (name === 'addArchitectureView') {
|
|
@@ -2596,13 +2685,13 @@ async function callTool(name, args = {}, dependencies = undefined) {
|
|
|
2596
2685
|
if (name === 'updateArchitectureView') {
|
|
2597
2686
|
const context = await loadContext(args);
|
|
2598
2687
|
const write = !args.dryRun;
|
|
2599
|
-
return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'updateView', view_id: args.view_id, patch: args.patch }], write), context), write);
|
|
2688
|
+
return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'updateView', view_id: args.view_id, patch: args.patch, acknowledgeLoss: args.acknowledgeLoss, lossJustification: args.lossJustification }], write), context), write);
|
|
2600
2689
|
}
|
|
2601
2690
|
|
|
2602
2691
|
if (name === 'removeArchitectureView') {
|
|
2603
2692
|
const context = await loadContext(args);
|
|
2604
2693
|
const write = !args.dryRun;
|
|
2605
|
-
return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'removeView', view_id: args.view_id }], write), context), write);
|
|
2694
|
+
return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'removeView', view_id: args.view_id, acknowledgeLoss: args.acknowledgeLoss, lossJustification: args.lossJustification }], write), context), write);
|
|
2606
2695
|
}
|
|
2607
2696
|
|
|
2608
2697
|
if (name === 'queryNeo4jGraph') {
|
package/package.json
CHANGED