@jacebenson/jsn 4.3.13-dev.73.1 → 4.3.13-dev.74.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -14,7 +14,7 @@ compatibility: |
|
|
|
14
14
|
Works with Claude Code, OpenCode, Cursor, and agentskills-compatible tools.
|
|
15
15
|
metadata:
|
|
16
16
|
author: jacebenson
|
|
17
|
-
version: "4.3.
|
|
17
|
+
version: "4.3.10"
|
|
18
18
|
repository: https://github.com/jacebenson/jsn
|
|
19
19
|
---
|
|
20
20
|
|
|
@@ -74,41 +74,17 @@ Pick the most specific tool for the job. **Never default to eval** — it's the
|
|
|
74
74
|
|
|
75
75
|
### Customization evidence
|
|
76
76
|
|
|
77
|
+
Use `jsn customizations --help` to discover bounded read-only capture and offline
|
|
78
|
+
review/comparison. For example:
|
|
79
|
+
|
|
77
80
|
```sh
|
|
78
81
|
jsn customizations capture --label review --customer-scope x_acme_app --json
|
|
79
|
-
jsn customizations
|
|
80
|
-
jsn customizations show <run_id> --get data.categories
|
|
81
|
-
jsn customizations show <run_id> --get data.triage
|
|
82
|
-
jsn customizations html <run_id> [--out review.html]
|
|
83
|
-
jsn customizations compare <baseline_id> <new_id> --json
|
|
82
|
+
jsn customizations show <run_id> --json
|
|
84
83
|
```
|
|
85
84
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
session/GCK using authentication POSTs. List/show/compare are offline.
|
|
90
|
-
Declare customer ownership with repeatable `--customer-scope` values or sys_ids;
|
|
91
|
-
never infer customer ownership from a non-global scope. The declaration is
|
|
92
|
-
caller evidence, not independent publisher verification. OOTB findings reflect
|
|
93
|
-
historical upgrade skips, not a current verified baseline deviation. The upgrade
|
|
94
|
-
history is read server-side for the skip dispositions and every observed skipped
|
|
95
|
-
`file_name` is resolved against `sys_metadata` by chunked
|
|
96
|
-
`sys_update_nameIN<chunk>` lookups, so the OOTB inventory covers the observed
|
|
97
|
-
skipped artifacts; the broad `sys_metadata` read is an arbitrary `sys_id`-ordered
|
|
98
|
-
slice and is reported as a bounded sample, not an inventory, describing only the
|
|
99
|
-
unclassified and declared-customer categories. Unresolved skipped names and
|
|
100
|
-
failed lookup chunks stay explicit rather than becoming absent artifacts, and a
|
|
101
|
-
resolved artifact can lack a scope name when its scope record was outside the
|
|
102
|
-
bounded `sys_scope` read. Missing
|
|
103
|
-
ACL access, pagination limits and unmatched evidence remain explicit. All
|
|
104
|
-
reports are incomplete; no findings is not proof of no customizations.
|
|
105
|
-
Comparisons cover captured metadata/evidence, not source payloads. Removals mean
|
|
106
|
-
no longer observed, not proven deletion. Reversion impact and dependencies
|
|
107
|
-
remain unknown until separately investigated. Never recommend automatic reversion.
|
|
108
|
-
|
|
109
|
-
Upgrade history joins `file_name` to metadata `sys_update_name`. Raw disposition
|
|
110
|
-
`4` or `104` is skipped-customization evidence; resolution `reviewed_reverted`
|
|
111
|
-
excludes that history row. Use raw values, not display labels or other skip reasons.
|
|
85
|
+
Read the CLI's interpretation, coverage and per-artifact triage before drawing
|
|
86
|
+
conclusions. Verify ownership and current behavior before proposing changes;
|
|
87
|
+
never recommend automatic reversion.
|
|
112
88
|
|
|
113
89
|
### Complete an update set
|
|
114
90
|
|
|
@@ -4,6 +4,7 @@ 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
6
|
import { buildActionableReport, writeCustomizationHtml } from '../customizations-report.js';
|
|
7
|
+
import { customizationGuidance, formatCustomizationGuidance } from '../customization-guidance.js';
|
|
7
8
|
|
|
8
9
|
// Only capture needs an instance. Saved reports never need network access.
|
|
9
10
|
declareCapabilities('customizations', { noInstance: true, skipDailyChecks: true, mutationSubcommands: [] });
|
|
@@ -25,7 +26,7 @@ async function pickReport(app, argv, exclude = null, message = 'Select a customi
|
|
|
25
26
|
function show(app, report, options = {}) {
|
|
26
27
|
const actionable = buildActionableReport(report);
|
|
27
28
|
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)] });
|
|
29
|
+
app.ok({ ...report, interpretation: customizationGuidance(), triage, _formatted: formatReport(report, options) }, { summary: `Customization capture ${report.run_id}: ${report.status}`, breadcrumbs: [listHint, showHint(report.run_id), compareHint(report.run_id)] });
|
|
29
30
|
}
|
|
30
31
|
export function customizationsCmd(wrap) {
|
|
31
32
|
return {
|
|
@@ -36,7 +37,8 @@ export function customizationsCmd(wrap) {
|
|
|
36
37
|
builder: b => b.option('label', { type: 'string', describe: 'Label for this capture' })
|
|
37
38
|
.option('customer-scope', { type: 'string', array: true, default: [], describe: 'Declared customer-owned scope value or sys_id, repeatable; ownership is not inferred' })
|
|
38
39
|
.option('page-size', { type: 'number', default: 100, describe: 'Records per table page, 1..1000' })
|
|
39
|
-
.option('max-pages', { type: 'number', default: 10, describe: 'Maximum pages per source, 1..100' })
|
|
40
|
+
.option('max-pages', { type: 'number', default: 10, describe: 'Maximum pages per source, 1..100' })
|
|
41
|
+
.epilogue(`${customizationGuidance().capture}\n${formatCustomizationGuidance()}\nExample: jsn customizations capture --label review --customer-scope x_acme_app --json`),
|
|
40
42
|
handler: wrap(async (argv, app) => {
|
|
41
43
|
app.requireInstance();
|
|
42
44
|
if (!Number.isInteger(argv.pageSize) || argv.pageSize < 1 || argv.pageSize > 1000 || !Number.isInteger(argv.maxPages) || argv.maxPages < 1 || argv.maxPages > 100) throw errUsage('page-size must be 1..1000 and max-pages 1..100');
|
|
@@ -55,18 +57,21 @@ export function customizationsCmd(wrap) {
|
|
|
55
57
|
})
|
|
56
58
|
.command({ command: 'show [run_id]', describe: 'Review a saved capture offline', builder: b => b
|
|
57
59
|
.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)' })
|
|
60
|
+
.option('filter', { type: 'string', describe: 'Expand a group shown in the summary (friendly or table name for file types)' })
|
|
61
|
+
.epilogue(`Offline review: inspect coverage, interpretation and triage in --json output.\n${formatCustomizationGuidance()}\nExample: jsn customizations show <run_id> --get data.triage`), handler: wrap(async (argv, app) => {
|
|
59
62
|
const report = argv.run_id ? requireReport(argv.run_id) : await pickReport(app, argv);
|
|
60
63
|
if (report) show(app, report, { groupBy: argv.groupBy, filter: argv.filter || '' });
|
|
61
64
|
}) })
|
|
62
65
|
.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)' })
|
|
66
|
+
.option('out', { type: 'string', describe: 'Output HTML path (must not already exist)' })
|
|
67
|
+
.epilogue('Offline review with the same interpretation and coverage as show; writes a local file without changing instance records.\nExample: jsn customizations html <run_id> --out review.html'), handler: wrap(async (argv, app) => {
|
|
64
68
|
const report = argv.run_id ? requireReport(argv.run_id) : await pickReport(app, argv);
|
|
65
69
|
if (!report) return;
|
|
66
70
|
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)] });
|
|
71
|
+
app.ok({ run_id: report.run_id, file, artifacts: Object.values(report.categories).reduce((total, items) => total + items.length, 0), interpretation: customizationGuidance() }, { summary: `Wrote offline customization review: ${file}`, breadcrumbs: [listHint, showHint(report.run_id)] });
|
|
68
72
|
}) })
|
|
69
|
-
.command({ command: 'compare [baseline] [new]', describe: 'Compare two saved captures from the same instance',
|
|
73
|
+
.command({ command: 'compare [baseline] [new]', describe: 'Compare two saved captures from the same instance',
|
|
74
|
+
builder: b => b.epilogue(`Offline metadata/evidence comparison; inspect both captures' coverage before interpreting changes.\n${formatCustomizationGuidance()}\nExample: jsn customizations compare <baseline_id> <new_id> --json`), handler: wrap(async (argv, app) => {
|
|
70
75
|
if (!!argv.baseline !== !!argv.new) throw errUsage('Compare requires exactly two report IDs');
|
|
71
76
|
if (argv.baseline && argv.baseline === argv.new) throw errUsage('Compare requires two different report IDs');
|
|
72
77
|
const baseline = argv.baseline ? requireReport(argv.baseline) : await pickReport(app, argv, null, 'Select the baseline capture');
|
|
@@ -76,7 +81,8 @@ export function customizationsCmd(wrap) {
|
|
|
76
81
|
if (baseline.instance !== newer.instance) throw errUsage('Customization reports must describe the same instance');
|
|
77
82
|
const result = compareReports(baseline, newer);
|
|
78
83
|
app.ok({ ...result, _formatted: formatReportComparison(result) }, { summary: `Customization comparison: ${result.status}`, breadcrumbs: [listHint, showHint(baseline.run_id), showHint(newer.run_id)] });
|
|
79
|
-
}) })
|
|
84
|
+
}) })
|
|
85
|
+
.epilogue(`${customizationGuidance().capture}\n${formatCustomizationGuidance()}\nStart: jsn customizations capture --help\nReview: jsn customizations show --help\nCompare: jsn customizations compare --help`),
|
|
80
86
|
handler: wrap(async (_argv, app) => app.ok({ command: 'customizations', subcommands: ['capture', 'list', 'show', 'html', 'compare'] }, { summary: 'Use `jsn customizations <subcommand> --help` for details.' })),
|
|
81
87
|
};
|
|
82
88
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Shared interpretation for CLI help, saved-report views and comparisons.
|
|
2
|
+
// Keep this independent of capture/storage so older captures remain readable.
|
|
3
|
+
export function customizationGuidance() {
|
|
4
|
+
return {
|
|
5
|
+
capture: 'Capture uses bounded GET-only table reads and saves reports locally. Authentication may refresh OAuth credentials locally or bootstrap a session/GCK with authentication POSTs. Saved list/show/compare/html operations are offline.',
|
|
6
|
+
evidence: {
|
|
7
|
+
upgrade_skips: 'Raw upgrade dispositions 4 and 104 are historical skipped-customization evidence; reviewed_reverted rows are excluded. Display labels and other skip reasons are not equivalent. A skip does not prove a current OOTB baseline deviation.',
|
|
8
|
+
ownership: 'Customer scopes must be declared explicitly. Matching a scope verifies its identity, not customer or publisher ownership. Non-global scope and updater names do not establish ownership.',
|
|
9
|
+
metadata: 'The broad sys_metadata read is an arbitrary sys_id-ordered bounded sample, not an inventory. Declared-customer and unclassified categories are not complete inventories; they can include sampled artifacts and artifacts found by skipped-history lookups.',
|
|
10
|
+
resolution: 'Every observed skipped file_name is resolved by chunked sys_metadata.sys_update_name lookups. Unresolved names and failed lookup chunks remain coverage gaps. A resolved artifact may lack a scope name when its scope was outside the bounded sys_scope read.',
|
|
11
|
+
},
|
|
12
|
+
limitations: [
|
|
13
|
+
'All captures are incomplete. No findings is not proof of no customizations; ACLs, pagination limits, unmatched evidence and concurrent changes can hide artifacts.',
|
|
14
|
+
'Current baseline, full source payloads, runtime reversion impact and dependencies are not established. An empty dependency list does not prove none exist.',
|
|
15
|
+
],
|
|
16
|
+
comparison: 'Comparisons cover captured metadata and evidence only. Added/removed means newly observed/no longer observed, not proven creation/deletion. Changed declarations or coverage can change categories without an artifact change.',
|
|
17
|
+
next_steps: [
|
|
18
|
+
'Review coverage, evidence and per-artifact confidence before drawing conclusions.',
|
|
19
|
+
'Verify ownership and compare with a trusted baseline for the exact release; investigate dependencies and test in non-production before changing records. Never recommend automatic reversion.',
|
|
20
|
+
],
|
|
21
|
+
automated_remediation: false,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function formatCustomizationGuidance() {
|
|
26
|
+
const guidance = customizationGuidance();
|
|
27
|
+
return [
|
|
28
|
+
...Object.values(guidance.evidence), ...guidance.limitations,
|
|
29
|
+
guidance.comparison, ...guidance.next_steps.map(step => `Next: ${step}`),
|
|
30
|
+
].join('\n');
|
|
31
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { customizationGuidance, formatCustomizationGuidance } from './customization-guidance.js';
|
|
3
4
|
|
|
4
5
|
const CLASSIFICATIONS = {
|
|
5
6
|
ootb_modifications: {
|
|
@@ -94,6 +95,7 @@ export function buildActionableReport(report) {
|
|
|
94
95
|
start_time: report.start_time,
|
|
95
96
|
artifacts,
|
|
96
97
|
coverage: report.coverage,
|
|
98
|
+
interpretation: customizationGuidance(),
|
|
97
99
|
};
|
|
98
100
|
}
|
|
99
101
|
|
|
@@ -119,6 +121,7 @@ export function renderCustomizationHtml(report) {
|
|
|
119
121
|
<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
122
|
<section id="results" aria-live="polite"></section>
|
|
121
123
|
<details class="coverage"><summary>Capture coverage and limitations</summary><div id="coverage-content"></div></details>
|
|
124
|
+
<details class="coverage"><summary>How to interpret this evidence</summary>${formatCustomizationGuidance().split('\n').map(line => `<p>${escapeHtml(line)}</p>`).join('')}</details>
|
|
122
125
|
<script id="report-data" type="application/json">${data}</script>
|
|
123
126
|
<script>
|
|
124
127
|
'use strict';
|
|
@@ -146,6 +149,9 @@ function render(){
|
|
|
146
149
|
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
150
|
search.addEventListener('input',render);category.addEventListener('change',render);groupBy.addEventListener('change',render);render();
|
|
148
151
|
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));
|
|
152
|
+
coverage.append(el('p','Unmatched upgrade history records: '+(report.coverage.unmatched_upgrade_records??'unknown')));
|
|
153
|
+
if(report.coverage.unmatched_customer_scopes?.length)coverage.append(el('p','Unmatched customer declarations: '+report.coverage.unmatched_customer_scopes.join(', ')));
|
|
154
|
+
const upgrade=report.coverage.upgrade_evidence;if(upgrade){coverage.append(el('p','Observed skipped rows: '+upgrade.skipped_rows_observed+'; update names: '+upgrade.update_names_observed+'; resolved by lookup: '+upgrade.resolved_by_lookup+'; failed lookup chunks: '+upgrade.lookup_failures));if(upgrade.unresolved_update_names?.length)coverage.append(el('p','Unresolved skipped update names: '+upgrade.unresolved_update_names.join(', ')));}
|
|
149
155
|
</script>
|
|
150
156
|
</main></body></html>\n`;
|
|
151
157
|
}
|
package/src/customizations.js
CHANGED
|
@@ -6,6 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import crypto from 'node:crypto';
|
|
7
7
|
import { getVersion } from './commands/version.js';
|
|
8
8
|
import { classifyCustomization } from './customizations-report.js';
|
|
9
|
+
import { customizationGuidance, formatCustomizationGuidance } from './customization-guidance.js';
|
|
9
10
|
|
|
10
11
|
// Raw provider choices: 4 = Skipped, 104 = Skipped (second pass). Other skip
|
|
11
12
|
// dispositions do not establish a preserved customization, so the upgrade
|
|
@@ -290,22 +291,28 @@ export function compareReports(baseline, newer) {
|
|
|
290
291
|
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
292
|
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
293
|
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
|
+
impaired_sources: impaired, interpretation: customizationGuidance(),
|
|
294
295
|
coverage: { baseline: baseline.coverage, new: newer.coverage },
|
|
295
296
|
unknowns };
|
|
296
297
|
}
|
|
297
298
|
|
|
298
|
-
function
|
|
299
|
-
const lines = [
|
|
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
|
|
299
|
+
function coverageLines(report) {
|
|
300
|
+
const lines = [];
|
|
301
|
+
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}`);
|
|
302
|
+
for (const limitation of report.coverage.limitations || []) lines.push(`Unknown: ${limitation}`);
|
|
303
|
+
if (report.coverage.unmatched_customer_scopes?.length) lines.push(`Unmatched customer declarations: ${report.coverage.unmatched_customer_scopes.join(', ')}`);
|
|
303
304
|
const upgrade = report.coverage.upgrade_evidence;
|
|
304
305
|
if (upgrade) {
|
|
305
|
-
|
|
306
|
-
|
|
306
|
+
const unresolved = upgrade.unresolved_update_names;
|
|
307
|
+
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: ${unresolved?.length ?? 'unknown'}${upgrade.lookup_failures ? `; failed lookup chunks: ${upgrade.lookup_failures}` : ''}`);
|
|
308
|
+
if (unresolved?.length) lines.push(`Unresolved skipped update names (not visible in sys_metadata): ${unresolved.slice(0, 20).join(', ')}${unresolved.length > 20 ? `, ... and ${unresolved.length - 20} more` : ''}`);
|
|
307
309
|
}
|
|
308
|
-
lines.push(`Unmatched upgrade history records: ${report.coverage.unmatched_upgrade_records}`);
|
|
310
|
+
lines.push(`Unmatched upgrade history records: ${report.coverage.unmatched_upgrade_records ?? 'unknown'}`);
|
|
311
|
+
return lines;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function formatReportDetailed(report) {
|
|
315
|
+
const lines = [`Customization capture ${report.run_id}${report.label ? ` (${report.label})` : ''}`, `Instance: ${report.instance}`, `Status: ${report.status}`, '', 'COVERAGE', ...coverageLines(report), '', 'INTERPRETATION', formatCustomizationGuidance()];
|
|
309
316
|
for (const [category, items] of Object.entries(report.categories)) {
|
|
310
317
|
lines.push('', `${category.toUpperCase()} (${items.length})`);
|
|
311
318
|
for (const item of items) {
|
|
@@ -357,8 +364,13 @@ export function formatReport(report, { groupBy = 'type', filter = '' } = {}) {
|
|
|
357
364
|
}
|
|
358
365
|
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
366
|
lines.push('', 'Coverage and limitations are shared across these artifacts.');
|
|
360
|
-
|
|
361
|
-
|
|
367
|
+
lines.push(...coverageLines(report), '', 'INTERPRETATION', formatCustomizationGuidance());
|
|
368
|
+
for (const [category, records] of Object.entries(report.categories)) {
|
|
369
|
+
if (!records.length) continue;
|
|
370
|
+
const triage = classifyCustomization(category);
|
|
371
|
+
lines.push(`${triage.label}: ${triage.confidence}. ${triage.summary}`);
|
|
372
|
+
for (const step of triage.next_steps) lines.push(`Next: ${step}`);
|
|
373
|
+
}
|
|
362
374
|
return lines.join('\n') + '\n';
|
|
363
375
|
}
|
|
364
376
|
|
|
@@ -395,18 +407,21 @@ function fieldChanges(before, after) {
|
|
|
395
407
|
function describeComparedItem(item) {
|
|
396
408
|
const identity = item?.identity || {};
|
|
397
409
|
const scope = item?.scope || {};
|
|
410
|
+
const triage = classifyCustomization(item?.category);
|
|
398
411
|
return [
|
|
399
412
|
`Artifact: ${identity.table || 'unknown'}:${identity.sys_id || 'unknown'} "${identity.name || identity.update_name || 'unnamed'}"`,
|
|
400
413
|
`Category: ${item?.category || 'unknown'}`,
|
|
401
414
|
`Scope: ${scope.scope || scope.sys_id || 'unknown'}${scope.name ? ` (${scope.name})` : ''}`,
|
|
402
415
|
`Evidence: ${(item?.evidence || []).map(evidence => `${evidence.source}:${evidence.sys_id}`).join(', ') || 'none captured'}`,
|
|
403
416
|
`Risk: ${item?.risk?.rating || 'unknown'}`,
|
|
417
|
+
`Triage: ${triage.label} (${triage.confidence}). ${triage.summary}`,
|
|
418
|
+
...triage.next_steps.map(step => `Next: ${step}`),
|
|
404
419
|
`Recommendation: ${item?.recommendation || 'not recorded'}`,
|
|
405
420
|
];
|
|
406
421
|
}
|
|
407
422
|
|
|
408
423
|
export function formatReportComparison(result) {
|
|
409
|
-
const lines = [`Customization comparison: ${result.status}`, `Baseline: ${result.baseline_run_id}`, `New: ${result.new_run_id}`, ...result.unknowns];
|
|
424
|
+
const lines = [`Customization comparison: ${result.status}`, `Baseline: ${result.baseline_run_id}`, `New: ${result.new_run_id}`, ...result.unknowns, '', 'INTERPRETATION', formatCustomizationGuidance()];
|
|
410
425
|
const impaired = result.impaired_sources || { baseline: [], new: [] };
|
|
411
426
|
if (impaired.baseline.length) lines.push(`Unreliable baseline sources: ${impaired.baseline.join(', ')}`);
|
|
412
427
|
if (impaired.new.length) lines.push(`Unreliable new sources: ${impaired.new.join(', ')}`);
|
|
@@ -511,7 +526,7 @@ export async function captureCustomizations({ sdk, customerScopes = [], pageSize
|
|
|
511
526
|
},
|
|
512
527
|
unmatched_upgrade_records: data.sys_upgrade_history_log.filter(row => !visibleNames.has(text(row.file_name))).length,
|
|
513
528
|
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
|
|
529
|
+
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 are not complete inventories; they can include sampled artifacts and artifacts resolved by skipped-history lookups. 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
530
|
},
|
|
516
531
|
};
|
|
517
532
|
}
|