@jacebenson/jsn 4.3.8-dev.57 → 4.3.8-dev.59

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/README.md CHANGED
@@ -50,8 +50,11 @@ lifecycle commands and the untested Windows host phases. Docker remains availabl
50
50
  jsn customizations capture --label before-upgrade --customer-scope x_acme_app
51
51
  jsn customizations list
52
52
  jsn customizations show <run_id>
53
+ jsn customizations html <run_id> # writes customizations-<run_id>.html
54
+ jsn customizations html <run_id> --out review.html
53
55
  jsn customizations compare <baseline_id> <new_id> --json
54
56
  jsn customizations show <run_id> --get data.coverage
57
+ jsn customizations show <run_id> --get data.triage
55
58
  ```
56
59
 
57
60
  Capture data access uses Table API GET requests only. Captures never run
@@ -88,6 +91,13 @@ review recommendation. Historical OOTB findings include the known skipped-upgrad
88
91
  impact and explicit unknown baseline, runtime impact and dependencies. No
89
92
  reversion is performed or recommended as safe without further investigation.
90
93
 
94
+ `show` adds a computed `data.triage` view with evidence confidence, the reason for
95
+ the classification and concrete review steps; it does not alter saved captures or
96
+ pretend to score risk. `html` exports a self-contained offline report with search,
97
+ classification filters, grouping, evidence, coverage and links back to records.
98
+ The default output is `customizations-<run_id>.html`; existing files are never
99
+ overwritten. The report does not change ServiceNow records.
100
+
91
101
  Defaults are 100 records per page and 10 pages per source. Adjust `--page-size`
92
102
  and `--max-pages` within the documented limits. Source coverage compares the rows
93
103
  fetched with the Stats API count for the same query and reports both. A page
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.8-dev.57",
3
+ "version": "4.3.8-dev.59",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -79,6 +79,8 @@ Pick the most specific tool for the job. **Never default to eval** — it's the
79
79
  jsn customizations capture --label review --customer-scope x_acme_app --json
80
80
  jsn customizations list --json
81
81
  jsn customizations show <run_id> --get data.categories
82
+ jsn customizations show <run_id> --get data.triage
83
+ jsn customizations html <run_id> [--out review.html]
82
84
  jsn customizations compare <baseline_id> <new_id> --json
83
85
  ```
84
86
 
@@ -3,6 +3,7 @@ import { errUsageHint, errUsage } from '../errors.js';
3
3
  import { canUsePicker } from '../helpers.js';
4
4
  import { paginatedSearch } from '../paginated-search.js';
5
5
  import { captureReport, listReports, getReport, compareReports, formatReport, formatReportList, formatReportComparison } from '../customizations.js';
6
+ import { buildActionableReport, writeCustomizationHtml } from '../customizations-report.js';
6
7
 
7
8
  // Only capture needs an instance. Saved reports never need network access.
8
9
  declareCapabilities('customizations', { noInstance: true, skipDailyChecks: true, mutationSubcommands: [] });
@@ -21,8 +22,10 @@ async function pickReport(app, argv, exclude = null, message = 'Select a customi
21
22
  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
23
  return picked?.value ? requireReport(picked.value.run_id) : null;
23
24
  }
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)] });
25
+ function show(app, report, options = {}) {
26
+ const actionable = buildActionableReport(report);
27
+ const triage = actionable.artifacts.map(({ category, category_label, identity, triage: classification }) => ({ category, category_label, identity, ...classification }));
28
+ app.ok({ ...report, triage, _formatted: formatReport(report, options) }, { summary: `Customization capture ${report.run_id}: ${report.status}`, breadcrumbs: [listHint, showHint(report.run_id), compareHint(report.run_id)] });
26
29
  }
27
30
  export function customizationsCmd(wrap) {
28
31
  return {
@@ -50,9 +53,18 @@ export function customizationsCmd(wrap) {
50
53
  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
54
  }),
52
55
  })
