@jacebenson/jsn 4.3.8-dev.58 → 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 +10 -0
- package/package.json +1 -1
- package/skills/servicenow/SKILL.md +2 -0
- package/src/commands/customizations.js +12 -2
- package/src/customizations-report.js +158 -0
- package/src/customizations.js +4 -0
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
|
@@ -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: [] });
|
|
@@ -22,7 +23,9 @@ async function pickReport(app, argv, exclude = null, message = 'Select a customi
|
|
|
22
23
|
return picked?.value ? requireReport(picked.value.run_id) : null;
|
|
23
24
|
}
|
|
24
25
|
function show(app, report, options = {}) {
|
|
25
|
-
|
|
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 {
|
|
@@ -56,6 +59,13 @@ export function customizationsCmd(wrap) {
|
|
|
56
59
|
const report = argv.run_id ? requireReport(argv.run_id) : await pickReport(app, argv);
|
|
57
60
|
if (report) show(app, report, { groupBy: argv.groupBy, filter: argv.filter || '' });
|
|
58
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)] });
|
|
68
|
+
}) })
|
|
59
69
|
.command({ command: 'compare [baseline] [new]', describe: 'Compare two saved captures from the same instance', handler: wrap(async (argv, app) => {
|
|
60
70
|
if (!!argv.baseline !== !!argv.new) throw errUsage('Compare requires exactly two report IDs');
|
|
61
71
|
if (argv.baseline && argv.baseline === argv.new) throw errUsage('Compare requires two different report IDs');
|
|
@@ -67,6 +77,6 @@ export function customizationsCmd(wrap) {
|
|
|
67
77
|
const result = compareReports(baseline, newer);
|
|
68
78
|
app.ok({ ...result, _formatted: formatReportComparison(result) }, { summary: `Customization comparison: ${result.status}`, breadcrumbs: [listHint, showHint(baseline.run_id), showHint(newer.run_id)] });
|
|
69
79
|
}) }),
|
|
70
|
-
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.' })),
|
|
71
81
|
};
|
|
72
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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
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
|
+
}
|
package/src/customizations.js
CHANGED
|
@@ -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
|
|
@@ -316,6 +317,9 @@ function formatReportDetailed(report) {
|
|
|
316
317
|
for (const unknown of item.reversion.unknowns) lines.push(` Reversion unknown: ${unknown}`);
|
|
317
318
|
}
|
|
318
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}`);
|
|
319
323
|
lines.push(` Recommendation: ${item.recommendation}`);
|
|
320
324
|
}
|
|
321
325
|
}
|