@jacebenson/jsn 4.3.8-dev.57 → 4.3.8-dev.58
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/package.json
CHANGED
|
@@ -21,8 +21,8 @@ async function pickReport(app, argv, exclude = null, message = 'Select a customi
|
|
|
21
21
|
const picked = await paginatedSearch({ message, totalCount: runs.length, pageSize: 10, source: async (term = '') => runs.filter(run => `${run.run_id} ${run.label} ${run.instance}`.toLowerCase().includes(term.toLowerCase())).map(run => ({ name: `${run.run_id} ${run.label || '(unlabeled)'} ${run.instance}`, value: run })) });
|
|
22
22
|
return picked?.value ? requireReport(picked.value.run_id) : null;
|
|
23
23
|
}
|
|
24
|
-
function show(app, report) {
|
|
25
|
-
app.ok({ ...report, _formatted: formatReport(report) }, { summary: `Customization capture ${report.run_id}: ${report.status}`, breadcrumbs: [listHint, showHint(report.run_id), compareHint(report.run_id)] });
|
|
24
|
+
function show(app, report, options = {}) {
|
|
25
|
+
app.ok({ ...report, _formatted: formatReport(report, options) }, { summary: `Customization capture ${report.run_id}: ${report.status}`, breadcrumbs: [listHint, showHint(report.run_id), compareHint(report.run_id)] });
|
|
26
26
|
}
|
|
27
27
|
export function customizationsCmd(wrap) {
|
|
28
28
|
return {
|
|
@@ -50,9 +50,11 @@ export function customizationsCmd(wrap) {
|
|
|
50
50
|
app.ok({ runs, count: runs.length, _formatted: formatReportList(runs) }, { summary: `${runs.length} customization capture(s)`, breadcrumbs: [listHint, ...(runs[0] ? [showHint(runs[0].run_id)] : [])] });
|
|
51
51
|
}),
|
|
52
52
|
})
|
|
53
|
-
.command({ command: 'show [run_id]', describe: 'Review a saved capture offline',
|
|
53
|
+
.command({ command: 'show [run_id]', describe: 'Review a saved capture offline', builder: b => b
|
|
54
|
+
.option('group-by', { type: 'string', choices: ['type', 'scope', 'updated-by'], default: 'type', describe: 'Group summary by file type, scope or last updater' })
|
|
55
|
+
.option('filter', { type: 'string', describe: 'Expand a group shown in the summary (friendly or table name for file types)' }), handler: wrap(async (argv, app) => {
|
|
54
56
|
const report = argv.run_id ? requireReport(argv.run_id) : await pickReport(app, argv);
|
|
55
|
-
if (report) show(app, report);
|
|
57
|
+
if (report) show(app, report, { groupBy: argv.groupBy, filter: argv.filter || '' });
|
|
56
58
|
}) })
|
|
57
59
|
.command({ command: 'compare [baseline] [new]', describe: 'Compare two saved captures from the same instance', handler: wrap(async (argv, app) => {
|
|
58
60
|
if (!!argv.baseline !== !!argv.new) throw errUsage('Compare requires exactly two report IDs');
|
package/src/customizations.js
CHANGED
|
@@ -14,7 +14,7 @@ export const SKIP_DISPOSITIONS = ['4', '104'];
|
|
|
14
14
|
const SKIP_QUERY = `dispositionIN${SKIP_DISPOSITIONS.join(',')}`;
|
|
15
15
|
const SOURCES = {
|
|
16
16
|
sys_scope: { fields: 'sys_id,name,scope,sys_class_name', query: 'ORDERBYsys_id', countQuery: '', kind: 'inventory read' },
|
|
17
|
-
sys_metadata: { fields: 'sys_id,sys_name,sys_class_name,sys_scope,sys_update_name,sys_updated_on', query: 'ORDERBYsys_id', countQuery: '', kind: 'bounded sample' },
|
|
17
|
+
sys_metadata: { fields: 'sys_id,sys_name,sys_class_name,sys_scope,sys_update_name,sys_updated_on,sys_updated_by', query: 'ORDERBYsys_id', countQuery: '', kind: 'bounded sample' },
|
|
18
18
|
sys_upgrade_history_log: { fields: 'sys_id,file_name,disposition,resolution_status,sys_updated_on', query: `${SKIP_QUERY}^ORDERBYsys_id`, countQuery: SKIP_QUERY, kind: 'inventory read' },
|
|
19
19
|
};
|
|
20
20
|
// The broad sys_metadata read is an arbitrary sys_id-ordered slice of a table
|
|
@@ -27,6 +27,19 @@ export const RESOLUTION_CHUNK_SIZE = 25;
|
|
|
27
27
|
const RESOLUTION_MAX_PAGES = 3;
|
|
28
28
|
const value = v => v && typeof v === 'object' ? v.value ?? '' : v ?? '';
|
|
29
29
|
const text = v => String(value(v));
|
|
30
|
+
const humanize = value => String(value || '').replace(/^sys_/, '').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replaceAll('_', ' ').replace(/\bui\b/gi, 'UI').replace(/\b[a-z]/g, letter => letter.toUpperCase());
|
|
31
|
+
const friendlyTable = table => humanize(table) || 'Unknown type';
|
|
32
|
+
const friendlyArtifactName = (name, updateName, table, sysId) => {
|
|
33
|
+
if (name && name !== sysId && name !== updateName) return /[_]|[a-z][A-Z]/.test(name) ? humanize(name) : name;
|
|
34
|
+
if (updateName) {
|
|
35
|
+
const prefixes = [table, table.replace(/_[^_]+$/, ''), table.replace(/_[^_]+_[^_]+$/, '')].filter(Boolean);
|
|
36
|
+
const prefix = prefixes.find(candidate => updateName.startsWith(`${candidate}_`));
|
|
37
|
+
const fallback = prefix ? updateName.slice(prefix.length + 1) : updateName;
|
|
38
|
+
return humanize(fallback) || 'Name unavailable';
|
|
39
|
+
}
|
|
40
|
+
return sysId ? 'Name unavailable' : 'Unnamed artifact';
|
|
41
|
+
};
|
|
42
|
+
const displayArtifactName = identity => friendlyArtifactName(identity?.name || '', identity?.update_name || '', identity?.table || '', identity?.sys_id || '');
|
|
30
43
|
|
|
31
44
|
// How many rows are available for the same query? The Stats API count is the
|
|
32
45
|
// only evidence that lets a capture claim completeness; without it coverage
|
|
@@ -281,7 +294,7 @@ export function compareReports(baseline, newer) {
|
|
|
281
294
|
unknowns };
|
|
282
295
|
}
|
|
283
296
|
|
|
284
|
-
|
|
297
|
+
function formatReportDetailed(report) {
|
|
285
298
|
const lines = [`Customization capture ${report.run_id}${report.label ? ` (${report.label})` : ''}`, `Instance: ${report.instance}`, `Status: ${report.status}`, '', 'COVERAGE'];
|
|
286
299
|
for (const [source, coverage] of Object.entries(report.coverage.sources)) lines.push(`${source}${coverage.kind ? ` [${coverage.kind}]` : ''}: ${coverage.status}, ${coverage.fetched} fetched of ${coverage.available === null || coverage.available === undefined ? 'unknown' : coverage.available} available. ${coverage.reason}`);
|
|
287
300
|
for (const limitation of report.coverage.limitations) lines.push(`Unknown: ${limitation}`);
|
|
@@ -295,7 +308,7 @@ export function formatReport(report) {
|
|
|
295
308
|
for (const [category, items] of Object.entries(report.categories)) {
|
|
296
309
|
lines.push('', `${category.toUpperCase()} (${items.length})`);
|
|
297
310
|
for (const item of items) {
|
|
298
|
-
lines.push(`${item.identity.table}:${item.identity.sys_id} ${item.identity
|
|
311
|
+
lines.push(`${friendlyTable(item.identity.table)}:${item.identity.sys_id} ${displayArtifactName(item.identity)}`, ` Scope: ${item.scope.scope || item.scope.sys_id || 'unknown'}`, ` Risk: ${item.risk.rating}. ${item.risk.rationale}`);
|
|
299
312
|
for (const evidence of item.evidence) lines.push(` Evidence: ${evidence.source}:${evidence.sys_id}. ${evidence.basis}`);
|
|
300
313
|
if (item.reversion) {
|
|
301
314
|
for (const impact of item.reversion.known_impact) lines.push(` Known impact: ${impact}`);
|
|
@@ -308,6 +321,43 @@ export function formatReport(report) {
|
|
|
308
321
|
}
|
|
309
322
|
return lines.join('\n') + '\n';
|
|
310
323
|
}
|
|
324
|
+
export const CUSTOMIZATION_GROUPS = ['type', 'scope', 'updated-by'];
|
|
325
|
+
function groupKey(item, groupBy) {
|
|
326
|
+
if (groupBy === 'scope') return item.scope?.scope || item.scope?.sys_id || '(unknown scope)';
|
|
327
|
+
if (groupBy === 'updated-by') return item.identity?.updated_by || '(unknown updater)';
|
|
328
|
+
return item.identity?.table || 'unknown';
|
|
329
|
+
}
|
|
330
|
+
export function formatReport(report, { groupBy = 'type', filter = '' } = {}) {
|
|
331
|
+
if (!CUSTOMIZATION_GROUPS.includes(groupBy)) throw new Error(`group-by must be one of: ${CUSTOMIZATION_GROUPS.join(', ')}`);
|
|
332
|
+
if (filter) {
|
|
333
|
+
const filtered = structuredClone(report);
|
|
334
|
+
for (const [category, items] of Object.entries(filtered.categories)) filtered.categories[category] = items.filter(item => {
|
|
335
|
+
const key = groupKey(item, groupBy);
|
|
336
|
+
return key.toLowerCase() === String(filter).toLowerCase() || (groupBy === 'type' && friendlyTable(key).toLowerCase() === String(filter).toLowerCase());
|
|
337
|
+
});
|
|
338
|
+
const text = formatReportDetailed(filtered);
|
|
339
|
+
return `${text}\nFiltered by ${groupBy}: ${filter}\n`;
|
|
340
|
+
}
|
|
341
|
+
const items = Object.values(report.categories).flat();
|
|
342
|
+
const groups = new Map();
|
|
343
|
+
for (const item of items) {
|
|
344
|
+
const key = groupKey(item, groupBy);
|
|
345
|
+
groups.set(key, (groups.get(key) || 0) + 1);
|
|
346
|
+
}
|
|
347
|
+
const label = { type: 'file type', scope: 'scope', 'updated-by': 'updated by' }[groupBy];
|
|
348
|
+
const lines = [`Customization capture ${report.run_id}${report.label ? ` (${report.label})` : ''}`, `Instance: ${report.instance}`, `Status: ${report.status}`, `Artifacts: ${items.length}`, '', `GROUPED BY ${label.toUpperCase()}`];
|
|
349
|
+
if (!groups.size) lines.push('(no artifacts)');
|
|
350
|
+
for (const [key, count] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
351
|
+
const byCategory = Object.entries(report.categories).map(([category, records]) => [category.replaceAll('_', ' '), records.filter(item => groupKey(item, groupBy) === key).length]).filter(([, total]) => total > 0).map(([category, total]) => `${category} ${total}`).join(', ');
|
|
352
|
+
lines.push(`${groupBy === 'type' ? friendlyTable(key) : key} (${count}) ${byCategory}`);
|
|
353
|
+
}
|
|
354
|
+
lines.push('', 'Expand a group with --filter <group-name>. File-type filters accept the friendly label or table name. Regroup with --group-by scope or --group-by updated-by.', 'Examples:', ` jsn customizations show ${report.run_id} --filter "Script Include"`, ` jsn customizations show ${report.run_id} --group-by scope`, ` jsn customizations show ${report.run_id} --group-by updated-by`);
|
|
355
|
+
lines.push('', 'Coverage and limitations are shared across these artifacts.');
|
|
356
|
+
for (const [source, coverage] of Object.entries(report.coverage.sources)) lines.push(`${source}: ${coverage.status}, ${coverage.fetched}/${coverage.available ?? 'unknown'} rows${coverage.kind ? ` (${coverage.kind})` : ''}`);
|
|
357
|
+
lines.push('Limits: metadata is a bounded sample; skipped-upgrade evidence is historical, not proof of a current OOTB difference. Scope and updater are clues, not proof of ownership.');
|
|
358
|
+
return lines.join('\n') + '\n';
|
|
359
|
+
}
|
|
360
|
+
|
|
311
361
|
export function formatReportList(runs) {
|
|
312
362
|
return ['RUN ID STATUS LABEL INSTANCE', ...runs.map(run => `${run.run_id} ${run.status} ${run.label || '(unlabeled)'} ${run.instance}`), ...(runs.length ? [] : ['(no customization captures)'])].join('\n') + '\n';
|
|
313
363
|
}
|
|
@@ -408,7 +458,7 @@ export async function captureCustomizations({ sdk, customerScopes = [], pageSize
|
|
|
408
458
|
const scopeId = text(row.sys_scope);
|
|
409
459
|
const scope = scopes.get(scopeId);
|
|
410
460
|
const scopeName = text(scope?.scope);
|
|
411
|
-
const identity = { sys_id: text(row.sys_id), table: text(row.sys_class_name) || 'unknown', name: text(row.sys_name) || text(row.sys_update_name) || text(row.sys_id), update_name: text(row.sys_update_name) };
|
|
461
|
+
const identity = { sys_id: text(row.sys_id), table: text(row.sys_class_name) || 'unknown', name: text(row.sys_name) || text(row.sys_update_name) || text(row.sys_id), update_name: text(row.sys_update_name), updated_by: text(row.sys_updated_by) || null };
|
|
412
462
|
const evidence = [{ source: 'sys_metadata', sys_id: identity.sys_id, basis: 'Visible application metadata record', updated_on: text(row.sys_updated_on) }];
|
|
413
463
|
const item = {
|
|
414
464
|
identity, scope: { sys_id: scopeId || null, name: text(scope?.name) || null, scope: scopeName || null }, evidence,
|