@jacebenson/jsn 4.3.8 → 4.3.9-dev.62
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 +11 -417
- package/package.json +4 -2
- package/skills/servicenow/SKILL.md +54 -0
- package/src/cli.js +4 -0
- package/src/commands/actions.js +89 -2
- package/src/commands/catalog.js +2 -2
- package/src/commands/customizations.js +82 -0
- package/src/commands/flows.js +28 -6
- package/src/commands/skipped-updates.js +102 -0
- package/src/commands/updatesets.js +91 -31
- package/src/context.js +16 -25
- package/src/customizations-report.js +158 -0
- package/src/customizations.js +517 -0
- package/src/flow-create.js +137 -71
- package/src/flow-designer-interactive.js +2 -2
- package/src/flow-publish.js +15 -3
- package/src/flow-safe-edit.js +48 -0
- package/src/impact-analysis.js +67 -0
- package/src/sdk.js +33 -2
- package/src/skipped-update-provider.js +45 -0
- package/src/skipped-updates.js +177 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
// Read-only metadata inventory. Upgrade skips are historical evidence, not a
|
|
2
|
+
// current baseline diff. Scope ownership must be declared, never inferred.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import { getVersion } from './commands/version.js';
|
|
8
|
+
import { classifyCustomization } from './customizations-report.js';
|
|
9
|
+
|
|
10
|
+
// Raw provider choices: 4 = Skipped, 104 = Skipped (second pass). Other skip
|
|
11
|
+
// dispositions do not establish a preserved customization, so the upgrade
|
|
12
|
+
// history read is filtered server-side and a bounded budget spends its pages
|
|
13
|
+
// on preserved-customization evidence instead of arbitrary history rows.
|
|
14
|
+
export const SKIP_DISPOSITIONS = ['4', '104'];
|
|
15
|
+
const SKIP_QUERY = `dispositionIN${SKIP_DISPOSITIONS.join(',')}`;
|
|
16
|
+
const SOURCES = {
|
|
17
|
+
sys_scope: { fields: 'sys_id,name,scope,sys_class_name', query: 'ORDERBYsys_id', countQuery: '', kind: 'inventory read' },
|
|
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' },
|
|
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' },
|
|
20
|
+
};
|
|
21
|
+
// The broad sys_metadata read is an arbitrary sys_id-ordered slice of a table
|
|
22
|
+
// that can hold hundreds of thousands of rows, so it can only ever be a bounded
|
|
23
|
+
// sample of the instance. Skipped-upgrade artifacts are therefore resolved
|
|
24
|
+
// directly by the update names the filtered history read observed, so every
|
|
25
|
+
// skipped artifact visible on the instance reaches the inventory.
|
|
26
|
+
export const RESOLUTION_SOURCE = 'sys_metadata_name_resolution';
|
|
27
|
+
export const RESOLUTION_CHUNK_SIZE = 25;
|
|
28
|
+
const RESOLUTION_MAX_PAGES = 3;
|
|
29
|
+
const value = v => v && typeof v === 'object' ? v.value ?? '' : v ?? '';
|
|
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 || '');
|
|
44
|
+
|
|
45
|
+
// How many rows are available for the same query? The Stats API count is the
|
|
46
|
+
// only evidence that lets a capture claim completeness; without it coverage
|
|
47
|
+
// must stay unknown rather than implied by a page that happened to end.
|
|
48
|
+
async function availableCount(sdk, table, countQuery, fetched) {
|
|
49
|
+
if (typeof sdk.aggregateCount !== 'function') return null;
|
|
50
|
+
let count;
|
|
51
|
+
try {
|
|
52
|
+
count = await sdk.aggregateCount(table, countQuery);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (!Number.isFinite(count) || count < 0) return null;
|
|
57
|
+
// A zero count with rows in hand means the count is unusable, not that the
|
|
58
|
+
// table is empty.
|
|
59
|
+
if (count === 0 && fetched > 0) return null;
|
|
60
|
+
return Math.floor(count);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A per-query count with no zero/short-page guessing: a count of 0 for a name
|
|
64
|
+
// chunk means no visible row matched that chunk, which is real evidence.
|
|
65
|
+
async function statsCount(sdk, table, query) {
|
|
66
|
+
if (typeof sdk.aggregateCount !== 'function') return null;
|
|
67
|
+
try {
|
|
68
|
+
const count = await sdk.aggregateCount(table, query);
|
|
69
|
+
return Number.isFinite(count) && count >= 0 ? Math.floor(count) : null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readSource(sdk, table, { fields, query, countQuery, kind }, pageSize, maxPages) {
|
|
76
|
+
const rows = [];
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
const coverage = {
|
|
79
|
+
status: 'limited', fetched: 0, available: null, pages: 0, duplicate_rows: 0, missing_identity: 0,
|
|
80
|
+
query, ...(kind ? { kind } : {}), reason: 'Page limit reached; remaining records unknown.',
|
|
81
|
+
};
|
|
82
|
+
// The strict seam rejects a 2xx body without a result array, so a malformed
|
|
83
|
+
// envelope becomes an unavailable source instead of an empty inventory.
|
|
84
|
+
const listFn = typeof sdk.listStrict === 'function' ? sdk.listStrict.bind(sdk) : sdk.list.bind(sdk);
|
|
85
|
+
let failure = '';
|
|
86
|
+
try {
|
|
87
|
+
for (let page = 0; page < maxPages; page++) {
|
|
88
|
+
const batch = await listFn(table, {
|
|
89
|
+
sysparm_fields: fields, sysparm_query: query,
|
|
90
|
+
sysparm_display_value: 'all', sysparm_exclude_reference_link: 'true',
|
|
91
|
+
sysparm_limit: String(pageSize), sysparm_offset: String(page * pageSize),
|
|
92
|
+
}, { readOnly: true });
|
|
93
|
+
coverage.pages++;
|
|
94
|
+
if (!Array.isArray(batch)) throw new Error('Invalid table response');
|
|
95
|
+
for (const row of batch) {
|
|
96
|
+
const id = text(row.sys_id);
|
|
97
|
+
if (!id) { coverage.missing_identity++; continue; }
|
|
98
|
+
if (seen.has(id)) { coverage.duplicate_rows++; continue; }
|
|
99
|
+
seen.add(id);
|
|
100
|
+
rows.push(row);
|
|
101
|
+
const required = table === 'sys_metadata' ? ['sys_class_name', 'sys_scope', 'sys_update_name'] : table === 'sys_scope' ? ['scope'] : ['file_name', 'disposition', 'resolution_status'];
|
|
102
|
+
if (required.some(field => !Object.hasOwn(row, field))) coverage.missing_identity++;
|
|
103
|
+
}
|
|
104
|
+
coverage.fetched = rows.length;
|
|
105
|
+
// A short page is not exhaustion: ServiceNow returns short offset pages
|
|
106
|
+
// on large tables. Keep reading within the page budget.
|
|
107
|
+
if (batch.length === 0) break;
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
failure = String(error?.message || error);
|
|
111
|
+
}
|
|
112
|
+
if (failure) {
|
|
113
|
+
coverage.status = /401|403|permission|forbidden|access denied/i.test(failure) ? 'permission_denied' : 'unavailable';
|
|
114
|
+
coverage.reason = 'Table read failed; partial results retained. Authentication, ACLs, table availability or network access may limit coverage.';
|
|
115
|
+
return { rows, coverage };
|
|
116
|
+
}
|
|
117
|
+
coverage.available = await availableCount(sdk, table, countQuery, rows.length);
|
|
118
|
+
const duplicateNote = coverage.duplicate_rows
|
|
119
|
+
? ` Pagination returned ${coverage.duplicate_rows} repeated identit${coverage.duplicate_rows === 1 ? 'y' : 'ies'}; offset paging on large tables can overlap or skip.`
|
|
120
|
+
: '';
|
|
121
|
+
if (coverage.missing_identity) {
|
|
122
|
+
coverage.status = 'missing_fields';
|
|
123
|
+
coverage.reason = 'Some records omitted identity or classification fields; field ACLs or malformed responses may hide evidence.';
|
|
124
|
+
} else if (coverage.available !== null && coverage.fetched >= coverage.available) {
|
|
125
|
+
coverage.status = 'exhausted';
|
|
126
|
+
coverage.reason = `Read all ${coverage.available} rows the Stats API reports for this query. Both the count and the rows reflect visible ACL-filtered records.`;
|
|
127
|
+
} else if (coverage.available !== null) {
|
|
128
|
+
coverage.status = 'limited';
|
|
129
|
+
coverage.reason = `Read ${coverage.fetched} of ${coverage.available} rows the Stats API reports for this query; ${coverage.available - coverage.fetched} row(s) were not read within the page budget.`;
|
|
130
|
+
} else if (coverage.duplicate_rows) {
|
|
131
|
+
coverage.status = 'unstable';
|
|
132
|
+
coverage.reason = 'Repeated identities across pages; pagination may be ignored or records changed during capture. The Stats API count for this query was unavailable, so completeness is unknown.';
|
|
133
|
+
} else {
|
|
134
|
+
coverage.status = 'limited';
|
|
135
|
+
coverage.reason = 'The Stats API count for this query was unavailable, so visible-result completeness cannot be claimed.';
|
|
136
|
+
}
|
|
137
|
+
coverage.reason += duplicateNote;
|
|
138
|
+
return { rows, coverage };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Resolve observed skipped-upgrade update names against visible sys_metadata
|
|
142
|
+
// with chunked GET-only `sys_update_nameIN<chunk>` queries. The broad sample
|
|
143
|
+
// cannot be relied on for this: it is an arbitrary sys_id-ordered slice, so an
|
|
144
|
+
// inventory claim needs the artifacts the history read actually named.
|
|
145
|
+
// Read failures are reported per chunk instead of being hidden behind the
|
|
146
|
+
// sample, and completeness stays anchored to the Stats API count for the exact
|
|
147
|
+
// name queries used, never to a page that happened to end.
|
|
148
|
+
async function resolveUpdateNames(sdk, names) {
|
|
149
|
+
const requested = [...new Set(names)].sort();
|
|
150
|
+
const rows = [];
|
|
151
|
+
const seen = new Set();
|
|
152
|
+
const resolved = new Set();
|
|
153
|
+
const coverage = {
|
|
154
|
+
kind: 'targeted name resolution', status: 'limited', fetched: 0, available: null, pages: 0, chunks: 0,
|
|
155
|
+
query: 'sys_update_nameIN<observed skipped update names>', requested_names: requested.length,
|
|
156
|
+
resolved_names: 0, unresolved_names: 0, failed_chunks: 0, reason: '',
|
|
157
|
+
};
|
|
158
|
+
if (!requested.length) {
|
|
159
|
+
coverage.query = '';
|
|
160
|
+
coverage.reason = 'No skipped upgrade rows were observed, so no update names required resolution.';
|
|
161
|
+
return { rows, resolved, coverage };
|
|
162
|
+
}
|
|
163
|
+
const listFn = typeof sdk.listStrict === 'function' ? sdk.listStrict.bind(sdk) : sdk.list.bind(sdk);
|
|
164
|
+
const failures = [];
|
|
165
|
+
let countSum = 0;
|
|
166
|
+
let countKnown = true;
|
|
167
|
+
for (let start = 0; start < requested.length; start += RESOLUTION_CHUNK_SIZE) {
|
|
168
|
+
const chunk = requested.slice(start, start + RESOLUTION_CHUNK_SIZE);
|
|
169
|
+
const limit = Math.max(chunk.length, 1);
|
|
170
|
+
const query = `sys_update_nameIN${chunk.join(',')}`;
|
|
171
|
+
coverage.chunks++;
|
|
172
|
+
try {
|
|
173
|
+
for (let page = 0; page < RESOLUTION_MAX_PAGES; page++) {
|
|
174
|
+
const batch = await listFn('sys_metadata', {
|
|
175
|
+
sysparm_fields: SOURCES.sys_metadata.fields, sysparm_query: `${query}^ORDERBYsys_id`,
|
|
176
|
+
sysparm_display_value: 'all', sysparm_exclude_reference_link: 'true',
|
|
177
|
+
sysparm_limit: String(limit), sysparm_offset: String(page * limit),
|
|
178
|
+
}, { readOnly: true });
|
|
179
|
+
coverage.pages++;
|
|
180
|
+
if (!Array.isArray(batch)) throw new Error('Invalid table response');
|
|
181
|
+
for (const row of batch) {
|
|
182
|
+
const updateName = text(row.sys_update_name);
|
|
183
|
+
if (updateName && chunk.includes(updateName)) resolved.add(updateName);
|
|
184
|
+
const id = text(row.sys_id);
|
|
185
|
+
if (!id || seen.has(id)) continue;
|
|
186
|
+
seen.add(id);
|
|
187
|
+
rows.push(row);
|
|
188
|
+
}
|
|
189
|
+
coverage.fetched = rows.length;
|
|
190
|
+
if (batch.length === 0) break;
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
failures.push(String(error?.message || error));
|
|
194
|
+
}
|
|
195
|
+
const counted = await statsCount(sdk, 'sys_metadata', query);
|
|
196
|
+
if (counted === null) countKnown = false;
|
|
197
|
+
else countSum += counted;
|
|
198
|
+
}
|
|
199
|
+
coverage.available = countKnown ? countSum : null;
|
|
200
|
+
coverage.resolved_names = resolved.size;
|
|
201
|
+
coverage.unresolved_names = requested.length - resolved.size;
|
|
202
|
+
if (failures.length) {
|
|
203
|
+
coverage.status = failures.some(message => /401|403|permission|forbidden|access denied/i.test(message)) ? 'permission_denied' : 'unavailable';
|
|
204
|
+
coverage.failed_chunks = failures.length;
|
|
205
|
+
coverage.reason = `${failures.length} of ${coverage.chunks} update-name lookup chunk(s) failed; rows resolved by the remaining chunks were retained, but some observed skipped artifacts may be missing from this inventory. ACL visibility or a read error may limit this source.`;
|
|
206
|
+
} else if (coverage.available !== null && coverage.fetched >= coverage.available) {
|
|
207
|
+
coverage.status = 'exhausted';
|
|
208
|
+
coverage.reason = `Resolved ${coverage.resolved_names} of ${requested.length} observed skipped update names in ${coverage.chunks} chunk(s) and read all ${coverage.available} row(s) the Stats API reports for those name queries.`;
|
|
209
|
+
} else if (coverage.available !== null) {
|
|
210
|
+
coverage.status = 'limited';
|
|
211
|
+
coverage.reason = `Resolved ${coverage.resolved_names} of ${requested.length} observed skipped update names and read ${coverage.fetched} of ${coverage.available} row(s) the Stats API reports for those name queries; the per-chunk page budget was reached.`;
|
|
212
|
+
} else {
|
|
213
|
+
coverage.status = 'limited';
|
|
214
|
+
coverage.reason = `Resolved ${coverage.resolved_names} of ${requested.length} observed skipped update names; the Stats API count for those name queries was unavailable, so lookup completeness cannot be claimed.`;
|
|
215
|
+
}
|
|
216
|
+
return { rows, resolved, coverage };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export const CUSTOMIZATION_SCHEMA_VERSION = 1;
|
|
220
|
+
export function getCustomizationsDir() {
|
|
221
|
+
return path.join(process.env.JSN_DATA_HOME || path.join(os.homedir(), '.jsn'), 'customizations');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function reportFile(id, dir) {
|
|
225
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(String(id))) throw new Error('Invalid customization report ID');
|
|
226
|
+
return path.join(dir, `${id}.json`);
|
|
227
|
+
}
|
|
228
|
+
function validateReport(report) {
|
|
229
|
+
if (report?.capture_schema_version !== CUSTOMIZATION_SCHEMA_VERSION || !report.instance || !report.categories || !report.coverage) throw new Error('Unsupported or invalid customization report schema');
|
|
230
|
+
for (const category of ['ootb_modifications', 'customer_app_artifacts', 'unclassified']) {
|
|
231
|
+
if (!Array.isArray(report.categories[category])) throw new Error('Invalid customization report categories');
|
|
232
|
+
}
|
|
233
|
+
return report;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function captureReport({ sdk, instance, profile = '', label = '', customerScopes = [], pageSize = 100, maxPages = 10, dir = getCustomizationsDir() }) {
|
|
237
|
+
if (!instance) throw new Error('An authenticated instance is required for customization capture');
|
|
238
|
+
const start_time = new Date().toISOString();
|
|
239
|
+
const capture = await captureCustomizations({ sdk, customerScopes, pageSize, maxPages });
|
|
240
|
+
const report = { run_id: crypto.randomUUID(), instance, profile, label, start_time, finish_time: new Date().toISOString(), jsn_version: getVersion(), capture_schema_version: CUSTOMIZATION_SCHEMA_VERSION, command_options: { customerScopes, pageSize, maxPages }, ...capture };
|
|
241
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
242
|
+
fs.writeFileSync(reportFile(report.run_id, dir), JSON.stringify(report, null, 2), { mode: 0o600, flag: 'wx' });
|
|
243
|
+
return report;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function getReport(id, { dir = getCustomizationsDir() } = {}) {
|
|
247
|
+
try { return validateReport(JSON.parse(fs.readFileSync(reportFile(id, dir), 'utf8'))); }
|
|
248
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
249
|
+
}
|
|
250
|
+
export function listReports({ limit = 50, dir = getCustomizationsDir() } = {}) {
|
|
251
|
+
if (!fs.existsSync(dir)) return [];
|
|
252
|
+
return fs.readdirSync(dir).filter(name => name.endsWith('.json')).map(name => {
|
|
253
|
+
const report = getReport(name.slice(0, -5), { dir });
|
|
254
|
+
return { run_id: report.run_id, instance: report.instance, profile: report.profile, label: report.label, start_time: report.start_time, status: report.status };
|
|
255
|
+
}).sort((a, b) => b.start_time.localeCompare(a.start_time) || a.run_id.localeCompare(b.run_id)).slice(0, Math.max(1, Math.min(1000, Number(limit) || 50)));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Object key order and evidence enumeration order are not artifact changes.
|
|
259
|
+
function canonical(value) {
|
|
260
|
+
if (Array.isArray(value)) return value.map(canonical).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
261
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
|
|
262
|
+
return value;
|
|
263
|
+
}
|
|
264
|
+
function inventory(report) {
|
|
265
|
+
return new Map(Object.entries(report.categories).flatMap(([category, items]) => items.map(item => [`${item.identity.table}:${item.identity.sys_id}`, { category, ...item }])));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Sources that cannot be read are not empty sources. A comparison built on top
|
|
269
|
+
// of one must say so instead of presenting missing coverage as real removals.
|
|
270
|
+
function impairedSources(coverage) {
|
|
271
|
+
const sources = coverage?.sources || {};
|
|
272
|
+
return Object.entries(sources).filter(([, source]) => {
|
|
273
|
+
if (source.status === 'unavailable' || source.status === 'permission_denied') return true;
|
|
274
|
+
return source.fetched === 0 && source.available === null;
|
|
275
|
+
}).map(([table, source]) => `${table} (${source.status})`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function compareReports(baseline, newer) {
|
|
279
|
+
validateReport(baseline); validateReport(newer);
|
|
280
|
+
if (baseline.instance !== newer.instance) throw new Error('Customization reports must describe the same instance');
|
|
281
|
+
const a = inventory(baseline), b = inventory(newer);
|
|
282
|
+
const additions = [], removals = [], changes = [];
|
|
283
|
+
for (const id of [...new Set([...a.keys(), ...b.keys()])].sort()) {
|
|
284
|
+
if (!a.has(id)) additions.push({ id, availability: 'not_observed_in_baseline', item: b.get(id) });
|
|
285
|
+
else if (!b.has(id)) removals.push({ id, availability: 'not_observed_in_new', item: a.get(id) });
|
|
286
|
+
else if (JSON.stringify(canonical(a.get(id))) !== JSON.stringify(canonical(b.get(id)))) changes.push({ id, baseline: a.get(id), new: b.get(id) });
|
|
287
|
+
}
|
|
288
|
+
const impaired = { baseline: impairedSources(baseline.coverage), new: impairedSources(newer.coverage) };
|
|
289
|
+
const unreliable = impaired.baseline.length > 0 || impaired.new.length > 0;
|
|
290
|
+
const unknowns = ['Additions/removals mean newly observed/no longer observed, not proven creation/deletion. ACLs, limits and concurrent changes can affect visibility.', 'Only captured metadata and evidence are compared, not full artifact content or OOTB baselines.', 'Ownership declarations, scope visibility and evidence coverage can cause category changes without an artifact change.'];
|
|
291
|
+
if (unreliable) unknowns.push(`Unavailable or empty sources make observed additions/removals unreliable: baseline [${impaired.baseline.join(', ') || 'none'}], new [${impaired.new.join(', ') || 'none'}]. They may reflect missing coverage rather than a real change and must not be read as mass creation or deletion.`);
|
|
292
|
+
return { baseline_run_id: baseline.run_id, new_run_id: newer.run_id, instance: baseline.instance, status: unreliable ? 'unreliable' : 'incomplete', additions, removals, changes,
|
|
293
|
+
impaired_sources: impaired,
|
|
294
|
+
coverage: { baseline: baseline.coverage, new: newer.coverage },
|
|
295
|
+
unknowns };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function formatReportDetailed(report) {
|
|
299
|
+
const lines = [`Customization capture ${report.run_id}${report.label ? ` (${report.label})` : ''}`, `Instance: ${report.instance}`, `Status: ${report.status}`, '', 'COVERAGE'];
|
|
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}`);
|
|
301
|
+
for (const limitation of report.coverage.limitations) lines.push(`Unknown: ${limitation}`);
|
|
302
|
+
if (report.coverage.unmatched_customer_scopes.length) lines.push(`Unmatched customer declarations: ${report.coverage.unmatched_customer_scopes.join(', ')}`);
|
|
303
|
+
const upgrade = report.coverage.upgrade_evidence;
|
|
304
|
+
if (upgrade) {
|
|
305
|
+
lines.push(`Observed skipped upgrade rows: ${upgrade.skipped_rows_observed}; distinct update names: ${upgrade.update_names_observed}; resolved by direct name lookup: ${upgrade.resolved_by_lookup}; unresolved: ${upgrade.unresolved_update_names.length}${upgrade.lookup_failures ? `; failed lookup chunks: ${upgrade.lookup_failures}` : ''}`);
|
|
306
|
+
if (upgrade.unresolved_update_names.length) lines.push(`Unresolved skipped update names (not visible in sys_metadata): ${upgrade.unresolved_update_names.slice(0, 20).join(', ')}${upgrade.unresolved_update_names.length > 20 ? `, ... and ${upgrade.unresolved_update_names.length - 20} more` : ''}`);
|
|
307
|
+
}
|
|
308
|
+
lines.push(`Unmatched upgrade history records: ${report.coverage.unmatched_upgrade_records}`);
|
|
309
|
+
for (const [category, items] of Object.entries(report.categories)) {
|
|
310
|
+
lines.push('', `${category.toUpperCase()} (${items.length})`);
|
|
311
|
+
for (const item of items) {
|
|
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}`);
|
|
313
|
+
for (const evidence of item.evidence) lines.push(` Evidence: ${evidence.source}:${evidence.sys_id}. ${evidence.basis}`);
|
|
314
|
+
if (item.reversion) {
|
|
315
|
+
for (const impact of item.reversion.known_impact) lines.push(` Known impact: ${impact}`);
|
|
316
|
+
lines.push(` Known dependencies: ${item.reversion.known_dependencies.join(', ') || 'not established'}`);
|
|
317
|
+
for (const unknown of item.reversion.unknowns) lines.push(` Reversion unknown: ${unknown}`);
|
|
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}`);
|
|
323
|
+
lines.push(` Recommendation: ${item.recommendation}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return lines.join('\n') + '\n';
|
|
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
|
+
|
|
365
|
+
export function formatReportList(runs) {
|
|
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';
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Flatten a captured item into comparable leaf fields so a comparison can show
|
|
370
|
+
// what actually changed instead of only asserting that something did.
|
|
371
|
+
function flattenFields(source, prefix = '', out = {}) {
|
|
372
|
+
const current = prefix || 'value';
|
|
373
|
+
if (source === null || source === undefined) { out[current] = ''; return out; }
|
|
374
|
+
if (Array.isArray(source)) {
|
|
375
|
+
if (!source.length) { out[current] = '(none)'; return out; }
|
|
376
|
+
source.forEach((entry, index) => flattenFields(entry, `${prefix}[${index}]`, out));
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
if (typeof source === 'object') {
|
|
380
|
+
const keys = Object.keys(source).sort();
|
|
381
|
+
if (!keys.length) { out[current] = '(none)'; return out; }
|
|
382
|
+
for (const key of keys) flattenFields(source[key], prefix ? `${prefix}.${key}` : key, out);
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
out[current] = String(source);
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
function fieldChanges(before, after) {
|
|
389
|
+
const a = flattenFields(canonical(before));
|
|
390
|
+
const b = flattenFields(canonical(after));
|
|
391
|
+
return [...new Set([...Object.keys(a), ...Object.keys(b)])].sort()
|
|
392
|
+
.filter(field => a[field] !== b[field])
|
|
393
|
+
.map(field => ({ field, before: Object.hasOwn(a, field) ? a[field] : '(absent)', after: Object.hasOwn(b, field) ? b[field] : '(absent)' }));
|
|
394
|
+
}
|
|
395
|
+
function describeComparedItem(item) {
|
|
396
|
+
const identity = item?.identity || {};
|
|
397
|
+
const scope = item?.scope || {};
|
|
398
|
+
return [
|
|
399
|
+
`Artifact: ${identity.table || 'unknown'}:${identity.sys_id || 'unknown'} "${identity.name || identity.update_name || 'unnamed'}"`,
|
|
400
|
+
`Category: ${item?.category || 'unknown'}`,
|
|
401
|
+
`Scope: ${scope.scope || scope.sys_id || 'unknown'}${scope.name ? ` (${scope.name})` : ''}`,
|
|
402
|
+
`Evidence: ${(item?.evidence || []).map(evidence => `${evidence.source}:${evidence.sys_id}`).join(', ') || 'none captured'}`,
|
|
403
|
+
`Risk: ${item?.risk?.rating || 'unknown'}`,
|
|
404
|
+
`Recommendation: ${item?.recommendation || 'not recorded'}`,
|
|
405
|
+
];
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function formatReportComparison(result) {
|
|
409
|
+
const lines = [`Customization comparison: ${result.status}`, `Baseline: ${result.baseline_run_id}`, `New: ${result.new_run_id}`, ...result.unknowns];
|
|
410
|
+
const impaired = result.impaired_sources || { baseline: [], new: [] };
|
|
411
|
+
if (impaired.baseline.length) lines.push(`Unreliable baseline sources: ${impaired.baseline.join(', ')}`);
|
|
412
|
+
if (impaired.new.length) lines.push(`Unreliable new sources: ${impaired.new.join(', ')}`);
|
|
413
|
+
for (const kind of ['additions', 'removals', 'changes']) {
|
|
414
|
+
lines.push('', `${kind.toUpperCase()} (${result[kind].length})`);
|
|
415
|
+
for (const entry of result[kind]) {
|
|
416
|
+
if (kind === 'changes') {
|
|
417
|
+
lines.push(`${entry.id}: ${entry.availability || 'captured metadata/evidence changed'}`);
|
|
418
|
+
const fields = fieldChanges(entry.baseline, entry.new);
|
|
419
|
+
for (const field of fields) lines.push(` ${field.field}: "${field.before}" -> "${field.after}"`);
|
|
420
|
+
if (!fields.length) lines.push(' No compared field differs; evidence ordering or another unrendered value changed.');
|
|
421
|
+
} else {
|
|
422
|
+
lines.push(`${entry.id}: ${entry.availability}`);
|
|
423
|
+
for (const detail of describeComparedItem(entry.item)) lines.push(` ${detail}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
for (const side of ['baseline', 'new']) for (const [table, source] of Object.entries(result.coverage[side].sources)) lines.push(`${side} ${table}${source.kind ? ` [${source.kind}]` : ''}: ${source.status}, ${source.fetched} fetched of ${source.available === null || source.available === undefined ? 'unknown' : source.available} available. ${source.reason}`);
|
|
428
|
+
return lines.join('\n') + '\n';
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export async function captureCustomizations({ sdk, customerScopes = [], pageSize = 100, maxPages = 10 }) {
|
|
432
|
+
if (!sdk) throw new Error('An authenticated SDK is required');
|
|
433
|
+
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 1000 || !Number.isInteger(maxPages) || maxPages < 1 || maxPages > 100) throw new Error('pageSize must be 1..1000 and maxPages 1..100');
|
|
434
|
+
const sources = {};
|
|
435
|
+
const data = {};
|
|
436
|
+
for (const [table, source] of Object.entries(SOURCES)) {
|
|
437
|
+
const result = await readSource(sdk, table, source, pageSize, maxPages);
|
|
438
|
+
sources[table] = result.coverage;
|
|
439
|
+
data[table] = result.rows;
|
|
440
|
+
}
|
|
441
|
+
// The broad sys_metadata read is a bounded sample, so the artifacts the
|
|
442
|
+
// filtered history read actually named are looked up directly. The source is
|
|
443
|
+
// only registered when there is something to resolve, so a report with no
|
|
444
|
+
// skipped rows has no phantom empty source.
|
|
445
|
+
const observedNames = data.sys_upgrade_history_log.map(row => text(row.file_name)).filter(Boolean);
|
|
446
|
+
const resolution = await resolveUpdateNames(sdk, observedNames);
|
|
447
|
+
if (observedNames.length) {
|
|
448
|
+
sources[RESOLUTION_SOURCE] = resolution.coverage;
|
|
449
|
+
const sampled = new Set(data.sys_metadata.map(row => text(row.sys_id)));
|
|
450
|
+
data.sys_metadata = [...data.sys_metadata, ...resolution.rows.filter(row => !sampled.has(text(row.sys_id)))];
|
|
451
|
+
}
|
|
452
|
+
const scopes = new Map(data.sys_scope.map(row => [text(row.sys_id), row]));
|
|
453
|
+
const declared = new Set(customerScopes);
|
|
454
|
+
const logs = new Map();
|
|
455
|
+
for (const row of data.sys_upgrade_history_log) {
|
|
456
|
+
const name = text(row.file_name);
|
|
457
|
+
if (!logs.has(name)) logs.set(name, []);
|
|
458
|
+
logs.get(name).push(row);
|
|
459
|
+
}
|
|
460
|
+
const categories = { ootb_modifications: [], customer_app_artifacts: [], unclassified: [] };
|
|
461
|
+
for (const row of data.sys_metadata) {
|
|
462
|
+
const scopeId = text(row.sys_scope);
|
|
463
|
+
const scope = scopes.get(scopeId);
|
|
464
|
+
const scopeName = text(scope?.scope);
|
|
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 };
|
|
466
|
+
const evidence = [{ source: 'sys_metadata', sys_id: identity.sys_id, basis: 'Visible application metadata record', updated_on: text(row.sys_updated_on) }];
|
|
467
|
+
const item = {
|
|
468
|
+
identity, scope: { sys_id: scopeId || null, name: text(scope?.name) || null, scope: scopeName || null }, evidence,
|
|
469
|
+
risk: { rating: 'unknown', rationale: 'No trusted current OOTB baseline, runtime usage or dependency analysis was captured.' },
|
|
470
|
+
recommendation: 'Review ownership, current configuration and tests before changing this artifact.',
|
|
471
|
+
unknowns: ['Current content and behavioral differences were not captured.', 'Metadata timestamps can change without a behavioral change.'],
|
|
472
|
+
};
|
|
473
|
+
const history = (logs.get(identity.update_name) || []).sort((a, b) => text(b.sys_updated_on).localeCompare(text(a.sys_updated_on)) || text(a.sys_id).localeCompare(text(b.sys_id)));
|
|
474
|
+
// Raw provider choices: 4 = Skipped, 104 = Skipped (second pass).
|
|
475
|
+
// The read is already filtered server-side; this check stays because a
|
|
476
|
+
// server filter is never trusted to be a classification.
|
|
477
|
+
const skipped = history.filter(log => Object.hasOwn(log, 'resolution_status') && SKIP_DISPOSITIONS.includes(text(log.disposition)) && text(log.resolution_status) !== 'reviewed_reverted');
|
|
478
|
+
// A declaration is caller evidence, not platform/vendor provenance. Keep it
|
|
479
|
+
// separate from OOTB findings and record the uncertainty explicitly.
|
|
480
|
+
if (scope && scopeId !== 'global' && scopeName !== 'global' && (declared.has(scopeName) || declared.has(scopeId))) {
|
|
481
|
+
evidence.push({ source: 'capture_options', sys_id: scopeId, basis: 'Customer ownership declared by caller; scope identity verified in sys_scope.' });
|
|
482
|
+
item.unknowns.push('Publisher/customer ownership is not independently verified.');
|
|
483
|
+
item.recommendation = 'Maintain as a declared customer application artifact; review with its owner, not as an OOTB deviation.';
|
|
484
|
+
categories.customer_app_artifacts.push(item);
|
|
485
|
+
} else if (identity.update_name && skipped.length) {
|
|
486
|
+
for (const log of skipped) evidence.push({ source: 'sys_upgrade_history_log', sys_id: text(log.sys_id), basis: 'Upgrade disposition skipped preserves a customization of a vendor-delivered artifact.', disposition: text(log.disposition), resolution_status: text(log.resolution_status) || null });
|
|
487
|
+
item.unknowns.push('Historical upgrade skips do not prove the customization still differs from OOTB. Later reversion or merge may have occurred.');
|
|
488
|
+
item.reversion = {
|
|
489
|
+
known_impact: ['The recorded upgrade did not overwrite the customization.'],
|
|
490
|
+
known_dependencies: [],
|
|
491
|
+
unknowns: ['Current OOTB baseline and exact changes are unavailable.', 'Runtime impact and dependencies of reverting have not been established. An empty dependency list does not mean none exist.'],
|
|
492
|
+
};
|
|
493
|
+
item.recommendation = 'Retain pending review. Compare the current artifact with a trusted baseline, inspect callers and regression-test in non-production before any reversion.';
|
|
494
|
+
categories.ootb_modifications.push(item);
|
|
495
|
+
} else {
|
|
496
|
+
item.unknowns.push('Origin is unclassified. A non-global scope or an update timestamp is not proof of customer ownership or an OOTB modification.');
|
|
497
|
+
categories.unclassified.push(item);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
const visibleNames = new Set(data.sys_metadata.map(row => text(row.sys_update_name)).filter(Boolean));
|
|
501
|
+
const unresolvedNames = [...new Set(observedNames)].filter(name => !visibleNames.has(name)).sort();
|
|
502
|
+
return {
|
|
503
|
+
status: 'incomplete', categories,
|
|
504
|
+
coverage: { complete: false, sources,
|
|
505
|
+
upgrade_evidence: {
|
|
506
|
+
skipped_rows_observed: data.sys_upgrade_history_log.length,
|
|
507
|
+
update_names_observed: new Set(observedNames).size,
|
|
508
|
+
resolved_by_lookup: observedNames.length ? resolution.coverage.resolved_names : 0,
|
|
509
|
+
unresolved_update_names: unresolvedNames,
|
|
510
|
+
lookup_failures: observedNames.length ? resolution.coverage.failed_chunks : 0,
|
|
511
|
+
},
|
|
512
|
+
unmatched_upgrade_records: data.sys_upgrade_history_log.filter(row => !visibleNames.has(text(row.file_name))).length,
|
|
513
|
+
unmatched_customer_scopes: customerScopes.filter(id => !data.sys_scope.some(row => text(row.sys_id) === id || text(row.scope) === id)),
|
|
514
|
+
limitations: ['Inventory covers visible sys_metadata and upgrade history only, not every customization mechanism.', `Upgrade history is read server-side for raw dispositions ${SKIP_DISPOSITIONS.join(' and ')} only; unmatched upgrade counts describe that filtered population, not all upgrade history.`, 'Coverage compares rows fetched with the Stats API count for the same query; a short page never implies exhaustion and an unavailable count leaves completeness unknown.', `The broad sys_metadata source is a bounded sample of an arbitrary sys_id-ordered slice, not an inventory, so unclassified and declared-customer categories only describe sampled artifacts. Skipped-upgrade artifacts are separate: they are resolved by the update names the filtered history read observed (${RESOLUTION_SOURCE}).`, 'Name resolution only looks up artifacts with skipped-upgrade evidence, so customizations with no upgrade-skip history are not discovered by it.', 'A resolved artifact can show a scope sys_id without a scope name when that scope record was outside the bounded sys_scope read.', 'Row/field ACL filtering and concurrent changes cannot be ruled out.', 'No current baseline, source payloads or dependency graph captured. Comparisons detect captured metadata/evidence changes only.', 'OOTB category means historical skipped-upgrade evidence, not a verified current deviation. Customer scopes are caller declarations.'],
|
|
515
|
+
},
|
|
516
|
+
};
|
|
517
|
+
}
|