53
- .command({ command: 'show [run_id]', describe: 'Review a saved capture offline', handler: wrap(async (argv, app) => {
56
+ .command({ command: 'show [run_id]', describe: 'Review a saved capture offline', builder: b => b
57
+ .option('group-by', { type: 'string', choices: ['type', 'scope', 'updated-by'], default: 'type', describe: 'Group summary by file type, scope or last updater' })
58
+ .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
59
  const report = argv.run_id ? requireReport(argv.run_id) : await pickReport(app, argv);
55
- if (report) show(app, report);
60
+ if (report) show(app, report, { groupBy: argv.groupBy, filter: argv.filter || '' });
61
+ }) })
62
+ .command({ command: 'html [run_id]', describe: 'Write a searchable, self-contained HTML review of a saved capture', builder: b => b
63
+ .option('out', { type: 'string', describe: 'Output HTML path (must not already exist)' }), handler: wrap(async (argv, app) => {
64
+ const report = argv.run_id ? requireReport(argv.run_id) : await pickReport(app, argv);
65
+ if (!report) return;
66
+ const file = writeCustomizationHtml(report, { out: argv.out });
67
+ app.ok({ run_id: report.run_id, file, artifacts: Object.values(report.categories).reduce((total, items) => total + items.length, 0) }, { summary: `Wrote offline customization review: ${file}`, breadcrumbs: [listHint, showHint(report.run_id)] });
56
68
  }) })
57
69
  .command({ command: 'compare [baseline] [new]', describe: 'Compare two saved captures from the same instance', handler: wrap(async (argv, app) => {
58
70
  if (!!argv.baseline !== !!argv.new) throw errUsage('Compare requires exactly two report IDs');
@@ -65,6 +77,6 @@ export function customizationsCmd(wrap) {
65
77
  const result = compareReports(baseline, newer);
66
78
  app.ok({ ...result, _formatted: formatReportComparison(result) }, { summary: `Customization comparison: ${result.status}`, breadcrumbs: [listHint, showHint(baseline.run_id), showHint(newer.run_id)] });
67
79
  }) }),
68
- handler: wrap(async (_argv, app) => app.ok({ command: 'customizations', subcommands: ['capture', 'list', 'show', 'compare'] }, { summary: 'Use `jsn customizations <subcommand> --help` for details.' })),
80
+ handler: wrap(async (_argv, app) => app.ok({ command: 'customizations', subcommands: ['capture', 'list', 'show', 'html', 'compare'] }, { summary: 'Use `jsn customizations <subcommand> --help` for details.' })),
69
81
  };
70
82
  }
@@ -0,0 +1,158 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const CLASSIFICATIONS = {
5
+ ootb_modifications: {
6
+ label: 'Historical upgrade skip',
7
+ confidence: 'Historical evidence only',
8
+ summary: 'An upgrade record says the platform preserved this artifact. That does not prove it currently differs from the vendor version.',
9
+ next_steps: [
10
+ 'Compare the current artifact with a trusted baseline for this exact release.',
11
+ 'Review later update-set history for a merge, reversion, or further edit.',
12
+ 'Check callers and add a regression test in non-production before considering a revert.',
13
+ ],
14
+ },
15
+ customer_app_artifacts: {
16
+ label: 'Caller-declared customer scope',
17
+ confidence: 'Declaration, not verified ownership',
18
+ summary: 'The scope record matched a scope declared by the person who captured this report. The instance does not verify customer or publisher ownership.',
19
+ next_steps: [
20
+ 'Confirm the application owner and publisher with the responsible team.',
21
+ 'Check whether the artifact is active or referenced, then review its tests and update-set history.',
22
+ 'Treat it as customer-owned only after an owner confirms the declaration.',
23
+ ],
24
+ },
25
+ unclassified: {
26
+ label: 'Unclassified metadata',
27
+ confidence: 'Insufficient evidence',
28
+ summary: 'Visible metadata was found, but this capture could not establish whether it is customer-owned or differs from an out-of-box artifact.',
29
+ next_steps: [
30
+ 'Identify the owner using application records, update-set history, and team knowledge.',
31
+ 'Check current references and expected behavior before changing or deleting it.',
32
+ 'Compare with a trusted vendor baseline only when evidence shows this is vendor-delivered.',
33
+ ],
34
+ },
35
+ };
36
+
37
+ 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());
38
+ const categoryLabel = category => ({ ootb_modifications: 'Historical upgrade skips', customer_app_artifacts: 'Declared customer scopes', unclassified: 'Unclassified metadata' })[category] || humanize(category);
39
+ const itemName = item => {
40
+ const { name = '', update_name: updateName = '', table = '', sys_id: sysId = '' } = item.identity || {};
41
+ if (name && name !== sysId && name !== updateName) return /[_]|[a-z][A-Z]/.test(name) ? humanize(name) : name;
42
+ if (updateName) {
43
+ const prefixes = [table, table.replace(/_[^_]+$/, ''), table.replace(/_[^_]+_[^_]+$/, '')].filter(Boolean);
44
+ const prefix = prefixes.find(candidate => updateName.startsWith(`${candidate}_`));
45
+ return humanize(prefix ? updateName.slice(prefix.length + 1) : updateName) || 'Name unavailable';
46
+ }
47
+ return sysId ? 'Name unavailable' : 'Unnamed artifact';
48
+ };
49
+
50
+ export function classifyCustomization(category) {
51
+ const classification = CLASSIFICATIONS[category] || CLASSIFICATIONS.unclassified;
52
+ return {
53
+ code: CLASSIFICATIONS[category] ? category : 'unclassified',
54
+ label: classification.label,
55
+ confidence: classification.confidence,
56
+ summary: classification.summary,
57
+ next_steps: [...classification.next_steps],
58
+ // There is no verified baseline or dependency graph to support safe automatic remediation.
59
+ automated_remediation: false,
60
+ };
61
+ }
62
+
63
+ function recordUrl(instance, table, sysId) {
64
+ if (!/^[a-z0-9_]+$/i.test(String(table || '')) || !sysId) return null;
65
+ try {
66
+ const url = new URL(`${encodeURIComponent(table)}.do`, `${String(instance).replace(/\/+$/, '')}/`);
67
+ if (!['http:', 'https:'].includes(url.protocol)) return null;
68
+ url.searchParams.set('sys_id', String(sysId));
69
+ return url.href;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ export function buildActionableReport(report) {
76
+ const artifacts = Object.entries(report.categories || {}).flatMap(([category, items]) => items.map(item => ({
77
+ category,
78
+ category_label: categoryLabel(category),
79
+ identity: item.identity || {},
80
+ name: itemName(item),
81
+ table_label: humanize(item.identity?.table || 'unknown'),
82
+ scope: item.scope?.scope || item.scope?.sys_id || '(unknown scope)',
83
+ updated_by: item.identity?.updated_by || '(unknown updater)',
84
+ evidence: item.evidence || [],
85
+ risk: item.risk || { rating: 'unknown', rationale: 'Risk has not been established.' },
86
+ triage: classifyCustomization(category),
87
+ record_url: recordUrl(report.instance, item.identity?.table, item.identity?.sys_id),
88
+ })));
89
+ return {
90
+ run_id: report.run_id,
91
+ label: report.label || '',
92
+ instance: report.instance,
93
+ status: report.status,
94
+ start_time: report.start_time,
95
+ artifacts,
96
+ coverage: report.coverage,
97
+ };
98
+ }
99
+
100
+ const safeJson = value => JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, character => ({ '<': '\\u003c', '>': '\\u003e', '&': '\\u0026', '\u2028': '\\u2028', '\u2029': '\\u2029' })[character]);
101
+ const escapeHtml = value => String(value ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;');
102
+
103
+ export function renderCustomizationHtml(report) {
104
+ const view = buildActionableReport(report);
105
+ const categoryOptions = [...new Set(view.artifacts.map(item => item.category))].map(category => `<option value="${escapeHtml(category)}">${escapeHtml(categoryLabel(category))}</option>`).join('');
106
+ const data = safeJson(view);
107
+ return `<!doctype html>
108
+ <html lang="en">
109
+ <head>
110
+ <meta charset="utf-8">
111
+ <meta name="viewport" content="width=device-width, initial-scale=1">
112
+ <title>Customization review${view.label ? `: ${escapeHtml(view.label)}` : ''}</title>
113
+ <style>
114
+ :root{color-scheme:light;--ink:#202833;--muted:#526173;--line:#d8dee7;--panel:#fff;--wash:#f3f6fa;--blue:#1358a5;--amber:#825300}*{box-sizing:border-box}body{margin:0;background:var(--wash);color:var(--ink);font:16px/1.5 system-ui,sans-serif}main{max-width:1180px;margin:auto;padding:32px 20px 60px}h1,h2,h3,p{margin-top:0}h1{font-size:1.8rem;margin-bottom:4px}h2{font-size:1.25rem;margin:0 0 14px}h3{font-size:1.1rem;margin:0}small,.muted{color:var(--muted)}.intro,.toolbar,.coverage,.group,.card{background:var(--panel);border:1px solid var(--line);border-radius:10px}.intro,.toolbar,.coverage{padding:18px 20px;margin-bottom:16px}.intro p:last-child{margin-bottom:0}.stats{display:flex;flex-wrap:wrap;gap:10px;margin-top:14px}.stat{background:var(--wash);border-radius:8px;padding:10px 14px}.toolbar{display:flex;flex-wrap:wrap;gap:12px;align-items:end;position:sticky;top:0;z-index:2}.control{display:grid;gap:4px}.control.grow{flex:1;min-width:220px}input,select{font:inherit;padding:8px 10px;border:1px solid #aeb9c7;border-radius:6px;background:white;color:var(--ink)}.group{padding:18px;margin:16px 0}.group>h2{border-bottom:1px solid var(--line);padding-bottom:10px}.card{padding:16px;margin:12px 0}.card-head{display:flex;gap:12px;align-items:flex-start;justify-content:space-between}.badge{display:inline-block;border-radius:100px;background:#e9f0fb;color:#183e71;padding:3px 9px;font-size:.83rem;font-weight:650}.confidence{color:var(--amber);font-size:.9rem;font-weight:650}.meta{display:flex;gap:8px 16px;flex-wrap:wrap;margin:10px 0;color:var(--muted);font-size:.92rem}.card h4{margin:12px 0 4px}.card ul{margin:4px 0 12px;padding-left:22px}.card a{color:var(--blue)}.coverage summary{cursor:pointer;font-weight:650}.coverage li{margin:6px 0}.empty{padding:20px;text-align:center;color:var(--muted)}.notice{border-left:4px solid #d39219;padding:8px 12px;background:#fff7e6;margin:12px 0}
115
+ </style>
116
+ </head>
117
+ <body><main>
118
+ <header class="intro"><h1>Customization review</h1><p>${escapeHtml(view.label || view.run_id)} · ${escapeHtml(view.instance)}</p><p class="muted">Status: ${escapeHtml(view.status)}. This report gives review steps, not automatic fixes. Confirm evidence before changing records.</p><div class="stats" id="stats"></div><p class="notice">A historical upgrade skip does not prove a current difference. A scope or updater name does not prove ownership. The broad metadata source is a bounded sample.</p></header>
119
+ <section class="toolbar" aria-label="Report filters"><label class="control grow">Search artifacts<input id="search" type="search" placeholder="Name, table, scope, updater, evidence"></label><label class="control">Classification<select id="category"><option value="">All classifications</option>${categoryOptions}</select></label><label class="control">Group by<select id="group-by"><option value="type">File type</option><option value="scope">Scope</option><option value="updated_by">Updated by</option></select></label></section>
120
+ <section id="results" aria-live="polite"></section>
121
+ <details class="coverage"><summary>Capture coverage and limitations</summary><div id="coverage-content"></div></details>
122
+ <script id="report-data" type="application/json">${data}</script>
123
+ <script>
124
+ 'use strict';
125
+ const report=JSON.parse(document.getElementById('report-data').textContent);
126
+ const results=document.getElementById('results');
127
+ const search=document.getElementById('search');
128
+ const category=document.getElementById('category');
129
+ const groupBy=document.getElementById('group-by');
130
+ const el=(tag,text,className='')=>{const node=document.createElement(tag);if(text!==undefined)node.textContent=text;if(className)node.className=className;return node;};
131
+ const groupName=item=>groupBy.value==='scope'?item.scope:groupBy.value==='updated_by'?item.updated_by:item.table_label;
132
+ const addList=(parent,title,items)=>{parent.append(el('h4',title));const ul=el('ul');for(const text of items)ul.append(el('li',text));parent.append(ul);};
133
+ function card(item){
134
+ const article=el('article',undefined,'card');const head=el('div',undefined,'card-head');const title=el('h3',item.name);head.append(title,el('span',item.triage.confidence,'confidence'));article.append(head);
135
+ article.append(el('p',item.triage.summary));const meta=el('div',undefined,'meta');meta.append(el('span',item.table_label+' · '+(item.identity.sys_id||'ID unavailable')),el('span','Scope: '+item.scope),el('span','Updated by: '+item.updated_by));article.append(meta,el('span',item.category_label,'badge'));
136
+ addList(article,'Evidence',item.evidence.length?item.evidence.map(e=>e.source+': '+e.sys_id+'. '+e.basis):['No artifact evidence was captured.']);addList(article,'Next steps',item.triage.next_steps);article.append(el('p','Risk: '+(item.risk.rating||'unknown')+'. '+(item.risk.rationale||'Not established.')));
137
+ if(item.record_url){const link=el('a','Open record in ServiceNow');link.href=item.record_url;link.rel='noopener noreferrer';link.target='_blank';article.append(link);}return article;
138
+ }
139
+ function render(){
140
+ const term=search.value.trim().toLowerCase();const selected=category.value;
141
+ const filtered=report.artifacts.filter(item=>(!selected||item.category===selected)&&(!term||JSON.stringify(item).toLowerCase().includes(term)));
142
+ results.replaceChildren();if(!filtered.length){results.append(el('p','No artifacts match these filters.','empty'));return;}
143
+ const groups=new Map();for(const item of filtered){const key=groupName(item);if(!groups.has(key))groups.set(key,[]);groups.get(key).push(item);}
144
+ for(const [name,items] of [...groups].sort(([a],[b])=>a.localeCompare(b))){const section=el('section',undefined,'group');section.append(el('h2',name+' ('+items.length+')'));for(const item of items)section.append(card(item));results.append(section);}
145
+ }
146
+ for(const [categoryName,items] of Object.entries(report.artifacts.reduce((groups,item)=>{groups[item.category]=(groups[item.category]||0)+1;return groups;},{})))document.getElementById('stats').append(el('span',categoryName.replaceAll('_',' ')+': '+items,'stat'));
147
+ search.addEventListener('input',render);category.addEventListener('change',render);groupBy.addEventListener('change',render);render();
148
+ const coverage=document.getElementById('coverage-content');const ul=el('ul');for(const [source,details] of Object.entries(report.coverage.sources||{})){ul.append(el('li',source+': '+details.status+', '+details.fetched+'/'+(details.available??'unknown')+' rows. '+details.reason));}coverage.append(ul);for(const note of report.coverage.limitations||[])coverage.append(el('p',note));
149
+ </script>
150
+ </main></body></html>\n`;
151
+ }
152
+
153
+ export function writeCustomizationHtml(report, { out } = {}) {
154
+ const runId = String(report.run_id || 'capture').replace(/[^a-zA-Z0-9_-]/g, '_');
155
+ const target = path.resolve(out || path.join(process.cwd(), `customizations-${runId}.html`));
156
+ fs.writeFileSync(target, renderCustomizationHtml(report), { mode: 0o600, flag: 'wx' });
157
+ return target;
158
+ }
@@ -5,6 +5,7 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import crypto from 'node:crypto';
7
7
  import { getVersion } from './commands/version.js';
8
+ import { classifyCustomization } from './customizations-report.js';
8
9
 
9
10
  // Raw provider choices: 4 = Skipped, 104 = Skipped (second pass). Other skip
10
11
  // dispositions do not establish a preserved customization, so the upgrade
@@ -14,7 +15,7 @@ export const SKIP_DISPOSITIONS = ['4', '104'];
14
15
  const SKIP_QUERY = `dispositionIN${SKIP_DISPOSITIONS.join(',')}`;
15
16
  const SOURCES = {
16
17
  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' },
18
+ 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
19
  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
20
  };
20
21
  // The broad sys_metadata read is an arbitrary sys_id-ordered slice of a table
@@ -27,6 +28,19 @@ export const RESOLUTION_CHUNK_SIZE = 25;
27
28
  const RESOLUTION_MAX_PAGES = 3;
28
29
  const value = v => v && typeof v === 'object' ? v.value ?? '' : v ?? '';
29
30
  const text = v => String(value(v));
31
+ 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());
32
+ const friendlyTable = table => humanize(table) || 'Unknown type';
33
+ const friendlyArtifactName = (name, updateName, table, sysId) => {
34
+ if (name && name !== sysId && name !== updateName) return /[_]|[a-z][A-Z]/.test(name) ? humanize(name) : name;
35
+ if (updateName) {
36
+ const prefixes = [table, table.replace(/_[^_]+$/, ''), table.replace(/_[^_]+_[^_]+$/, '')].filter(Boolean);
37
+ const prefix = prefixes.find(candidate => updateName.startsWith(`${candidate}_`));
38
+ const fallback = prefix ? updateName.slice(prefix.length + 1) : updateName;
39
+ return humanize(fallback) || 'Name unavailable';
40
+ }
41
+ return sysId ? 'Name unavailable' : 'Unnamed artifact';
42
+ };
43
+ const displayArtifactName = identity => friendlyArtifactName(identity?.name || '', identity?.update_name || '', identity?.table || '', identity?.sys_id || '');
30
44
 
31
45
  // How many rows are available for the same query? The Stats API count is the
32
46
  // only evidence that lets a capture claim completeness; without it coverage
@@ -281,7 +295,7 @@ export function compareReports(baseline, newer) {
281
295
  unknowns };
282
296
  }
