json-humanized 2.0.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/LICENSE +21 -0
- package/README.md +351 -0
- package/bin/cli.js +319 -0
- package/docs/ARCHITECTURE.md +139 -0
- package/docs/DEMO.html +461 -0
- package/docs/PUBLISHING.md +124 -0
- package/examples/api-response.json +42 -0
- package/examples/demo.js +50 -0
- package/examples/user-profile.json +36 -0
- package/index.d.ts +138 -0
- package/package.json +71 -0
- package/src/cache.js +172 -0
- package/src/config.js +259 -0
- package/src/diff.js +284 -0
- package/src/formatters/index.js +113 -0
- package/src/formatters/template.js +132 -0
- package/src/humanizer.js +307 -0
- package/src/index.js +157 -0
- package/src/parsers/index.js +119 -0
- package/src/strategies/ai.js +108 -0
- package/src/strategies/ollama.js +135 -0
- package/src/strategies/openai.js +82 -0
- package/src/watch.js +133 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* config.js — load and merge .jh.config.json for custom rules, field mappings,
|
|
5
|
+
* default engine, format, and more.
|
|
6
|
+
*
|
|
7
|
+
* Config file is searched upward from cwd (like ESLint / Prettier).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
// ─── defaults ────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const DEFAULTS = {
|
|
16
|
+
engine: 'local',
|
|
17
|
+
format: 'plain',
|
|
18
|
+
lang: 'English',
|
|
19
|
+
maxChars: 12000,
|
|
20
|
+
context: '',
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Custom field label overrides.
|
|
24
|
+
* Key: exact JSON field name (or glob pattern).
|
|
25
|
+
* Value: human-readable label (or null to hide the field).
|
|
26
|
+
*
|
|
27
|
+
* Example:
|
|
28
|
+
* "fieldLabels": {
|
|
29
|
+
* "usr_id": "User ID",
|
|
30
|
+
* "txn_ref": "Transaction reference",
|
|
31
|
+
* "internal_*": null
|
|
32
|
+
* }
|
|
33
|
+
*/
|
|
34
|
+
fieldLabels: {},
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Custom field type overrides.
|
|
38
|
+
* Possible types: email, date, money, phone, url, id, boolean,
|
|
39
|
+
* sensitive, coordinates, count, age, rating, text
|
|
40
|
+
*
|
|
41
|
+
* Example:
|
|
42
|
+
* "fieldTypes": {
|
|
43
|
+
* "invoice_no": "id",
|
|
44
|
+
* "balance": "money"
|
|
45
|
+
* }
|
|
46
|
+
*/
|
|
47
|
+
fieldTypes: {},
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fields to always hide (treated as sensitive).
|
|
51
|
+
* Example: ["internal_token", "debug_*"]
|
|
52
|
+
*/
|
|
53
|
+
hiddenFields: [],
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Template file path (Handlebars .hbs) for custom output.
|
|
57
|
+
* Relative to the config file location.
|
|
58
|
+
*/
|
|
59
|
+
template: null,
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* AI provider: 'anthropic' | 'openai' | 'ollama'
|
|
63
|
+
*/
|
|
64
|
+
aiProvider: 'anthropic',
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Ollama base URL (only used when aiProvider = 'ollama')
|
|
68
|
+
*/
|
|
69
|
+
ollamaUrl: 'http://localhost:11434',
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Ollama model name
|
|
73
|
+
*/
|
|
74
|
+
ollamaModel: 'llama3',
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* OpenAI model (only used when aiProvider = 'openai')
|
|
78
|
+
*/
|
|
79
|
+
openaiModel: 'gpt-4o-mini',
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Enable AI response caching (saves API calls for identical JSON)
|
|
83
|
+
*/
|
|
84
|
+
cache: true,
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Cache TTL in seconds (default 1 hour)
|
|
88
|
+
*/
|
|
89
|
+
cacheTTL: 3600,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// ─── config file names ───────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
const CONFIG_FILES = [
|
|
95
|
+
'.jh.config.json',
|
|
96
|
+
'.jhrc.json',
|
|
97
|
+
'jh.config.json',
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Walk up directory tree looking for config file.
|
|
104
|
+
*/
|
|
105
|
+
function findConfigFile(startDir = process.cwd()) {
|
|
106
|
+
let dir = path.resolve(startDir);
|
|
107
|
+
|
|
108
|
+
// eslint-disable-next-line no-constant-condition
|
|
109
|
+
while (true) {
|
|
110
|
+
for (const name of CONFIG_FILES) {
|
|
111
|
+
const candidate = path.join(dir, name);
|
|
112
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const parent = path.dirname(dir);
|
|
116
|
+
if (parent === dir) break; // reached filesystem root
|
|
117
|
+
dir = parent;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Deep-merge two plain objects (right wins).
|
|
125
|
+
*/
|
|
126
|
+
function mergeDeep(target, source) {
|
|
127
|
+
const result = Object.assign({}, target);
|
|
128
|
+
for (const key of Object.keys(source)) {
|
|
129
|
+
if (
|
|
130
|
+
source[key] !== null &&
|
|
131
|
+
typeof source[key] === 'object' &&
|
|
132
|
+
!Array.isArray(source[key]) &&
|
|
133
|
+
typeof target[key] === 'object' &&
|
|
134
|
+
target[key] !== null
|
|
135
|
+
) {
|
|
136
|
+
result[key] = mergeDeep(target[key], source[key]);
|
|
137
|
+
} else {
|
|
138
|
+
result[key] = source[key];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── glob-style pattern matching (simple: only * wildcard) ──────────────────
|
|
145
|
+
|
|
146
|
+
function matchPattern(pattern, key) {
|
|
147
|
+
if (!pattern.includes('*')) return pattern === key;
|
|
148
|
+
const re = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
|
149
|
+
return re.test(key);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── public API ──────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Load config from disk and merge with defaults.
|
|
156
|
+
* @param {string} [configPath] explicit path; if omitted, auto-detected.
|
|
157
|
+
* @returns {{ config: object, configPath: string|null }}
|
|
158
|
+
*/
|
|
159
|
+
function loadConfig(configPath) {
|
|
160
|
+
const filePath = configPath || findConfigFile();
|
|
161
|
+
|
|
162
|
+
if (!filePath) {
|
|
163
|
+
return { config: Object.assign({}, DEFAULTS), configPath: null };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let raw;
|
|
167
|
+
try {
|
|
168
|
+
raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
169
|
+
} catch (err) {
|
|
170
|
+
throw new Error(`Failed to parse config file ${filePath}: ${err.message}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const config = mergeDeep(DEFAULTS, raw);
|
|
174
|
+
|
|
175
|
+
// Resolve template path relative to config file
|
|
176
|
+
if (config.template) {
|
|
177
|
+
config.template = path.resolve(path.dirname(filePath), config.template);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { config, configPath: filePath };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Resolve a human-readable label for a JSON key.
|
|
185
|
+
* Uses fieldLabels from config; falls back to built-in camelCase/snake_case split.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} key
|
|
188
|
+
* @param {object} fieldLabels from config
|
|
189
|
+
* @returns {string|null} null = hide this field
|
|
190
|
+
*/
|
|
191
|
+
function resolveLabel(key, fieldLabels = {}) {
|
|
192
|
+
for (const pattern of Object.keys(fieldLabels)) {
|
|
193
|
+
if (matchPattern(pattern, key)) {
|
|
194
|
+
return fieldLabels[pattern]; // may be null (= hide)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return null; // no override
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Resolve a semantic field type override.
|
|
202
|
+
* @param {string} key
|
|
203
|
+
* @param {object} fieldTypes from config
|
|
204
|
+
* @returns {string|null}
|
|
205
|
+
*/
|
|
206
|
+
function resolveType(key, fieldTypes = {}) {
|
|
207
|
+
for (const pattern of Object.keys(fieldTypes)) {
|
|
208
|
+
if (matchPattern(pattern, key)) {
|
|
209
|
+
return fieldTypes[pattern];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Check if a field should be hidden.
|
|
217
|
+
* @param {string} key
|
|
218
|
+
* @param {string[]} hiddenFields from config
|
|
219
|
+
* @returns {boolean}
|
|
220
|
+
*/
|
|
221
|
+
function isHidden(key, hiddenFields = []) {
|
|
222
|
+
return hiddenFields.some(pattern => matchPattern(pattern, key));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Generate a minimal example config file content.
|
|
227
|
+
* @returns {string} JSON string
|
|
228
|
+
*/
|
|
229
|
+
function generateExampleConfig() {
|
|
230
|
+
const example = {
|
|
231
|
+
engine: 'local',
|
|
232
|
+
format: 'plain',
|
|
233
|
+
lang: 'English',
|
|
234
|
+
maxChars: 12000,
|
|
235
|
+
cache: true,
|
|
236
|
+
fieldLabels: {
|
|
237
|
+
'user_id': 'User ID',
|
|
238
|
+
'txn_ref': 'Transaction reference',
|
|
239
|
+
'internal_*': null,
|
|
240
|
+
},
|
|
241
|
+
fieldTypes: {
|
|
242
|
+
'invoice_no': 'id',
|
|
243
|
+
'balance': 'money',
|
|
244
|
+
'created': 'date',
|
|
245
|
+
},
|
|
246
|
+
hiddenFields: ['debug_*', 'internal_hash'],
|
|
247
|
+
aiProvider: 'anthropic',
|
|
248
|
+
};
|
|
249
|
+
return JSON.stringify(example, null, 2);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = {
|
|
253
|
+
DEFAULTS,
|
|
254
|
+
loadConfig,
|
|
255
|
+
resolveLabel,
|
|
256
|
+
resolveType,
|
|
257
|
+
isHidden,
|
|
258
|
+
generateExampleConfig,
|
|
259
|
+
};
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* diff.js — compare two JSON values and describe changes in human language.
|
|
5
|
+
* Works with both local rule-based and AI engines.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { humanize } = require('./index');
|
|
9
|
+
|
|
10
|
+
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function typeLabel(v) {
|
|
13
|
+
if (v === null) return 'null';
|
|
14
|
+
if (Array.isArray(v)) return 'array';
|
|
15
|
+
return typeof v;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function friendlyKey(key) {
|
|
19
|
+
return key
|
|
20
|
+
.replace(/([A-Z])/g, ' $1')
|
|
21
|
+
.replace(/[_-]+/g, ' ')
|
|
22
|
+
.trim()
|
|
23
|
+
.toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatValue(v) {
|
|
27
|
+
if (v === null) return 'null';
|
|
28
|
+
if (typeof v === 'boolean') return v ? 'yes' : 'no';
|
|
29
|
+
if (typeof v === 'number') return String(v);
|
|
30
|
+
if (typeof v === 'string') return `"${v}"`;
|
|
31
|
+
if (Array.isArray(v)) return `[array with ${v.length} item(s)]`;
|
|
32
|
+
if (typeof v === 'object') return `{object with ${Object.keys(v).length} key(s)}`;
|
|
33
|
+
return String(v);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── core diff logic ─────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
function diffObjects(a, b, path = '') {
|
|
39
|
+
const changes = [];
|
|
40
|
+
const allKeys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
41
|
+
|
|
42
|
+
for (const key of allKeys) {
|
|
43
|
+
const fullPath = path ? `${path}.${key}` : key;
|
|
44
|
+
const label = friendlyKey(key);
|
|
45
|
+
|
|
46
|
+
if (!(key in a)) {
|
|
47
|
+
changes.push({ type: 'added', path: fullPath, label, value: b[key] });
|
|
48
|
+
} else if (!(key in b)) {
|
|
49
|
+
changes.push({ type: 'removed', path: fullPath, label, value: a[key] });
|
|
50
|
+
} else if (
|
|
51
|
+
typeof a[key] === 'object' && a[key] !== null &&
|
|
52
|
+
typeof b[key] === 'object' && b[key] !== null &&
|
|
53
|
+
!Array.isArray(a[key]) && !Array.isArray(b[key])
|
|
54
|
+
) {
|
|
55
|
+
const nested = diffObjects(a[key], b[key], fullPath);
|
|
56
|
+
changes.push(...nested);
|
|
57
|
+
} else if (JSON.stringify(a[key]) !== JSON.stringify(b[key])) {
|
|
58
|
+
changes.push({
|
|
59
|
+
type: 'changed',
|
|
60
|
+
path: fullPath,
|
|
61
|
+
label,
|
|
62
|
+
from: a[key],
|
|
63
|
+
to: b[key],
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return changes;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function diffArrays(a, b) {
|
|
72
|
+
const changes = [];
|
|
73
|
+
const maxLen = Math.max(a.length, b.length);
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < maxLen; i++) {
|
|
76
|
+
if (i >= b.length) {
|
|
77
|
+
changes.push({ type: 'removed', path: `[${i}]`, label: `item ${i + 1}`, value: a[i] });
|
|
78
|
+
} else if (i >= a.length) {
|
|
79
|
+
changes.push({ type: 'added', path: `[${i}]`, label: `item ${i + 1}`, value: b[i] });
|
|
80
|
+
} else if (JSON.stringify(a[i]) !== JSON.stringify(b[i])) {
|
|
81
|
+
changes.push({ type: 'changed', path: `[${i}]`, label: `item ${i + 1}`, from: a[i], to: b[i] });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return changes;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function collectChanges(a, b) {
|
|
89
|
+
const ta = typeLabel(a);
|
|
90
|
+
const tb = typeLabel(b);
|
|
91
|
+
|
|
92
|
+
if (ta !== tb) {
|
|
93
|
+
return [{ type: 'type_changed', from: ta, to: tb, fromValue: a, toValue: b }];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (ta === 'object') return diffObjects(a, b);
|
|
97
|
+
if (ta === 'array') return diffArrays(a, b);
|
|
98
|
+
|
|
99
|
+
if (JSON.stringify(a) !== JSON.stringify(b)) {
|
|
100
|
+
return [{ type: 'changed', path: 'root', label: 'value', from: a, to: b }];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── render changes to text ──────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
function renderChanges(changes, format = 'plain') {
|
|
109
|
+
if (changes.length === 0) {
|
|
110
|
+
return format === 'markdown'
|
|
111
|
+
? '## ✅ No differences\n\nThe two JSON values are **identical**.'
|
|
112
|
+
: '✅ No differences — the two JSON values are identical.';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const added = changes.filter(c => c.type === 'added');
|
|
116
|
+
const removed = changes.filter(c => c.type === 'removed');
|
|
117
|
+
const changed = changes.filter(c => c.type === 'changed');
|
|
118
|
+
const typeChg = changes.filter(c => c.type === 'type_changed');
|
|
119
|
+
|
|
120
|
+
const lines = [];
|
|
121
|
+
|
|
122
|
+
if (format === 'markdown') {
|
|
123
|
+
lines.push(`## 🔀 JSON Diff — ${changes.length} change(s) found\n`);
|
|
124
|
+
|
|
125
|
+
if (typeChg.length) {
|
|
126
|
+
lines.push(`### ⚠️ Type changed`);
|
|
127
|
+
for (const c of typeChg) {
|
|
128
|
+
lines.push(`- Root type changed from **${c.from}** to **${c.to}**`);
|
|
129
|
+
}
|
|
130
|
+
lines.push('');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (added.length) {
|
|
134
|
+
lines.push(`### ➕ Added (${added.length})`);
|
|
135
|
+
for (const c of added) {
|
|
136
|
+
lines.push(`- **${c.label}** \`${c.path}\` was added with value ${formatValue(c.value)}`);
|
|
137
|
+
}
|
|
138
|
+
lines.push('');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (removed.length) {
|
|
142
|
+
lines.push(`### ➖ Removed (${removed.length})`);
|
|
143
|
+
for (const c of removed) {
|
|
144
|
+
lines.push(`- **${c.label}** \`${c.path}\` was removed (was ${formatValue(c.value)})`);
|
|
145
|
+
}
|
|
146
|
+
lines.push('');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (changed.length) {
|
|
150
|
+
lines.push(`### ✏️ Changed (${changed.length})`);
|
|
151
|
+
for (const c of changed) {
|
|
152
|
+
lines.push(`- **${c.label}** \`${c.path}\`: ${formatValue(c.from)} → ${formatValue(c.to)}`);
|
|
153
|
+
}
|
|
154
|
+
lines.push('');
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
lines.push(`Found ${changes.length} difference(s):\n`);
|
|
158
|
+
|
|
159
|
+
if (typeChg.length) {
|
|
160
|
+
for (const c of typeChg) {
|
|
161
|
+
lines.push(`⚠ Root type changed: ${c.from} → ${c.to}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (added.length) {
|
|
166
|
+
lines.push(`\n➕ Added (${added.length}):`);
|
|
167
|
+
for (const c of added) {
|
|
168
|
+
lines.push(` + ${c.label} (${c.path}): ${formatValue(c.value)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (removed.length) {
|
|
173
|
+
lines.push(`\n➖ Removed (${removed.length}):`);
|
|
174
|
+
for (const c of removed) {
|
|
175
|
+
lines.push(` - ${c.label} (${c.path}): was ${formatValue(c.value)}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (changed.length) {
|
|
180
|
+
lines.push(`\n✏ Changed (${changed.length}):`);
|
|
181
|
+
for (const c of changed) {
|
|
182
|
+
lines.push(` ~ ${c.label} (${c.path}): ${formatValue(c.from)} → ${formatValue(c.to)}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return lines.join('\n');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── AI-powered diff ─────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
async function diffWithAI(a, b, options = {}) {
|
|
193
|
+
const { apiKey, lang = 'English', context = '' } = options;
|
|
194
|
+
|
|
195
|
+
const key = apiKey || process.env.ANTHROPIC_API_KEY;
|
|
196
|
+
if (!key) throw new Error('ANTHROPIC_API_KEY is required for AI diff mode');
|
|
197
|
+
|
|
198
|
+
let sdk;
|
|
199
|
+
try {
|
|
200
|
+
sdk = require('@anthropic-ai/sdk');
|
|
201
|
+
} catch {
|
|
202
|
+
throw new Error('Install @anthropic-ai/sdk: npm install @anthropic-ai/sdk');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const client = new sdk.Anthropic({ apiKey: key });
|
|
206
|
+
|
|
207
|
+
const prompt = [
|
|
208
|
+
context ? `Context: ${context}\n` : '',
|
|
209
|
+
'You are a JSON diff analyst. Compare these two JSON values and describe all differences in plain, natural language.',
|
|
210
|
+
`Respond in ${lang}.`,
|
|
211
|
+
'Be concise but thorough. Group changes by: added fields, removed fields, changed values.',
|
|
212
|
+
'',
|
|
213
|
+
'BEFORE:',
|
|
214
|
+
JSON.stringify(a, null, 2).slice(0, 6000),
|
|
215
|
+
'',
|
|
216
|
+
'AFTER:',
|
|
217
|
+
JSON.stringify(b, null, 2).slice(0, 6000),
|
|
218
|
+
].filter(Boolean).join('\n');
|
|
219
|
+
|
|
220
|
+
const response = await client.messages.create({
|
|
221
|
+
model: 'claude-opus-4-5',
|
|
222
|
+
max_tokens: 1024,
|
|
223
|
+
messages: [{ role: 'user', content: prompt }],
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return response.content.map(b => b.text || '').join('');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─── public API ──────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Diff two JavaScript values and return a human-readable description.
|
|
233
|
+
*
|
|
234
|
+
* @param {*} a - "before" value
|
|
235
|
+
* @param {*} b - "after" value
|
|
236
|
+
* @param {object} options
|
|
237
|
+
* @param {'local'|'ai'} [options.engine='local']
|
|
238
|
+
* @param {'plain'|'markdown'|'json'} [options.format='plain']
|
|
239
|
+
* @param {string} [options.lang='English'] (AI only)
|
|
240
|
+
* @param {string} [options.context=''] (AI only)
|
|
241
|
+
* @param {string} [options.apiKey]
|
|
242
|
+
* @returns {Promise<string>}
|
|
243
|
+
*/
|
|
244
|
+
async function diff(a, b, options = {}) {
|
|
245
|
+
const { engine = 'local', format = 'plain' } = options;
|
|
246
|
+
|
|
247
|
+
if (engine === 'ai') {
|
|
248
|
+
return diffWithAI(a, b, options);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const changes = collectChanges(a, b);
|
|
252
|
+
|
|
253
|
+
if (format === 'json') {
|
|
254
|
+
return JSON.stringify({
|
|
255
|
+
changes,
|
|
256
|
+
summary: {
|
|
257
|
+
total: changes.length,
|
|
258
|
+
added: changes.filter(c => c.type === 'added').length,
|
|
259
|
+
removed: changes.filter(c => c.type === 'removed').length,
|
|
260
|
+
changed: changes.filter(c => c.type === 'changed').length,
|
|
261
|
+
},
|
|
262
|
+
}, null, 2);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return renderChanges(changes, format);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Diff two JSON files.
|
|
270
|
+
*/
|
|
271
|
+
async function diffFiles(fileA, fileB, options = {}) {
|
|
272
|
+
const fs = require('fs');
|
|
273
|
+
const { parseAny } = require('./parsers');
|
|
274
|
+
|
|
275
|
+
const rawA = fs.readFileSync(fileA, 'utf8');
|
|
276
|
+
const rawB = fs.readFileSync(fileB, 'utf8');
|
|
277
|
+
|
|
278
|
+
const a = parseAny(rawA, fileA);
|
|
279
|
+
const b = parseAny(rawB, fileB);
|
|
280
|
+
|
|
281
|
+
return diff(a, b, options);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
module.exports = { diff, diffFiles };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
// json-humanized · Output formatters
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Plain text formatter (default)
|
|
9
|
+
*/
|
|
10
|
+
function formatPlain(text, meta = {}) {
|
|
11
|
+
const lines = [];
|
|
12
|
+
if (meta.filename) {
|
|
13
|
+
lines.push(`File: ${meta.filename}`);
|
|
14
|
+
lines.push('─'.repeat(50));
|
|
15
|
+
}
|
|
16
|
+
lines.push(text);
|
|
17
|
+
if (meta.engine) {
|
|
18
|
+
lines.push('');
|
|
19
|
+
lines.push(`[Processed by: ${meta.engine}]`);
|
|
20
|
+
}
|
|
21
|
+
return lines.join('\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Markdown formatter
|
|
26
|
+
*/
|
|
27
|
+
function formatMarkdown(text, meta = {}) {
|
|
28
|
+
const lines = [];
|
|
29
|
+
|
|
30
|
+
if (meta.filename) {
|
|
31
|
+
lines.push(`# JSON Analysis: \`${meta.filename}\``);
|
|
32
|
+
lines.push('');
|
|
33
|
+
} else {
|
|
34
|
+
lines.push('# JSON Analysis');
|
|
35
|
+
lines.push('');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (meta.timestamp) {
|
|
39
|
+
lines.push(`> Generated on ${new Date(meta.timestamp).toLocaleString()}`);
|
|
40
|
+
lines.push('');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
lines.push('## Summary');
|
|
44
|
+
lines.push('');
|
|
45
|
+
lines.push(text);
|
|
46
|
+
lines.push('');
|
|
47
|
+
|
|
48
|
+
if (meta.stats) {
|
|
49
|
+
lines.push('## Metadata');
|
|
50
|
+
lines.push('');
|
|
51
|
+
lines.push(`| Field | Value |`);
|
|
52
|
+
lines.push(`|-------|-------|`);
|
|
53
|
+
for (const [k, v] of Object.entries(meta.stats)) {
|
|
54
|
+
lines.push(`| ${k} | ${v} |`);
|
|
55
|
+
}
|
|
56
|
+
lines.push('');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (meta.engine) {
|
|
60
|
+
lines.push(`---`);
|
|
61
|
+
lines.push(`*Processed by json-humanized (${meta.engine} engine)*`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Story/narrative formatter — wraps output in a storytelling frame
|
|
69
|
+
*/
|
|
70
|
+
function formatStory(text, meta = {}) {
|
|
71
|
+
const lines = [];
|
|
72
|
+
lines.push('━'.repeat(60));
|
|
73
|
+
lines.push(' 📖 THE DATA STORY');
|
|
74
|
+
lines.push('━'.repeat(60));
|
|
75
|
+
lines.push('');
|
|
76
|
+
lines.push(text);
|
|
77
|
+
lines.push('');
|
|
78
|
+
lines.push('━'.repeat(60));
|
|
79
|
+
if (meta.filename) {
|
|
80
|
+
lines.push(` Source: ${meta.filename}`);
|
|
81
|
+
}
|
|
82
|
+
return lines.join('\n');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* JSON formatter — outputs structured metadata alongside the description
|
|
87
|
+
*/
|
|
88
|
+
function formatJSON(text, meta = {}) {
|
|
89
|
+
const output = {
|
|
90
|
+
humanized: text,
|
|
91
|
+
metadata: {
|
|
92
|
+
engine: meta.engine || 'local',
|
|
93
|
+
filename: meta.filename || null,
|
|
94
|
+
timestamp: meta.timestamp || new Date().toISOString(),
|
|
95
|
+
stats: meta.stats || {},
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
return JSON.stringify(output, null, 2);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Apply a named formatter
|
|
103
|
+
*/
|
|
104
|
+
function applyFormat(text, format = 'plain', meta = {}) {
|
|
105
|
+
switch (format) {
|
|
106
|
+
case 'markdown': return formatMarkdown(text, meta);
|
|
107
|
+
case 'story': return formatStory(text, meta);
|
|
108
|
+
case 'json': return formatJSON(text, meta);
|
|
109
|
+
default: return formatPlain(text, meta);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { applyFormat, formatPlain, formatMarkdown, formatStory, formatJSON };
|