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/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 };