283
297
 
284
- export function formatReport(report) {
298
+ function formatReportDetailed(report) {
285
299
  const lines = [`Customization capture ${report.run_id}${report.label ? ` (${report.label})` : ''}`, `Instance: ${report.instance}`, `Status: ${report.status}`, '', 'COVERAGE'];
286
300
  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
301
  for (const limitation of report.coverage.limitations) lines.push(`Unknown: ${limitation}`);
@@ -295,7 +309,7 @@ export function formatReport(report) {
295
309
  for (const [category, items] of Object.entries(report.categories)) {
296
310
  lines.push('', `${category.toUpperCase()} (${items.length})`);
297
311
  for (const item of items) {
298
- lines.push(`${item.identity.table}:${item.identity.sys_id} ${item.identity.name}`, ` Scope: ${item.scope.scope || item.scope.sys_id || 'unknown'}`, ` Risk: ${item.risk.rating}. ${item.risk.rationale}`);
312
+ 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
313
  for (const evidence of item.evidence) lines.push(` Evidence: ${evidence.source}:${evidence.sys_id}. ${evidence.basis}`);
300
314
  if (item.reversion) {
301
315
  for (const impact of item.reversion.known_impact) lines.push(` Known impact: ${impact}`);
@@ -303,11 +317,51 @@ export function formatReport(report) {
303
317
  for (const unknown of item.reversion.unknowns) lines.push(` Reversion unknown: ${unknown}`);
304
318
  }
305
319
  for (const unknown of item.unknowns) lines.push(` Unknown: ${unknown}`);
320
+ const triage = classifyCustomization(category);
321
+ lines.push(` Triage: ${triage.label} (${triage.confidence})`, ` Why: ${triage.summary}`);
322
+ for (const step of triage.next_steps) lines.push(` Next: ${step}`);
306
323
  lines.push(` Recommendation: ${item.recommendation}`);
307
324
  }
308
325
  }
309
326
  return lines.join('\n') + '\n';
310
327
  }
328
+ export const CUSTOMIZATION_GROUPS = ['type', 'scope', 'updated-by'];
329
+ function groupKey(item, groupBy) {
330
+ if (groupBy === 'scope') return item.scope?.scope || item.scope?.sys_id || '(unknown scope)';
331
+ if (groupBy === 'updated-by') return item.identity?.updated_by || '(unknown updater)';
332
+ return item.identity?.table || 'unknown';
333
+ }
334
+ export function formatReport(report, { groupBy = 'type', filter = '' } = {}) {
335
+ if (!CUSTOMIZATION_GROUPS.includes(groupBy)) throw new Error(`group-by must be one of: ${CUSTOMIZATION_GROUPS.join(', ')}`);
336
+ if (filter) {
337
+ const filtered = structuredClone(report);
338
+ for (const [category, items] of Object.entries(filtered.categories)) filtered.categories[category] = items.filter(item => {
339
+ const key = groupKey(item, groupBy);
340
+ return key.toLowerCase() === String(filter).toLowerCase() || (groupBy === 'type' && friendlyTable(key).toLowerCase() === String(filter).toLowerCase());
341
+ });
342
+ const text = formatReportDetailed(filtered);
343
+ return `${text}\nFiltered by ${groupBy}: ${filter}\n`;
344
+ }
345
+ const items = Object.values(report.categories).flat();
346
+ const groups = new Map();
347
+ for (const item of items) {
348
+ const key = groupKey(item, groupBy);
349
+ groups.set(key, (groups.get(key) || 0) + 1);
350
+ }
351
+ const label = { type: 'file type', scope: 'scope', 'updated-by': 'updated by' }[groupBy];
352
+ const lines = [`Customization capture ${report.run_id}${report.label ? ` (${report.label})` : ''}`, `Instance: ${report.instance}`, `Status: ${report.status}`, `Artifacts: ${items.length}`, '', `GROUPED BY ${label.toUpperCase()}`];
353
+ if (!groups.size) lines.push('(no artifacts)');
354
+ for (const [key, count] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
355
+ 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(', ');
356
+ lines.push(`${groupBy === 'type' ? friendlyTable(key) : key} (${count}) ${byCategory}`);
357
+ }
358
+ 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`);
359
+ lines.push('', 'Coverage and limitations are shared across these artifacts.');
360
+ 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})` : ''}`);
361
+ 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.');
362
+ return lines.join('\n') + '\n';
363
+ }
364
+
311
365
  export function formatReportList(runs) {
312
366
  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
367
  }
@@ -408,7 +462,7 @@ export async function captureCustomizations({ sdk, customerScopes = [], pageSize
408
462
  const scopeId = text(row.sys_scope);
409
463
  const scope = scopes.get(scopeId);
410
464
  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) };
465
+ 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
466
  const evidence = [{ source: 'sys_metadata', sys_id: identity.sys_id, basis: 'Visible application metadata record', updated_on: text(row.sys_updated_on) }];
413
467
  const item = {
414
468
  identity, scope: { sys_id: scopeId || null, name: text(scope?.name) || null, scope: scopeName || null }, evidence,