agentxchain 2.97.0 → 2.98.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/bin/agentxchain.js +3 -2
- package/package.json +1 -1
- package/src/commands/diff.js +94 -10
- package/src/lib/export-diff.js +347 -0
package/bin/agentxchain.js
CHANGED
|
@@ -345,9 +345,10 @@ program
|
|
|
345
345
|
.action(decisionsCommand);
|
|
346
346
|
|
|
347
347
|
program
|
|
348
|
-
.command('diff <
|
|
349
|
-
.description('Compare two recorded governed runs
|
|
348
|
+
.command('diff <left_ref> <right_ref>')
|
|
349
|
+
.description('Compare two recorded governed runs or two export artifacts')
|
|
350
350
|
.option('-j, --json', 'Output as JSON')
|
|
351
|
+
.option('--export', 'Compare two export artifacts instead of run-history entries')
|
|
351
352
|
.option('-d, --dir <path>', 'Project directory')
|
|
352
353
|
.action(diffCommand);
|
|
353
354
|
|
package/package.json
CHANGED
package/src/commands/diff.js
CHANGED
|
@@ -1,9 +1,41 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
|
|
3
3
|
import { findProjectRoot } from '../lib/config.js';
|
|
4
|
+
import { buildExportDiff, resolveExportArtifact } from '../lib/export-diff.js';
|
|
4
5
|
import { buildRunDiff, resolveRunHistoryReference } from '../lib/run-diff.js';
|
|
5
6
|
|
|
6
7
|
export async function diffCommand(leftRef, rightRef, opts) {
|
|
8
|
+
if (opts.export) {
|
|
9
|
+
const leftExport = resolveExportArtifact(leftRef);
|
|
10
|
+
if (!leftExport.ok) {
|
|
11
|
+
console.error(chalk.red(leftExport.error));
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const rightExport = resolveExportArtifact(rightRef);
|
|
16
|
+
if (!rightExport.ok) {
|
|
17
|
+
console.error(chalk.red(rightExport.error));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const exportDiff = buildExportDiff(leftExport.artifact, rightExport.artifact, {
|
|
22
|
+
left_ref: leftExport.resolved_ref,
|
|
23
|
+
right_ref: rightExport.resolved_ref,
|
|
24
|
+
});
|
|
25
|
+
if (!exportDiff.ok) {
|
|
26
|
+
console.error(chalk.red(exportDiff.error));
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (opts.json) {
|
|
31
|
+
console.log(JSON.stringify(exportDiff.diff, null, 2));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log(formatExportDiffText(exportDiff.diff));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
7
39
|
const root = findProjectRoot(opts.dir || process.cwd());
|
|
8
40
|
if (!root) {
|
|
9
41
|
console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
|
|
@@ -53,16 +85,7 @@ function formatRunDiffText(diff) {
|
|
|
53
85
|
|
|
54
86
|
appendChangedSection(lines, 'List changes', Object.values(diff.list_changes)
|
|
55
87
|
.filter((entry) => entry.changed)
|
|
56
|
-
.flatMap((entry) =>
|
|
57
|
-
const items = [];
|
|
58
|
-
if (entry.added.length > 0) {
|
|
59
|
-
items.push(`${entry.label} added: ${entry.added.join(', ')}`);
|
|
60
|
-
}
|
|
61
|
-
if (entry.removed.length > 0) {
|
|
62
|
-
items.push(`${entry.label} removed: ${entry.removed.join(', ')}`);
|
|
63
|
-
}
|
|
64
|
-
return items;
|
|
65
|
-
}));
|
|
88
|
+
.flatMap((entry) => listChangeItems(entry)));
|
|
66
89
|
|
|
67
90
|
appendChangedSection(lines, 'Gate changes', diff.gate_changes
|
|
68
91
|
.filter((entry) => entry.changed)
|
|
@@ -71,6 +94,47 @@ function formatRunDiffText(diff) {
|
|
|
71
94
|
return lines.join('\n');
|
|
72
95
|
}
|
|
73
96
|
|
|
97
|
+
function formatExportDiffText(diff) {
|
|
98
|
+
const lines = [];
|
|
99
|
+
lines.push(chalk.bold('Export Diff'));
|
|
100
|
+
lines.push(`${chalk.dim('Left:')} ${formatExportHeader(diff.left_ref, diff.left)}`);
|
|
101
|
+
lines.push(`${chalk.dim('Right:')} ${formatExportHeader(diff.right_ref, diff.right)}`);
|
|
102
|
+
|
|
103
|
+
if (!diff.changed) {
|
|
104
|
+
lines.push('');
|
|
105
|
+
lines.push(chalk.green('No differences.'));
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
appendChangedSection(lines, 'Changed fields', Object.values(diff.scalar_changes)
|
|
110
|
+
.filter((entry) => entry.changed)
|
|
111
|
+
.map((entry) => `${entry.label}: ${formatValue(entry.left, entry.label)} -> ${formatValue(entry.right, entry.label)}`));
|
|
112
|
+
|
|
113
|
+
appendChangedSection(lines, 'Numeric deltas', Object.values(diff.numeric_changes)
|
|
114
|
+
.filter((entry) => entry.changed)
|
|
115
|
+
.map((entry) => `${entry.label}: ${formatValue(entry.left, entry.label)} -> ${formatValue(entry.right, entry.label)}${formatDelta(entry.delta, entry.label)}`));
|
|
116
|
+
|
|
117
|
+
appendChangedSection(lines, 'List changes', Object.values(diff.list_changes)
|
|
118
|
+
.filter((entry) => entry.changed)
|
|
119
|
+
.flatMap((entry) => listChangeItems(entry)));
|
|
120
|
+
|
|
121
|
+
if (diff.subject_kind === 'coordinator') {
|
|
122
|
+
appendChangedSection(lines, 'Repo status changes', diff.repo_status_changes
|
|
123
|
+
.filter((entry) => entry.changed)
|
|
124
|
+
.map((entry) => `${entry.key}: ${formatValue(entry.left)} -> ${formatValue(entry.right)}`));
|
|
125
|
+
|
|
126
|
+
appendChangedSection(lines, 'Repo export changes', diff.repo_export_changes
|
|
127
|
+
.filter((entry) => entry.changed)
|
|
128
|
+
.map((entry) => `${entry.key}: ${formatValue(entry.left)} -> ${formatValue(entry.right)}`));
|
|
129
|
+
|
|
130
|
+
appendChangedSection(lines, 'Event type deltas', diff.event_type_changes
|
|
131
|
+
.filter((entry) => entry.changed)
|
|
132
|
+
.map((entry) => `${entry.key}: ${formatValue(entry.left)} -> ${formatValue(entry.right)}${formatDelta(entry.delta, entry.label)}`));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return lines.join('\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
74
138
|
function appendChangedSection(lines, heading, items) {
|
|
75
139
|
if (items.length === 0) return;
|
|
76
140
|
lines.push('');
|
|
@@ -80,6 +144,17 @@ function appendChangedSection(lines, heading, items) {
|
|
|
80
144
|
}
|
|
81
145
|
}
|
|
82
146
|
|
|
147
|
+
function listChangeItems(entry) {
|
|
148
|
+
const items = [];
|
|
149
|
+
if (entry.added.length > 0) {
|
|
150
|
+
items.push(`${entry.label} added: ${entry.added.join(', ')}`);
|
|
151
|
+
}
|
|
152
|
+
if (entry.removed.length > 0) {
|
|
153
|
+
items.push(`${entry.label} removed: ${entry.removed.join(', ')}`);
|
|
154
|
+
}
|
|
155
|
+
return items;
|
|
156
|
+
}
|
|
157
|
+
|
|
83
158
|
function formatRunHeader(entry) {
|
|
84
159
|
const status = entry.status || '—';
|
|
85
160
|
const trigger = entry.trigger || 'legacy';
|
|
@@ -89,6 +164,15 @@ function formatRunHeader(entry) {
|
|
|
89
164
|
return `${entry.run_id} (${status}, ${trigger}, ${recordedAt})`;
|
|
90
165
|
}
|
|
91
166
|
|
|
167
|
+
function formatExportHeader(ref, entry) {
|
|
168
|
+
const exportKind = entry.export_kind === 'agentxchain_coordinator_export'
|
|
169
|
+
? 'coordinator export'
|
|
170
|
+
: 'run export';
|
|
171
|
+
const status = entry.status || '—';
|
|
172
|
+
const identity = entry.run_id || entry.super_run_id || 'unknown run';
|
|
173
|
+
return `${ref} (${exportKind}, ${status}, ${identity})`;
|
|
174
|
+
}
|
|
175
|
+
|
|
92
176
|
function formatValue(value, label = '') {
|
|
93
177
|
if (value == null) return '—';
|
|
94
178
|
if (typeof value === 'boolean') return value ? 'yes' : 'no';
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
const RUN_EXPORT_SCALAR_FIELDS = [
|
|
5
|
+
['run_id', 'Run ID'],
|
|
6
|
+
['status', 'Status'],
|
|
7
|
+
['phase', 'Phase'],
|
|
8
|
+
['project_name', 'Project'],
|
|
9
|
+
['project_goal', 'Goal'],
|
|
10
|
+
['provenance_trigger', 'Trigger'],
|
|
11
|
+
['dashboard_status', 'Dashboard'],
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const RUN_EXPORT_NUMERIC_FIELDS = [
|
|
15
|
+
['history_entries', 'History entries'],
|
|
16
|
+
['decision_entries', 'Decision entries'],
|
|
17
|
+
['hook_audit_entries', 'Hook audit entries'],
|
|
18
|
+
['notification_audit_entries', 'Notification audit entries'],
|
|
19
|
+
['dispatch_artifact_files', 'Dispatch artifacts'],
|
|
20
|
+
['staging_artifact_files', 'Staging artifacts'],
|
|
21
|
+
['total_delegations_issued', 'Delegations'],
|
|
22
|
+
['active_repo_decision_count', 'Active repo decisions'],
|
|
23
|
+
['overridden_repo_decision_count', 'Overridden repo decisions'],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const COORDINATOR_EXPORT_SCALAR_FIELDS = [
|
|
27
|
+
['super_run_id', 'Super run ID'],
|
|
28
|
+
['status', 'Status'],
|
|
29
|
+
['phase', 'Phase'],
|
|
30
|
+
['project_name', 'Project'],
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const COORDINATOR_EXPORT_NUMERIC_FIELDS = [
|
|
34
|
+
['barrier_count', 'Barriers'],
|
|
35
|
+
['history_entries', 'History entries'],
|
|
36
|
+
['decision_entries', 'Decision entries'],
|
|
37
|
+
['total_events', 'Aggregated events'],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export function resolveExportArtifact(ref) {
|
|
41
|
+
const exportPath = resolve(ref);
|
|
42
|
+
if (!existsSync(exportPath)) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
error: `Export file not found: ${exportPath}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let artifact;
|
|
50
|
+
try {
|
|
51
|
+
artifact = JSON.parse(readFileSync(exportPath, 'utf8'));
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
error: `Failed to parse export file ${exportPath}: ${error.message}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
error: `Export file ${exportPath} must contain a JSON object.`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (typeof artifact.export_kind !== 'string' || !artifact.export_kind.trim()) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
error: `Export file ${exportPath} is missing export_kind.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
artifact,
|
|
76
|
+
resolved_ref: exportPath,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function buildExportDiff(leftArtifact, rightArtifact, refs = {}) {
|
|
81
|
+
if (leftArtifact.export_kind !== rightArtifact.export_kind) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
error: `Export kinds do not match: ${leftArtifact.export_kind} vs ${rightArtifact.export_kind}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (leftArtifact.export_kind === 'agentxchain_run_export') {
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
diff: buildRunExportDiff(leftArtifact, rightArtifact, refs),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (leftArtifact.export_kind === 'agentxchain_coordinator_export') {
|
|
96
|
+
return {
|
|
97
|
+
ok: true,
|
|
98
|
+
diff: buildCoordinatorExportDiff(leftArtifact, rightArtifact, refs),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: `Unsupported export kind for diff: ${leftArtifact.export_kind}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildRunExportDiff(leftArtifact, rightArtifact, refs) {
|
|
109
|
+
const left = normalizeRunExport(leftArtifact);
|
|
110
|
+
const right = normalizeRunExport(rightArtifact);
|
|
111
|
+
|
|
112
|
+
const scalar_changes = buildFieldChanges(left, right, RUN_EXPORT_SCALAR_FIELDS);
|
|
113
|
+
const numeric_changes = buildNumericChanges(left, right, RUN_EXPORT_NUMERIC_FIELDS);
|
|
114
|
+
const list_changes = {
|
|
115
|
+
active_turn_ids: buildListChange('Active turns', left.active_turn_ids, right.active_turn_ids),
|
|
116
|
+
retained_turn_ids: buildListChange('Retained turns', left.retained_turn_ids, right.retained_turn_ids),
|
|
117
|
+
active_repo_decision_ids: buildListChange('Active repo decisions', left.active_repo_decision_ids, right.active_repo_decision_ids),
|
|
118
|
+
overridden_repo_decision_ids: buildListChange('Overridden repo decisions', left.overridden_repo_decision_ids, right.overridden_repo_decision_ids),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const changed = hasChanged(scalar_changes, numeric_changes, list_changes);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
comparison_mode: 'export',
|
|
125
|
+
subject_kind: 'run',
|
|
126
|
+
export_kind: leftArtifact.export_kind,
|
|
127
|
+
left_ref: refs.left_ref || null,
|
|
128
|
+
right_ref: refs.right_ref || null,
|
|
129
|
+
changed,
|
|
130
|
+
left,
|
|
131
|
+
right,
|
|
132
|
+
scalar_changes,
|
|
133
|
+
numeric_changes,
|
|
134
|
+
list_changes,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildCoordinatorExportDiff(leftArtifact, rightArtifact, refs) {
|
|
139
|
+
const left = normalizeCoordinatorExport(leftArtifact);
|
|
140
|
+
const right = normalizeCoordinatorExport(rightArtifact);
|
|
141
|
+
|
|
142
|
+
const scalar_changes = buildFieldChanges(left, right, COORDINATOR_EXPORT_SCALAR_FIELDS);
|
|
143
|
+
const numeric_changes = buildNumericChanges(left, right, COORDINATOR_EXPORT_NUMERIC_FIELDS);
|
|
144
|
+
const list_changes = {
|
|
145
|
+
repo_ids: buildListChange('Repos', left.repo_ids, right.repo_ids),
|
|
146
|
+
repos_with_events: buildListChange('Repos with events', left.repos_with_events, right.repos_with_events),
|
|
147
|
+
};
|
|
148
|
+
const repo_status_changes = buildMapChanges('Repo status', left.repo_run_statuses, right.repo_run_statuses);
|
|
149
|
+
const repo_export_changes = buildBooleanMapChanges('Repo export ok', left.repo_export_status, right.repo_export_status);
|
|
150
|
+
const event_type_changes = buildNumericMapChanges('Event type', left.event_type_counts, right.event_type_counts);
|
|
151
|
+
|
|
152
|
+
const changed = hasChanged(scalar_changes, numeric_changes, list_changes)
|
|
153
|
+
|| repo_status_changes.some((entry) => entry.changed)
|
|
154
|
+
|| repo_export_changes.some((entry) => entry.changed)
|
|
155
|
+
|| event_type_changes.some((entry) => entry.changed);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
comparison_mode: 'export',
|
|
159
|
+
subject_kind: 'coordinator',
|
|
160
|
+
export_kind: leftArtifact.export_kind,
|
|
161
|
+
left_ref: refs.left_ref || null,
|
|
162
|
+
right_ref: refs.right_ref || null,
|
|
163
|
+
changed,
|
|
164
|
+
left,
|
|
165
|
+
right,
|
|
166
|
+
scalar_changes,
|
|
167
|
+
numeric_changes,
|
|
168
|
+
list_changes,
|
|
169
|
+
repo_status_changes,
|
|
170
|
+
repo_export_changes,
|
|
171
|
+
event_type_changes,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeRunExport(artifact) {
|
|
176
|
+
const summary = artifact.summary || {};
|
|
177
|
+
const repoDecisions = summary.repo_decisions || {};
|
|
178
|
+
return {
|
|
179
|
+
export_kind: artifact.export_kind,
|
|
180
|
+
run_id: summary.run_id || null,
|
|
181
|
+
status: summary.status || null,
|
|
182
|
+
phase: summary.phase || null,
|
|
183
|
+
project_name: artifact.project?.name || null,
|
|
184
|
+
project_goal: summary.project_goal || artifact.project?.goal || null,
|
|
185
|
+
provenance_trigger: summary.provenance?.trigger || null,
|
|
186
|
+
dashboard_status: summary.dashboard_session?.status || null,
|
|
187
|
+
history_entries: toNumber(summary.history_entries),
|
|
188
|
+
decision_entries: toNumber(summary.decision_entries),
|
|
189
|
+
hook_audit_entries: toNumber(summary.hook_audit_entries),
|
|
190
|
+
notification_audit_entries: toNumber(summary.notification_audit_entries),
|
|
191
|
+
dispatch_artifact_files: toNumber(summary.dispatch_artifact_files),
|
|
192
|
+
staging_artifact_files: toNumber(summary.staging_artifact_files),
|
|
193
|
+
total_delegations_issued: toNumber(summary.delegation_summary?.total_delegations_issued),
|
|
194
|
+
active_repo_decision_count: toNumber(repoDecisions.active_count),
|
|
195
|
+
overridden_repo_decision_count: toNumber(repoDecisions.overridden_count),
|
|
196
|
+
active_turn_ids: normalizeStringArray(summary.active_turn_ids),
|
|
197
|
+
retained_turn_ids: normalizeStringArray(summary.retained_turn_ids),
|
|
198
|
+
active_repo_decision_ids: normalizeStringArray((repoDecisions.active || []).map((entry) => entry?.id)),
|
|
199
|
+
overridden_repo_decision_ids: normalizeStringArray((repoDecisions.overridden || []).map((entry) => entry?.id)),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeCoordinatorExport(artifact) {
|
|
204
|
+
const summary = artifact.summary || {};
|
|
205
|
+
const aggregatedEvents = summary.aggregated_events || {};
|
|
206
|
+
const repoRunStatuses = summary.repo_run_statuses || {};
|
|
207
|
+
const repos = artifact.repos || {};
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
export_kind: artifact.export_kind,
|
|
211
|
+
super_run_id: summary.super_run_id || null,
|
|
212
|
+
status: summary.status || null,
|
|
213
|
+
phase: summary.phase || null,
|
|
214
|
+
project_name: artifact.coordinator?.project_name || null,
|
|
215
|
+
barrier_count: toNumber(summary.barrier_count),
|
|
216
|
+
history_entries: toNumber(summary.history_entries),
|
|
217
|
+
decision_entries: toNumber(summary.decision_entries),
|
|
218
|
+
total_events: toNumber(aggregatedEvents.total_events),
|
|
219
|
+
repo_ids: normalizeStringArray(Object.keys(repos)),
|
|
220
|
+
repos_with_events: normalizeStringArray(aggregatedEvents.repos_with_events),
|
|
221
|
+
repo_run_statuses: normalizeStringMap(repoRunStatuses),
|
|
222
|
+
repo_export_status: normalizeBooleanMap(Object.fromEntries(
|
|
223
|
+
Object.entries(repos).map(([repoId, repoEntry]) => [repoId, repoEntry?.ok === true]),
|
|
224
|
+
)),
|
|
225
|
+
event_type_counts: normalizeNumericMap(aggregatedEvents.event_type_counts),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function buildFieldChanges(left, right, fields) {
|
|
230
|
+
return Object.fromEntries(
|
|
231
|
+
fields.map(([field, label]) => [field, {
|
|
232
|
+
label,
|
|
233
|
+
left: left[field],
|
|
234
|
+
right: right[field],
|
|
235
|
+
changed: !isEqual(left[field], right[field]),
|
|
236
|
+
}]),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function buildNumericChanges(left, right, fields) {
|
|
241
|
+
return Object.fromEntries(
|
|
242
|
+
fields.map(([field, label]) => [field, {
|
|
243
|
+
label,
|
|
244
|
+
left: left[field],
|
|
245
|
+
right: right[field],
|
|
246
|
+
changed: !isEqual(left[field], right[field]),
|
|
247
|
+
delta: typeof left[field] === 'number' && typeof right[field] === 'number'
|
|
248
|
+
? right[field] - left[field]
|
|
249
|
+
: null,
|
|
250
|
+
}]),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function buildListChange(label, left, right) {
|
|
255
|
+
const leftSet = new Set(left);
|
|
256
|
+
const rightSet = new Set(right);
|
|
257
|
+
return {
|
|
258
|
+
label,
|
|
259
|
+
left,
|
|
260
|
+
right,
|
|
261
|
+
added: right.filter((value) => !leftSet.has(value)),
|
|
262
|
+
removed: left.filter((value) => !rightSet.has(value)),
|
|
263
|
+
changed: left.length !== right.length || left.some((value, index) => value !== right[index]),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function buildMapChanges(label, leftMap, rightMap) {
|
|
268
|
+
const keys = normalizeStringArray([...Object.keys(leftMap), ...Object.keys(rightMap)]);
|
|
269
|
+
return keys.map((key) => ({
|
|
270
|
+
label,
|
|
271
|
+
key,
|
|
272
|
+
left: leftMap[key] ?? null,
|
|
273
|
+
right: rightMap[key] ?? null,
|
|
274
|
+
changed: !isEqual(leftMap[key] ?? null, rightMap[key] ?? null),
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function buildBooleanMapChanges(label, leftMap, rightMap) {
|
|
279
|
+
return buildMapChanges(label, leftMap, rightMap);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function buildNumericMapChanges(label, leftMap, rightMap) {
|
|
283
|
+
const keys = normalizeStringArray([...Object.keys(leftMap), ...Object.keys(rightMap)]);
|
|
284
|
+
return keys.map((key) => {
|
|
285
|
+
const left = typeof leftMap[key] === 'number' ? leftMap[key] : null;
|
|
286
|
+
const right = typeof rightMap[key] === 'number' ? rightMap[key] : null;
|
|
287
|
+
return {
|
|
288
|
+
label,
|
|
289
|
+
key,
|
|
290
|
+
left,
|
|
291
|
+
right,
|
|
292
|
+
changed: !isEqual(left, right),
|
|
293
|
+
delta: typeof left === 'number' && typeof right === 'number' ? right - left : null,
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function hasChanged(scalarChanges, numericChanges, listChanges) {
|
|
299
|
+
return [
|
|
300
|
+
...Object.values(scalarChanges),
|
|
301
|
+
...Object.values(numericChanges),
|
|
302
|
+
].some((entry) => entry.changed)
|
|
303
|
+
|| Object.values(listChanges).some((entry) => entry.changed);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function normalizeStringArray(value) {
|
|
307
|
+
if (!Array.isArray(value)) return [];
|
|
308
|
+
return [...new Set(value
|
|
309
|
+
.filter((item) => typeof item === 'string' && item.trim().length > 0)
|
|
310
|
+
.map((item) => item.trim()))]
|
|
311
|
+
.sort((a, b) => a.localeCompare(b, 'en'));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function normalizeStringMap(value) {
|
|
315
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
316
|
+
return Object.fromEntries(
|
|
317
|
+
Object.entries(value)
|
|
318
|
+
.filter(([key]) => typeof key === 'string' && key.trim().length > 0)
|
|
319
|
+
.map(([key, mapValue]) => [key.trim(), typeof mapValue === 'string' && mapValue.trim() ? mapValue.trim() : null]),
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function normalizeBooleanMap(value) {
|
|
324
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
325
|
+
return Object.fromEntries(
|
|
326
|
+
Object.entries(value)
|
|
327
|
+
.filter(([key]) => typeof key === 'string' && key.trim().length > 0)
|
|
328
|
+
.map(([key, mapValue]) => [key.trim(), mapValue === true]),
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function normalizeNumericMap(value) {
|
|
333
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
334
|
+
return Object.fromEntries(
|
|
335
|
+
Object.entries(value)
|
|
336
|
+
.filter(([key, mapValue]) => typeof key === 'string' && key.trim().length > 0 && typeof mapValue === 'number')
|
|
337
|
+
.map(([key, mapValue]) => [key.trim(), mapValue]),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function toNumber(value) {
|
|
342
|
+
return typeof value === 'number' ? value : null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function isEqual(left, right) {
|
|
346
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
347
|
+
}
|