@jacebenson/jsn 4.3.8-dev.47 → 4.3.8-dev.49
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 +92 -0
- package/package.json +1 -1
- package/skills/servicenow/SKILL.md +36 -0
- package/src/cli.js +4 -0
- package/src/commands/customizations.js +70 -0
- package/src/commands/skipped-updates.js +102 -0
- package/src/customizations.js +463 -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
package/README.md
CHANGED
|
@@ -44,6 +44,96 @@ private files and offers a foreground console, not native service registration.
|
|
|
44
44
|
See [direct MID installation](docs/mid-direct-install.md) for security boundaries,
|
|
45
45
|
lifecycle commands and the untested Windows host phases. Docker remains available.
|
|
46
46
|
|
|
47
|
+
## Customization reports
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
jsn customizations capture --label before-upgrade --customer-scope x_acme_app
|
|
51
|
+
jsn customizations list
|
|
52
|
+
jsn customizations show <run_id>
|
|
53
|
+
jsn customizations compare <baseline_id> <new_id> --json
|
|
54
|
+
jsn customizations show <run_id> --get data.coverage
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Capture data access uses Table API GET requests only. Captures never run
|
|
58
|
+
background scripts or create, update or delete instance records. Standard
|
|
59
|
+
authentication still applies, including OAuth refresh, local refreshed-credential
|
|
60
|
+
storage and session/GCK bootstrap, which can make authentication POST requests.
|
|
61
|
+
Expired credentials without a usable refresh token require login. Reports are private local JSON files in
|
|
62
|
+
`$JSN_DATA_HOME/customizations`, defaulting to `~/.jsn/customizations`. List,
|
|
63
|
+
show and compare work offline. Interactive terminals offer local report pickers;
|
|
64
|
+
pass explicit IDs with JSON, pipes or `--get`.
|
|
65
|
+
|
|
66
|
+
The bounded inventory joins visible `sys_metadata`, `sys_scope` and
|
|
67
|
+
`sys_upgrade_history_log` records. `ootb_modifications` contains artifacts
|
|
68
|
+
with historical skipped-upgrade evidence, not a verified current OOTB diff.
|
|
69
|
+
Because the broad `sys_metadata` read is an arbitrary `sys_id`-ordered slice
|
|
70
|
+
(994 of 621972 rows on a large instance), it is reported as a **bounded sample,
|
|
71
|
+
not an inventory**: it only ever describes the rows the page budget reached.
|
|
72
|
+
The skipped-upgrade artifacts are inventoried separately and completely for the
|
|
73
|
+
observed history: after the filtered upgrade-history read, every observed
|
|
74
|
+
`sys_update_name` is resolved with chunked GET-only
|
|
75
|
+
`sys_update_nameIN<chunk of names>` lookups on `sys_metadata`, so an artifact
|
|
76
|
+
with skipped-upgrade evidence appears in `ootb_modifications` even when it falls
|
|
77
|
+
far outside the sample. `coverage.upgrade_evidence` reports the skipped rows
|
|
78
|
+
observed, the distinct update names, how many resolved by direct lookup, the
|
|
79
|
+
unresolved name list and any failed lookup chunks.
|
|
80
|
+
`customer_app_artifacts` contains only scopes explicitly declared with repeatable
|
|
81
|
+
`--customer-scope` values or sys_ids. The instance verifies the scope identity,
|
|
82
|
+
not its ownership. Installed/vendor scopes are never automatically called
|
|
83
|
+
customer-built. Other visible artifacts remain `unclassified`, and both those
|
|
84
|
+
categories cover only what the bounded sample read.
|
|
85
|
+
|
|
86
|
+
Each item reports identity, scope, evidence, unknown risk with rationale and a
|
|
87
|
+
review recommendation. Historical OOTB findings include the known skipped-upgrade
|
|
88
|
+
impact and explicit unknown baseline, runtime impact and dependencies. No
|
|
89
|
+
reversion is performed or recommended as safe without further investigation.
|
|
90
|
+
|
|
91
|
+
Defaults are 100 records per page and 10 pages per source. Adjust `--page-size`
|
|
92
|
+
and `--max-pages` within the documented limits. Source coverage compares the rows
|
|
93
|
+
fetched with the Stats API count for the same query and reports both. A page
|
|
94
|
+
shorter than the requested size never implies exhaustion; `exhausted` is reported
|
|
95
|
+
only when the fetched rows account for the available total, and an unavailable
|
|
96
|
+
count is reported as unknown rather than implied completeness. Other statuses
|
|
97
|
+
distinguish missing fields, unstable pagination and read failures. Both the count
|
|
98
|
+
and the rows reflect visible ACL-filtered records, so hidden rows cannot be ruled
|
|
99
|
+
out and reports remain `incomplete`. An empty category does not prove that no
|
|
100
|
+
customizations exist. A 2xx table body without a `result` array is treated as an
|
|
101
|
+
unavailable source, never as an empty table.
|
|
102
|
+
|
|
103
|
+
Comparisons show newly observed, no-longer-observed and changed metadata/evidence.
|
|
104
|
+
They ignore local capture IDs, labels, timestamps and enumeration order.
|
|
105
|
+
They do not prove record creation/deletion or detect source-body changes without
|
|
106
|
+
captured metadata changes. Changed entries render the concrete before/after field
|
|
107
|
+
values, and additions/removals list the artifact's name, table, scope, category
|
|
108
|
+
and evidence basis. When either report has an unavailable or empty source, the
|
|
109
|
+
comparison status is `unreliable` and those differences are labelled as missing
|
|
110
|
+
coverage instead of mass creation or deletion. Compare only reports from the same
|
|
111
|
+
instance.
|
|
112
|
+
|
|
113
|
+
Provider contract: [ServiceNow Customizations Unchanged](https://www.servicenow.com/docs/r/xanadu/platform-administration/customizations-unchanged-related-list.html)
|
|
114
|
+
explains that the `Skipped` disposition preserves customizations. Other skip
|
|
115
|
+
reasons are not treated as equivalent evidence. This command cannot establish a
|
|
116
|
+
trusted baseline from that history alone.
|
|
117
|
+
|
|
118
|
+
The verified provider join is `sys_upgrade_history_log.file_name` to
|
|
119
|
+
`sys_metadata.sys_update_name`, not the log's nonexistent `name` field. Capture
|
|
120
|
+
uses raw values from `sysparm_display_value=all`: disposition `4` or `104`
|
|
121
|
+
means Skipped or Skipped (second pass). Resolution `reviewed_reverted` excludes
|
|
122
|
+
that history row; a present empty resolution is distinct from a missing field.
|
|
123
|
+
The upgrade-history read is filtered server-side to those dispositions so a
|
|
124
|
+
bounded capture spends its pages on preserved-customization evidence instead of
|
|
125
|
+
arbitrary history rows, and `unmatched_upgrade_records` counts only that filtered
|
|
126
|
+
population, not all upgrade history. The filtered history is the unit of the
|
|
127
|
+
inventory claim: every observed `file_name` is then resolved against visible
|
|
128
|
+
`sys_metadata` with chunked GET-only `sys_update_nameIN<chunk>` lookups, so
|
|
129
|
+
skipped artifacts are inventoried from the history instead of from the arbitrary
|
|
130
|
+
`sys_id`-ordered metadata sample. `unmatched_upgrade_records` and
|
|
131
|
+
`coverage.upgrade_evidence.unresolved_update_names` describe only names no
|
|
132
|
+
visible metadata row matched; a failed lookup chunk is reported rather than
|
|
133
|
+
treated as an absent artifact.
|
|
134
|
+
These values were checked against the PDI dictionary, choices and actual joined
|
|
135
|
+
rows. Display labels are not used for classification.
|
|
136
|
+
|
|
47
137
|
## Quick Start
|
|
48
138
|
|
|
49
139
|
```bash
|
|
@@ -344,6 +434,8 @@ Run `jsn` for the full grouped list. Commands are organized by ServiceNow domain
|
|
|
344
434
|
|
|
345
435
|
**Automation** — `flows`, `actions`, `rules`, `scrapi`, `updatesets`, `eval`, `rest`
|
|
346
436
|
|
|
437
|
+
**Upgrade review** — [`skipped-updates`](docs/skipped-updates.md) discovers existing unresolved skips, reviews current/base field changes and known dependencies, and processes only explicitly selected actions after an exact batch preview and one confirmation.
|
|
438
|
+
|
|
347
439
|
**Access** — `acls`, `roles`, `scopes`, `properties`, `privileges`
|
|
348
440
|
|
|
349
441
|
**User Experience** — `forms`, `lists`, `clientscripts`, `uipolicies`, `uiactions`
|
package/package.json
CHANGED
|
@@ -73,6 +73,42 @@ Pick the most specific tool for the job. **Never default to eval** — it's the
|
|
|
73
73
|
| **Docs** | `docs sync`, `docs status`, `docs search`, `docs serve` |
|
|
74
74
|
| **Developer** | `eval`, `rest`, `skill`, `version` |
|
|
75
75
|
|
|
76
|
+
### Customization evidence
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
jsn customizations capture --label review --customer-scope x_acme_app --json
|
|
80
|
+
jsn customizations list --json
|
|
81
|
+
jsn customizations show <run_id> --get data.categories
|
|
82
|
+
jsn customizations compare <baseline_id> <new_id> --json
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Capture performs bounded GET-only table reads and saves local reports. It never
|
|
86
|
+
evals or creates, updates or deletes instance records. Standard authentication
|
|
87
|
+
may refresh OAuth tokens, store refreshed credentials locally and bootstrap a
|
|
88
|
+
session/GCK using authentication POSTs. List/show/compare are offline.
|
|
89
|
+
Declare customer ownership with repeatable `--customer-scope` values or sys_ids;
|
|
90
|
+
never infer customer ownership from a non-global scope. The declaration is
|
|
91
|
+
caller evidence, not independent publisher verification. OOTB findings reflect
|
|
92
|
+
historical upgrade skips, not a current verified baseline deviation. The upgrade
|
|
93
|
+
history is read server-side for the skip dispositions and every observed skipped
|
|
94
|
+
`file_name` is resolved against `sys_metadata` by chunked
|
|
95
|
+
`sys_update_nameIN<chunk>` lookups, so the OOTB inventory covers the observed
|
|
96
|
+
skipped artifacts; the broad `sys_metadata` read is an arbitrary `sys_id`-ordered
|
|
97
|
+
slice and is reported as a bounded sample, not an inventory, describing only the
|
|
98
|
+
unclassified and declared-customer categories. Unresolved skipped names and
|
|
99
|
+
failed lookup chunks stay explicit rather than becoming absent artifacts, and a
|
|
100
|
+
resolved artifact can lack a scope name when its scope record was outside the
|
|
101
|
+
bounded `sys_scope` read. Missing
|
|
102
|
+
ACL access, pagination limits and unmatched evidence remain explicit. All
|
|
103
|
+
reports are incomplete; no findings is not proof of no customizations.
|
|
104
|
+
Comparisons cover captured metadata/evidence, not source payloads. Removals mean
|
|
105
|
+
no longer observed, not proven deletion. Reversion impact and dependencies
|
|
106
|
+
remain unknown until separately investigated. Never recommend automatic reversion.
|
|
107
|
+
|
|
108
|
+
Upgrade history joins `file_name` to metadata `sys_update_name`. Raw disposition
|
|
109
|
+
`4` or `104` is skipped-customization evidence; resolution `reviewed_reverted`
|
|
110
|
+
excludes that history row. Use raw values, not display labels or other skip reasons.
|
|
111
|
+
|
|
76
112
|
### Complete an update set
|
|
77
113
|
|
|
78
114
|
```bash
|
package/src/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { App } from './app.js';
|
|
|
9
9
|
import { resolveSession, applySession } from './session.js';
|
|
10
10
|
import { renderHelp } from './help.js';
|
|
11
11
|
import { isMutationCommand, refreshMutationCommands } from './mutations.js';
|
|
12
|
+
import { skippedUpdatesCmd } from './commands/skipped-updates.js';
|
|
12
13
|
import { noInstanceCommands, dailyCheckSkipCommands } from './capabilities.js';
|
|
13
14
|
import { guardExit, exitWithError, AppError, CodeUsage } from './errors.js';
|
|
14
15
|
import { formatCommandList } from './helpers.js';
|
|
@@ -70,6 +71,7 @@ import { b4rulesCmd } from './commands/b4rules.js';
|
|
|
70
71
|
import { atfCmd } from './commands/atf.js';
|
|
71
72
|
import { approvalsCmd } from './commands/approvals.js';
|
|
72
73
|
import { perfCmd } from './commands/perf.js';
|
|
74
|
+
import { customizationsCmd } from './commands/customizations.js';
|
|
73
75
|
import { midserversCmd } from './commands/midservers.js';
|
|
74
76
|
import { diffCmd } from './commands/diff.js';
|
|
75
77
|
import { graphqlCmd } from './commands/graphql.js';
|
|
@@ -405,7 +407,9 @@ export function buildCLI() {
|
|
|
405
407
|
// DEVELOPER
|
|
406
408
|
.command(atfCmd(wrap))
|
|
407
409
|
.command(approvalsCmd(wrap))
|
|
410
|
+
.command(skippedUpdatesCmd(wrap))
|
|
408
411
|
.command(perfCmd(wrap))
|
|
412
|
+
.command(customizationsCmd(wrap))
|
|
409
413
|
.command(midserversCmd(wrap))
|
|
410
414
|
.command(diffCmd(wrap))
|
|
411
415
|
.command(graphqlCmd(wrap))
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
2
|
+
import { errUsageHint, errUsage } from '../errors.js';
|
|
3
|
+
import { canUsePicker } from '../helpers.js';
|
|
4
|
+
import { paginatedSearch } from '../paginated-search.js';
|
|
5
|
+
import { captureReport, listReports, getReport, compareReports, formatReport, formatReportList, formatReportComparison } from '../customizations.js';
|
|
6
|
+
|
|
7
|
+
// Only capture needs an instance. Saved reports never need network access.
|
|
8
|
+
declareCapabilities('customizations', { noInstance: true, skipDailyChecks: true, mutationSubcommands: [] });
|
|
9
|
+
const listHint = { action: 'list', cmd: 'jsn customizations list', description: 'Browse saved customization captures' };
|
|
10
|
+
const showHint = id => ({ action: 'show', cmd: `jsn customizations show ${id}`, description: 'Review this saved capture' });
|
|
11
|
+
const compareHint = id => ({ action: 'compare', cmd: `jsn customizations compare ${id} OTHER_RUN_ID`, description: 'Compare captured metadata and evidence' });
|
|
12
|
+
function requireReport(id) {
|
|
13
|
+
const report = getReport(id);
|
|
14
|
+
if (!report) throw errUsageHint(`Unknown customization report: ${id}`, 'Run `jsn customizations list` to find saved captures.');
|
|
15
|
+
return report;
|
|
16
|
+
}
|
|
17
|
+
async function pickReport(app, argv, exclude = null, message = 'Select a customization capture') {
|
|
18
|
+
if (!canUsePicker(app, argv)) throw errUsageHint('A report ID is required when prompts are unavailable.', 'Run `jsn customizations list`, then pass the run ID explicitly.');
|
|
19
|
+
const runs = listReports({ limit: 1000 }).filter(run => run.run_id !== exclude);
|
|
20
|
+
if (!runs.length) throw errUsage('No saved customization captures available');
|
|
21
|
+
const picked = await paginatedSearch({ message, totalCount: runs.length, pageSize: 10, source: async (term = '') => runs.filter(run => `${run.run_id} ${run.label} ${run.instance}`.toLowerCase().includes(term.toLowerCase())).map(run => ({ name: `${run.run_id} ${run.label || '(unlabeled)'} ${run.instance}`, value: run })) });
|
|
22
|
+
return picked?.value ? requireReport(picked.value.run_id) : null;
|
|
23
|
+
}
|
|
24
|
+
function show(app, report) {
|
|
25
|
+
app.ok({ ...report, _formatted: formatReport(report) }, { summary: `Customization capture ${report.run_id}: ${report.status}`, breadcrumbs: [listHint, showHint(report.run_id), compareHint(report.run_id)] });
|
|
26
|
+
}
|
|
27
|
+
export function customizationsCmd(wrap) {
|
|
28
|
+
return {
|
|
29
|
+
command: 'customizations [subcommand]',
|
|
30
|
+
describe: 'Capture and compare read-only customization evidence locally',
|
|
31
|
+
builder: y => y
|
|
32
|
+
.command({ command: 'capture', describe: 'Capture bounded read-only metadata and historical upgrade evidence',
|
|
33
|
+
builder: b => b.option('label', { type: 'string', describe: 'Label for this capture' })
|
|
34
|
+
.option('customer-scope', { type: 'string', array: true, default: [], describe: 'Declared customer-owned scope value or sys_id, repeatable; ownership is not inferred' })
|
|
35
|
+
.option('page-size', { type: 'number', default: 100, describe: 'Records per table page, 1..1000' })
|
|
36
|
+
.option('max-pages', { type: 'number', default: 10, describe: 'Maximum pages per source, 1..100' }),
|
|
37
|
+
handler: wrap(async (argv, app) => {
|
|
38
|
+
app.requireInstance();
|
|
39
|
+
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');
|
|
40
|
+
const customerScopes = Array.isArray(argv.customerScope) ? argv.customerScope : argv.customerScope ? [argv.customerScope] : [];
|
|
41
|
+
const report = await captureReport({ sdk: app.sdk, instance: app.getEffectiveInstance(), profile: app.session?.profileName || '', label: argv.label || '', customerScopes, pageSize: argv.pageSize, maxPages: argv.maxPages });
|
|
42
|
+
show(app, report);
|
|
43
|
+
}),
|
|
44
|
+
})
|
|
45
|
+
.command({ command: 'list', aliases: ['ls'], describe: 'List saved local captures',
|
|
46
|
+
builder: b => b.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Maximum captures to list' }),
|
|
47
|
+
handler: wrap(async (argv, app) => {
|
|
48
|
+
if (canUsePicker(app, argv)) { const report = await pickReport(app, argv); if (report) show(app, report); return; }
|
|
49
|
+
const runs = listReports({ limit: argv.limit });
|
|
50
|
+
app.ok({ runs, count: runs.length, _formatted: formatReportList(runs) }, { summary: `${runs.length} customization capture(s)`, breadcrumbs: [listHint, ...(runs[0] ? [showHint(runs[0].run_id)] : [])] });
|
|
51
|
+
}),
|
|
52
|
+
})
|
|
53
|
+
.command({ command: 'show [run_id]', describe: 'Review a saved capture offline', handler: wrap(async (argv, app) => {
|
|
54
|
+
const report = argv.run_id ? requireReport(argv.run_id) : await pickReport(app, argv);
|
|
55
|
+
if (report) show(app, report);
|
|
56
|
+
}) })
|
|
57
|
+
.command({ command: 'compare [baseline] [new]', describe: 'Compare two saved captures from the same instance', handler: wrap(async (argv, app) => {
|
|
58
|
+
if (!!argv.baseline !== !!argv.new) throw errUsage('Compare requires exactly two report IDs');
|
|
59
|
+
if (argv.baseline && argv.baseline === argv.new) throw errUsage('Compare requires two different report IDs');
|
|
60
|
+
const baseline = argv.baseline ? requireReport(argv.baseline) : await pickReport(app, argv, null, 'Select the baseline capture');
|
|
61
|
+
if (!baseline) return;
|
|
62
|
+
const newer = argv.new ? requireReport(argv.new) : await pickReport(app, argv, baseline.run_id, 'Select the new capture');
|
|
63
|
+
if (!newer) return;
|
|
64
|
+
if (baseline.instance !== newer.instance) throw errUsage('Customization reports must describe the same instance');
|
|
65
|
+
const result = compareReports(baseline, newer);
|
|
66
|
+
app.ok({ ...result, _formatted: formatReportComparison(result) }, { summary: `Customization comparison: ${result.status}`, breadcrumbs: [listHint, showHint(baseline.run_id), showHint(newer.run_id)] });
|
|
67
|
+
}) }),
|
|
68
|
+
handler: wrap(async (_argv, app) => app.ok({ command: 'customizations', subcommands: ['capture', 'list', 'show', 'compare'] }, { summary: 'Use `jsn customizations <subcommand> --help` for details.' })),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { checkbox, select, confirm } from '@inquirer/prompts';
|
|
2
|
+
import { canUsePicker, interactiveList, getStringField } from '../helpers.js';
|
|
3
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
4
|
+
import { errUsage } from '../errors.js';
|
|
5
|
+
import { discoverSkippedUpdates, reviewSkippedUpdate, prepareSkippedBatch, processSkippedBatch, raw, SKIP_TABLE } from '../skipped-updates.js';
|
|
6
|
+
|
|
7
|
+
const label = row => `${raw(row.sys_id)} ${getStringField(row, 'target_name') || getStringField(row, 'file_name')}`;
|
|
8
|
+
const selectionOptions = y => y
|
|
9
|
+
.option('ids', { type: 'string', describe: 'Comma-separated literal finding sys_ids, maximum 100' })
|
|
10
|
+
.option('action', { type: 'string', choices: ['retain', 'revert'], describe: 'Explicit selected action, never inferred from recommendations' });
|
|
11
|
+
|
|
12
|
+
export function renderSkippedPreview(preview) {
|
|
13
|
+
const lines = [`Instance: ${preview.instance}`, `Exact selected batch: ${preview.count} finding(s)`];
|
|
14
|
+
for (const item of preview.selected) {
|
|
15
|
+
lines.push(`\n${item.sys_id}: ${item.artifact.update_name}`, `Action: ${item.action}`, item.operation,
|
|
16
|
+
`Recommendation only: ${item.impact.recommendation.action}. ${item.impact.recommendation.basis}`,
|
|
17
|
+
`State: disposition=${item.finding.disposition}, resolution=${item.finding.resolution_status}`,
|
|
18
|
+
`Evidence: base version ${item.impact.evidence.baseline_version || 'unknown'}, source ${item.impact.evidence.baseline_source_table || 'unknown'}/${item.impact.evidence.baseline_source || 'unknown'}`,
|
|
19
|
+
`Finding: ${preview.instance}/${SKIP_TABLE}.do?sys_id=${item.sys_id}`,
|
|
20
|
+
item.impact.direct_behavior.summary);
|
|
21
|
+
for (const change of item.impact.changes) lines.push(` ${change.field}: ${JSON.stringify(change.current)} -> ${JSON.stringify(change.base)}`, ` ${change.effect}`);
|
|
22
|
+
for (const dep of item.impact.known_dependencies) lines.push(` Known dependency: ${dep.table}.${dep.field} / ${dep.sys_id}`);
|
|
23
|
+
for (const gap of item.impact.unknown) lines.push(` Unknown: ${gap}`);
|
|
24
|
+
}
|
|
25
|
+
lines.push(`\nConfirmation token: ${preview.confirmation_token}`, 'No writes have been performed.');
|
|
26
|
+
return lines.join('\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function skippedUpdatesCmd(wrap, ui = {}) {
|
|
30
|
+
declareCapabilities('skipped-updates', { mutationSubcommands: ['process'] });
|
|
31
|
+
const prompts = { checkbox, select, confirm, canUsePicker, interactiveList, ...ui };
|
|
32
|
+
async function showReview(app, id) {
|
|
33
|
+
const review = await reviewSkippedUpdate(app.sdk, id);
|
|
34
|
+
const display = { instance: app.getEffectiveInstance(), selected: [{ ...review, action: 'none', operation: 'Read-only review.' }], count: 1, confirmation_token: 'not applicable' };
|
|
35
|
+
app.ok({ ...review, _formatted: renderSkippedPreview(display) }, { summary: 'Skipped update review; deeper effects remain unknown' });
|
|
36
|
+
}
|
|
37
|
+
async function selection(argv, app, guided) {
|
|
38
|
+
let ids = typeof argv.ids === 'string' ? argv.ids.split(',') : undefined;
|
|
39
|
+
let action = argv.action;
|
|
40
|
+
if (!ids && guided) {
|
|
41
|
+
const page = await discoverSkippedUpdates(app.sdk, { limit: 100, offset: 0 });
|
|
42
|
+
if (!page.count) throw errUsage('No unresolved skipped findings were returned.');
|
|
43
|
+
ids = await prompts.checkbox({ message: `Select findings from this page${page.has_more ? ' (more exist; use list --offset for others)' : ''}`,
|
|
44
|
+
choices: page.findings.map(row => ({ name: label(row), value: raw(row.sys_id) })), required: true });
|
|
45
|
+
}
|
|
46
|
+
if (!ids?.length) throw errUsage('Provide --ids with the exact selected finding sys_ids.');
|
|
47
|
+
if (!action && guided) action = await prompts.select({ message: 'Choose the action for these findings',
|
|
48
|
+
choices: [{ name: 'Retain customization, mark reviewed and retained', value: 'retain' },
|
|
49
|
+
{ name: 'Return to base system, discard customization', value: 'revert' }] });
|
|
50
|
+
return { ids, action, instance: app.getEffectiveInstance() };
|
|
51
|
+
}
|
|
52
|
+
return { command: 'skipped-updates', describe: 'Review and process existing unresolved skipped updates across all upgrade history',
|
|
53
|
+
builder: y => y
|
|
54
|
+
.command({ command: 'list', describe: 'Discover unresolved skips instance-wide, independent of new upgrades',
|
|
55
|
+
builder: y => y.option('limit', { type: 'number', default: 100, describe: 'Page size, maximum 500' })
|
|
56
|
+
.option('offset', { type: 'number', default: 0, describe: 'Page offset' }),
|
|
57
|
+
handler: wrap(async (argv, app) => {
|
|
58
|
+
app.requireInstance();
|
|
59
|
+
if (prompts.canUsePicker(app, argv)) {
|
|
60
|
+
const selected = await prompts.interactiveList({ app, table: SKIP_TABLE, singular: 'skipped update',
|
|
61
|
+
columns: ['target_name', 'file_name', 'disposition', 'resolution_status'], labelField: 'target_name',
|
|
62
|
+
query: 'dispositionIN4,104,9,10,11^resolution_statusIN,not_reviewed', formatLabel: label });
|
|
63
|
+
if (selected === undefined) return;
|
|
64
|
+
if (selected) { await showReview(app, raw(selected.sys_id)); return; }
|
|
65
|
+
}
|
|
66
|
+
const page = await discoverSkippedUpdates(app.sdk, argv);
|
|
67
|
+
app.ok(page, { summary: `${page.count} unresolved finding(s) fetched${page.has_more ? '; more available' : ''}. Instance-wide total is not measured.` });
|
|
68
|
+
}) })
|
|
69
|
+
.command({ command: 'review <sys-id>', describe: 'Review artifact evidence, field changes and bounded known dependencies',
|
|
70
|
+
handler: wrap(async (argv, app) => { app.requireInstance();
|
|
71
|
+
await showReview(app, argv['sys-id']);
|
|
72
|
+
}) })
|
|
73
|
+
.command({ command: 'preview', describe: 'Read-only exact batch preview and confirmation token', builder: selectionOptions,
|
|
74
|
+
handler: wrap(async (argv, app) => { app.requireInstance();
|
|
75
|
+
const options = await selection(argv, app, prompts.canUsePicker(app, argv));
|
|
76
|
+
const preview = await prepareSkippedBatch(app.sdk, options);
|
|
77
|
+
app.ok({ ...preview, _formatted: renderSkippedPreview(preview) }, { summary: 'Read-only batch preview; review before confirming' });
|
|
78
|
+
}) })
|
|
79
|
+
.command({ command: 'process', describe: 'Apply selected action after ONE explicit confirmation of the exact preview',
|
|
80
|
+
builder: y => selectionOptions(y).option('confirm', { type: 'string', describe: 'Confirmation token from a previously reviewed exact preview; required in automation' }),
|
|
81
|
+
handler: wrap(async (argv, app) => { app.requireInstance();
|
|
82
|
+
try {
|
|
83
|
+
const guided = prompts.canUsePicker(app, argv);
|
|
84
|
+
const options = await selection(argv, app, guided);
|
|
85
|
+
const result = await processSkippedBatch({ sdk: app.sdk, ...options,
|
|
86
|
+
readOnly: app.session?.readOnly === true, confirmationToken: argv.confirm,
|
|
87
|
+
onPreview: guided ? async preview => app.ok({ ...preview, _formatted: renderSkippedPreview(preview) }, { summary: 'Review exact batch before confirmation' }) : undefined,
|
|
88
|
+
confirm: guided && !argv.confirm ? async () => prompts.confirm({ message: 'Apply exactly this selected batch?', default: false }) : undefined });
|
|
89
|
+
app.ok({ ...result, _formatted: result.cancelled ? 'Cancelled. No writes performed.'
|
|
90
|
+
: result.results.map(row => `${row.sys_id} ${row.action}: ${row.success ? 'verified success' : `FAILED (${row.effect})`}`).join('\n') },
|
|
91
|
+
{ summary: `${result.success_count} succeeded; ${result.failure_count} failed${result.partial ? '; partial batch' : ''}` });
|
|
92
|
+
if (result.failure_count) process.exitCode = 1;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (err.name === 'ExitPromptError' || err.name === 'AbortPromptError') {
|
|
95
|
+
app.ok({ cancelled: true, writes_performed: false }, { summary: 'Cancelled. No writes performed.' }); return;
|
|
96
|
+
}
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
}) })
|
|
100
|
+
.demandCommand(1, 'Choose list, review, preview or process'),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,463 @@
|
|
|
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
|
+
|
|
9
|
+
// Raw provider choices: 4 = Skipped, 104 = Skipped (second pass). Other skip
|
|
10
|
+
// dispositions do not establish a preserved customization, so the upgrade
|
|
11
|
+
// history read is filtered server-side and a bounded budget spends its pages
|
|
12
|
+
// on preserved-customization evidence instead of arbitrary history rows.
|
|
13
|
+
export const SKIP_DISPOSITIONS = ['4', '104'];
|
|
14
|
+
const SKIP_QUERY = `dispositionIN${SKIP_DISPOSITIONS.join(',')}`;
|
|
15
|
+
const SOURCES = {
|
|
16
|
+
sys_scope: { fields: 'sys_id,name,scope,sys_class_name', query: 'ORDERBYsys_id', countQuery: '', kind: 'inventory read' },
|
|
17
|
+
sys_metadata: { fields: 'sys_id,sys_name,sys_class_name,sys_scope,sys_update_name,sys_updated_on', query: 'ORDERBYsys_id', countQuery: '', kind: 'bounded sample' },
|
|
18
|
+
sys_upgrade_history_log: { fields: 'sys_id,file_name,disposition,resolution_status,sys_updated_on', query: `${SKIP_QUERY}^ORDERBYsys_id`, countQuery: SKIP_QUERY, kind: 'inventory read' },
|
|
19
|
+
};
|
|
20
|
+
// The broad sys_metadata read is an arbitrary sys_id-ordered slice of a table
|
|
21
|
+
// that can hold hundreds of thousands of rows, so it can only ever be a bounded
|
|
22
|
+
// sample of the instance. Skipped-upgrade artifacts are therefore resolved
|
|
23
|
+
// directly by the update names the filtered history read observed, so every
|
|
24
|
+
// skipped artifact visible on the instance reaches the inventory.
|
|
25
|
+
export const RESOLUTION_SOURCE = 'sys_metadata_name_resolution';
|
|
26
|
+
export const RESOLUTION_CHUNK_SIZE = 25;
|
|
27
|
+
const RESOLUTION_MAX_PAGES = 3;
|
|
28
|
+
const value = v => v && typeof v === 'object' ? v.value ?? '' : v ?? '';
|
|
29
|
+
const text = v => String(value(v));
|
|
30
|
+
|
|
31
|
+
// How many rows are available for the same query? The Stats API count is the
|
|
32
|
+
// only evidence that lets a capture claim completeness; without it coverage
|
|
33
|
+
// must stay unknown rather than implied by a page that happened to end.
|
|
34
|
+
async function availableCount(sdk, table, countQuery, fetched) {
|
|
35
|
+
if (typeof sdk.aggregateCount !== 'function') return null;
|
|
36
|
+
let count;
|
|
37
|
+
try {
|
|
38
|
+
count = await sdk.aggregateCount(table, countQuery);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (!Number.isFinite(count) || count < 0) return null;
|
|
43
|
+
// A zero count with rows in hand means the count is unusable, not that the
|
|
44
|
+
// table is empty.
|
|
45
|
+
if (count === 0 && fetched > 0) return null;
|
|
46
|
+
return Math.floor(count);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A per-query count with no zero/short-page guessing: a count of 0 for a name
|
|
50
|
+
// chunk means no visible row matched that chunk, which is real evidence.
|
|
51
|
+
async function statsCount(sdk, table, query) {
|
|
52
|
+
if (typeof sdk.aggregateCount !== 'function') return null;
|
|
53
|
+
try {
|
|
54
|
+
const count = await sdk.aggregateCount(table, query);
|
|
55
|
+
return Number.isFinite(count) && count >= 0 ? Math.floor(count) : null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readSource(sdk, table, { fields, query, countQuery, kind }, pageSize, maxPages) {
|
|
62
|
+
const rows = [];
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
const coverage = {
|
|
65
|
+
status: 'limited', fetched: 0, available: null, pages: 0, duplicate_rows: 0, missing_identity: 0,
|
|
66
|
+
query, ...(kind ? { kind } : {}), reason: 'Page limit reached; remaining records unknown.',
|
|
67
|
+
};
|
|
68
|
+
// The strict seam rejects a 2xx body without a result array, so a malformed
|
|
69
|
+
// envelope becomes an unavailable source instead of an empty inventory.
|
|
70
|
+
const listFn = typeof sdk.listStrict === 'function' ? sdk.listStrict.bind(sdk) : sdk.list.bind(sdk);
|
|
71
|
+
let failure = '';
|
|
72
|
+
try {
|
|
73
|
+
for (let page = 0; page < maxPages; page++) {
|
|
74
|
+
const batch = await listFn(table, {
|
|
75
|
+
sysparm_fields: fields, sysparm_query: query,
|
|
76
|
+
sysparm_display_value: 'all', sysparm_exclude_reference_link: 'true',
|
|
77
|
+
sysparm_limit: String(pageSize), sysparm_offset: String(page * pageSize),
|
|
78
|
+
}, { readOnly: true });
|
|
79
|
+
coverage.pages++;
|
|
80
|
+
if (!Array.isArray(batch)) throw new Error('Invalid table response');
|
|
81
|
+
for (const row of batch) {
|
|
82
|
+
const id = text(row.sys_id);
|
|
83
|
+
if (!id) { coverage.missing_identity++; continue; }
|
|
84
|
+
if (seen.has(id)) { coverage.duplicate_rows++; continue; }
|
|
85
|
+
seen.add(id);
|
|
86
|
+
rows.push(row);
|
|
87
|
+
const required = table === 'sys_metadata' ? ['sys_class_name', 'sys_scope', 'sys_update_name'] : table === 'sys_scope' ? ['scope'] : ['file_name', 'disposition', 'resolution_status'];
|
|
88
|
+
if (required.some(field => !Object.hasOwn(row, field))) coverage.missing_identity++;
|
|
89
|
+
}
|
|
90
|
+
coverage.fetched = rows.length;
|
|
91
|
+
// A short page is not exhaustion: ServiceNow returns short offset pages
|
|
92
|
+
// on large tables. Keep reading within the page budget.
|
|
93
|
+
if (batch.length === 0) break;
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
failure = String(error?.message || error);
|
|
97
|
+
}
|
|
98
|
+
if (failure) {
|
|
99
|
+
coverage.status = /401|403|permission|forbidden|access denied/i.test(failure) ? 'permission_denied' : 'unavailable';
|
|
100
|
+
coverage.reason = 'Table read failed; partial results retained. Authentication, ACLs, table availability or network access may limit coverage.';
|
|
101
|
+
return { rows, coverage };
|
|
102
|
+
}
|
|
103
|
+
coverage.available = await availableCount(sdk, table, countQuery, rows.length);
|
|
104
|
+
const duplicateNote = coverage.duplicate_rows
|
|
105
|
+
? ` Pagination returned ${coverage.duplicate_rows} repeated identit${coverage.duplicate_rows === 1 ? 'y' : 'ies'}; offset paging on large tables can overlap or skip.`
|
|
106
|
+
: '';
|
|
107
|
+
if (coverage.missing_identity) {
|
|
108
|
+
coverage.status = 'missing_fields';
|
|
109
|
+
coverage.reason = 'Some records omitted identity or classification fields; field ACLs or malformed responses may hide evidence.';
|
|
110
|
+
} else if (coverage.available !== null && coverage.fetched >= coverage.available) {
|
|
111
|
+
coverage.status = 'exhausted';
|
|
112
|
+
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.`;
|
|
113
|
+
} else if (coverage.available !== null) {
|
|
114
|
+
coverage.status = 'limited';
|
|
115
|
+
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.`;
|
|
116
|
+
} else if (coverage.duplicate_rows) {
|
|
117
|
+
coverage.status = 'unstable';
|
|
118
|
+
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.';
|
|
119
|
+
} else {
|
|
120
|
+
coverage.status = 'limited';
|
|
121
|
+
coverage.reason = 'The Stats API count for this query was unavailable, so visible-result completeness cannot be claimed.';
|
|
122
|
+
}
|
|
123
|
+
coverage.reason += duplicateNote;
|
|
124
|
+
return { rows, coverage };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Resolve observed skipped-upgrade update names against visible sys_metadata
|
|
128
|
+
// with chunked GET-only `sys_update_nameIN<chunk>` queries. The broad sample
|
|
129
|
+
// cannot be relied on for this: it is an arbitrary sys_id-ordered slice, so an
|
|
130
|
+
// inventory claim needs the artifacts the history read actually named.
|
|
131
|
+
// Read failures are reported per chunk instead of being hidden behind the
|
|
132
|
+
// sample, and completeness stays anchored to the Stats API count for the exact
|
|
133
|
+
// name queries used, never to a page that happened to end.
|
|
134
|
+
async function resolveUpdateNames(sdk, names) {
|
|
135
|
+
const requested = [...new Set(names)].sort();
|
|
136
|
+
const rows = [];
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
const resolved = new Set();
|
|
139
|
+
const coverage = {
|
|
140
|
+
kind: 'targeted name resolution', status: 'limited', fetched: 0, available: null, pages: 0, chunks: 0,
|
|
141
|
+
query: 'sys_update_nameIN<observed skipped update names>', requested_names: requested.length,
|
|
142
|
+
resolved_names: 0, unresolved_names: 0, failed_chunks: 0, reason: '',
|
|
143
|
+
};
|
|
144
|
+
if (!requested.length) {
|
|
145
|
+
coverage.query = '';
|
|
146
|
+
coverage.reason = 'No skipped upgrade rows were observed, so no update names required resolution.';
|
|
147
|
+
return { rows, resolved, coverage };
|
|
148
|
+
}
|
|
149
|
+
const listFn = typeof sdk.listStrict === 'function' ? sdk.listStrict.bind(sdk) : sdk.list.bind(sdk);
|
|
150
|
+
const failures = [];
|
|
151
|
+
let countSum = 0;
|
|
152
|
+
let countKnown = true;
|
|
153
|
+
for (let start = 0; start < requested.length; start += RESOLUTION_CHUNK_SIZE) {
|
|
154
|
+
const chunk = requested.slice(start, start + RESOLUTION_CHUNK_SIZE);
|
|
155
|
+
const limit = Math.max(chunk.length, 1);
|
|
156
|
+
const query = `sys_update_nameIN${chunk.join(',')}`;
|
|
157
|
+
coverage.chunks++;
|
|
158
|
+
try {
|
|
159
|
+
for (let page = 0; page < RESOLUTION_MAX_PAGES; page++) {
|
|
160
|
+
const batch = await listFn('sys_metadata', {
|
|
161
|
+
sysparm_fields: SOURCES.sys_metadata.fields, sysparm_query: `${query}^ORDERBYsys_id`,
|
|
162
|
+
sysparm_display_value: 'all', sysparm_exclude_reference_link: 'true',
|
|
163
|
+
sysparm_limit: String(limit), sysparm_offset: String(page * limit),
|
|
164
|
+
}, { readOnly: true });
|
|
165
|
+
coverage.pages++;
|
|
166
|
+
if (!Array.isArray(batch)) throw new Error('Invalid table response');
|
|
167
|
+
for (const row of batch) {
|
|
168
|
+
const updateName = text(row.sys_update_name);
|
|
169
|
+
if (updateName && chunk.includes(updateName)) resolved.add(updateName);
|
|
170
|
+
const id = text(row.sys_id);
|
|
171
|
+
if (!id || seen.has(id)) continue;
|
|
172
|
+
seen.add(id);
|
|
173
|
+
rows.push(row);
|
|
174
|
+
}
|
|
175
|
+
coverage.fetched = rows.length;
|
|
176
|
+
if (batch.length === 0) break;
|
|
177
|
+
}
|
|
178
|
+
} catch (error) {
|
|
179
|
+
failures.push(String(error?.message || error));
|
|
180
|
+
}
|
|
181
|
+
const counted = await statsCount(sdk, 'sys_metadata', query);
|
|
182
|
+
if (counted === null) countKnown = false;
|
|
183
|
+
else countSum += counted;
|
|
184
|
+
}
|
|
185
|
+
coverage.available = countKnown ? countSum : null;
|
|
186
|
+
coverage.resolved_names = resolved.size;
|
|
187
|
+
coverage.unresolved_names = requested.length - resolved.size;
|
|
188
|
+
if (failures.length) {
|
|
189
|
+
coverage.status = failures.some(message => /401|403|permission|forbidden|access denied/i.test(message)) ? 'permission_denied' : 'unavailable';
|
|
190
|
+
coverage.failed_chunks = failures.length;
|
|
191
|
+
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.`;
|
|
192
|
+
} else if (coverage.available !== null && coverage.fetched >= coverage.available) {
|
|
193
|
+
coverage.status = 'exhausted';
|
|
194
|
+
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.`;
|
|
195
|
+
} else if (coverage.available !== null) {
|
|
196
|
+
coverage.status = 'limited';
|
|
197
|
+
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.`;
|
|
198
|
+
} else {
|
|
199
|
+
coverage.status = 'limited';
|
|
200
|
+
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.`;
|
|
201
|
+
}
|
|
202
|
+
return { rows, resolved, coverage };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export const CUSTOMIZATION_SCHEMA_VERSION = 1;
|
|
206
|
+
export function getCustomizationsDir() {
|
|
207
|
+
return path.join(process.env.JSN_DATA_HOME || path.join(os.homedir(), '.jsn'), 'customizations');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function reportFile(id, dir) {
|
|
211
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(String(id))) throw new Error('Invalid customization report ID');
|
|
212
|
+
return path.join(dir, `${id}.json`);
|
|
213
|
+
}
|
|
214
|
+
function validateReport(report) {
|
|
215
|
+
if (report?.capture_schema_version !== CUSTOMIZATION_SCHEMA_VERSION || !report.instance || !report.categories || !report.coverage) throw new Error('Unsupported or invalid customization report schema');
|
|
216
|
+
for (const category of ['ootb_modifications', 'customer_app_artifacts', 'unclassified']) {
|
|
217
|
+
if (!Array.isArray(report.categories[category])) throw new Error('Invalid customization report categories');
|
|
218
|
+
}
|
|
219
|
+
return report;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function captureReport({ sdk, instance, profile = '', label = '', customerScopes = [], pageSize = 100, maxPages = 10, dir = getCustomizationsDir() }) {
|
|
223
|
+
if (!instance) throw new Error('An authenticated instance is required for customization capture');
|
|
224
|
+
const start_time = new Date().toISOString();
|
|
225
|
+
const capture = await captureCustomizations({ sdk, customerScopes, pageSize, maxPages });
|
|
226
|
+
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 };
|
|
227
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
228
|
+
fs.writeFileSync(reportFile(report.run_id, dir), JSON.stringify(report, null, 2), { mode: 0o600, flag: 'wx' });
|
|
229
|
+
return report;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function getReport(id, { dir = getCustomizationsDir() } = {}) {
|
|
233
|
+
try { return validateReport(JSON.parse(fs.readFileSync(reportFile(id, dir), 'utf8'))); }
|
|
234
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
235
|
+
}
|
|
236
|
+
export function listReports({ limit = 50, dir = getCustomizationsDir() } = {}) {
|
|
237
|
+
if (!fs.existsSync(dir)) return [];
|
|
238
|
+
return fs.readdirSync(dir).filter(name => name.endsWith('.json')).map(name => {
|
|
239
|
+
const report = getReport(name.slice(0, -5), { dir });
|
|
240
|
+
return { run_id: report.run_id, instance: report.instance, profile: report.profile, label: report.label, start_time: report.start_time, status: report.status };
|
|
241
|
+
}).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)));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Object key order and evidence enumeration order are not artifact changes.
|
|
245
|
+
function canonical(value) {
|
|
246
|
+
if (Array.isArray(value)) return value.map(canonical).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
247
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
|
|
248
|
+
return value;
|
|
249
|
+
}
|
|
250
|
+
function inventory(report) {
|
|
251
|
+
return new Map(Object.entries(report.categories).flatMap(([category, items]) => items.map(item => [`${item.identity.table}:${item.identity.sys_id}`, { category, ...item }])));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Sources that cannot be read are not empty sources. A comparison built on top
|
|
255
|
+
// of one must say so instead of presenting missing coverage as real removals.
|
|
256
|
+
function impairedSources(coverage) {
|
|
257
|
+
const sources = coverage?.sources || {};
|
|
258
|
+
return Object.entries(sources).filter(([, source]) => {
|
|
259
|
+
if (source.status === 'unavailable' || source.status === 'permission_denied') return true;
|
|
260
|
+
return source.fetched === 0 && source.available === null;
|
|
261
|
+
}).map(([table, source]) => `${table} (${source.status})`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function compareReports(baseline, newer) {
|
|
265
|
+
validateReport(baseline); validateReport(newer);
|
|
266
|
+
if (baseline.instance !== newer.instance) throw new Error('Customization reports must describe the same instance');
|
|
267
|
+
const a = inventory(baseline), b = inventory(newer);
|
|
268
|
+
const additions = [], removals = [], changes = [];
|
|
269
|
+
for (const id of [...new Set([...a.keys(), ...b.keys()])].sort()) {
|
|
270
|
+
if (!a.has(id)) additions.push({ id, availability: 'not_observed_in_baseline', item: b.get(id) });
|
|
271
|
+
else if (!b.has(id)) removals.push({ id, availability: 'not_observed_in_new', item: a.get(id) });
|
|
272
|
+
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) });
|
|
273
|
+
}
|
|
274
|
+
const impaired = { baseline: impairedSources(baseline.coverage), new: impairedSources(newer.coverage) };
|
|
275
|
+
const unreliable = impaired.baseline.length > 0 || impaired.new.length > 0;
|
|
276
|
+
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.'];
|
|
277
|
+
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.`);
|
|
278
|
+
return { baseline_run_id: baseline.run_id, new_run_id: newer.run_id, instance: baseline.instance, status: unreliable ? 'unreliable' : 'incomplete', additions, removals, changes,
|
|
279
|
+
impaired_sources: impaired,
|
|
280
|
+
coverage: { baseline: baseline.coverage, new: newer.coverage },
|
|
281
|
+
unknowns };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function formatReport(report) {
|
|
285
|
+
const lines = [`Customization capture ${report.run_id}${report.label ? ` (${report.label})` : ''}`, `Instance: ${report.instance}`, `Status: ${report.status}`, '', 'COVERAGE'];
|
|
286
|
+
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
|
+
for (const limitation of report.coverage.limitations) lines.push(`Unknown: ${limitation}`);
|
|
288
|
+
if (report.coverage.unmatched_customer_scopes.length) lines.push(`Unmatched customer declarations: ${report.coverage.unmatched_customer_scopes.join(', ')}`);
|
|
289
|
+
const upgrade = report.coverage.upgrade_evidence;
|
|
290
|
+
if (upgrade) {
|
|
291
|
+
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}` : ''}`);
|
|
292
|
+
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` : ''}`);
|
|
293
|
+
}
|
|
294
|
+
lines.push(`Unmatched upgrade history records: ${report.coverage.unmatched_upgrade_records}`);
|
|
295
|
+
for (const [category, items] of Object.entries(report.categories)) {
|
|
296
|
+
lines.push('', `${category.toUpperCase()} (${items.length})`);
|
|
297
|
+
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}`);
|
|
299
|
+
for (const evidence of item.evidence) lines.push(` Evidence: ${evidence.source}:${evidence.sys_id}. ${evidence.basis}`);
|
|
300
|
+
if (item.reversion) {
|
|
301
|
+
for (const impact of item.reversion.known_impact) lines.push(` Known impact: ${impact}`);
|
|
302
|
+
lines.push(` Known dependencies: ${item.reversion.known_dependencies.join(', ') || 'not established'}`);
|
|
303
|
+
for (const unknown of item.reversion.unknowns) lines.push(` Reversion unknown: ${unknown}`);
|
|
304
|
+
}
|
|
305
|
+
for (const unknown of item.unknowns) lines.push(` Unknown: ${unknown}`);
|
|
306
|
+
lines.push(` Recommendation: ${item.recommendation}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return lines.join('\n') + '\n';
|
|
310
|
+
}
|
|
311
|
+
export function formatReportList(runs) {
|
|
312
|
+
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
|
+
}
|
|
314
|
+
|
|
315
|
+
// Flatten a captured item into comparable leaf fields so a comparison can show
|
|
316
|
+
// what actually changed instead of only asserting that something did.
|
|
317
|
+
function flattenFields(source, prefix = '', out = {}) {
|
|
318
|
+
const current = prefix || 'value';
|
|
319
|
+
if (source === null || source === undefined) { out[current] = ''; return out; }
|
|
320
|
+
if (Array.isArray(source)) {
|
|
321
|
+
if (!source.length) { out[current] = '(none)'; return out; }
|
|
322
|
+
source.forEach((entry, index) => flattenFields(entry, `${prefix}[${index}]`, out));
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
if (typeof source === 'object') {
|
|
326
|
+
const keys = Object.keys(source).sort();
|
|
327
|
+
if (!keys.length) { out[current] = '(none)'; return out; }
|
|
328
|
+
for (const key of keys) flattenFields(source[key], prefix ? `${prefix}.${key}` : key, out);
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
out[current] = String(source);
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
function fieldChanges(before, after) {
|
|
335
|
+
const a = flattenFields(canonical(before));
|
|
336
|
+
const b = flattenFields(canonical(after));
|
|
337
|
+
return [...new Set([...Object.keys(a), ...Object.keys(b)])].sort()
|
|
338
|
+
.filter(field => a[field] !== b[field])
|
|
339
|
+
.map(field => ({ field, before: Object.hasOwn(a, field) ? a[field] : '(absent)', after: Object.hasOwn(b, field) ? b[field] : '(absent)' }));
|
|
340
|
+
}
|
|
341
|
+
function describeComparedItem(item) {
|
|
342
|
+
const identity = item?.identity || {};
|
|
343
|
+
const scope = item?.scope || {};
|
|
344
|
+
return [
|
|
345
|
+
`Artifact: ${identity.table || 'unknown'}:${identity.sys_id || 'unknown'} "${identity.name || identity.update_name || 'unnamed'}"`,
|
|
346
|
+
`Category: ${item?.category || 'unknown'}`,
|
|
347
|
+
`Scope: ${scope.scope || scope.sys_id || 'unknown'}${scope.name ? ` (${scope.name})` : ''}`,
|
|
348
|
+
`Evidence: ${(item?.evidence || []).map(evidence => `${evidence.source}:${evidence.sys_id}`).join(', ') || 'none captured'}`,
|
|
349
|
+
`Risk: ${item?.risk?.rating || 'unknown'}`,
|
|
350
|
+
`Recommendation: ${item?.recommendation || 'not recorded'}`,
|
|
351
|
+
];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function formatReportComparison(result) {
|
|
355
|
+
const lines = [`Customization comparison: ${result.status}`, `Baseline: ${result.baseline_run_id}`, `New: ${result.new_run_id}`, ...result.unknowns];
|
|
356
|
+
const impaired = result.impaired_sources || { baseline: [], new: [] };
|
|
357
|
+
if (impaired.baseline.length) lines.push(`Unreliable baseline sources: ${impaired.baseline.join(', ')}`);
|
|
358
|
+
if (impaired.new.length) lines.push(`Unreliable new sources: ${impaired.new.join(', ')}`);
|
|
359
|
+
for (const kind of ['additions', 'removals', 'changes']) {
|
|
360
|
+
lines.push('', `${kind.toUpperCase()} (${result[kind].length})`);
|
|
361
|
+
for (const entry of result[kind]) {
|
|
362
|
+
if (kind === 'changes') {
|
|
363
|
+
lines.push(`${entry.id}: ${entry.availability || 'captured metadata/evidence changed'}`);
|
|
364
|
+
const fields = fieldChanges(entry.baseline, entry.new);
|
|
365
|
+
for (const field of fields) lines.push(` ${field.field}: "${field.before}" -> "${field.after}"`);
|
|
366
|
+
if (!fields.length) lines.push(' No compared field differs; evidence ordering or another unrendered value changed.');
|
|
367
|
+
} else {
|
|
368
|
+
lines.push(`${entry.id}: ${entry.availability}`);
|
|
369
|
+
for (const detail of describeComparedItem(entry.item)) lines.push(` ${detail}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
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}`);
|
|
374
|
+
return lines.join('\n') + '\n';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export async function captureCustomizations({ sdk, customerScopes = [], pageSize = 100, maxPages = 10 }) {
|
|
378
|
+
if (!sdk) throw new Error('An authenticated SDK is required');
|
|
379
|
+
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');
|
|
380
|
+
const sources = {};
|
|
381
|
+
const data = {};
|
|
382
|
+
for (const [table, source] of Object.entries(SOURCES)) {
|
|
383
|
+
const result = await readSource(sdk, table, source, pageSize, maxPages);
|
|
384
|
+
sources[table] = result.coverage;
|
|
385
|
+
data[table] = result.rows;
|
|
386
|
+
}
|
|
387
|
+
// The broad sys_metadata read is a bounded sample, so the artifacts the
|
|
388
|
+
// filtered history read actually named are looked up directly. The source is
|
|
389
|
+
// only registered when there is something to resolve, so a report with no
|
|
390
|
+
// skipped rows has no phantom empty source.
|
|
391
|
+
const observedNames = data.sys_upgrade_history_log.map(row => text(row.file_name)).filter(Boolean);
|
|
392
|
+
const resolution = await resolveUpdateNames(sdk, observedNames);
|
|
393
|
+
if (observedNames.length) {
|
|
394
|
+
sources[RESOLUTION_SOURCE] = resolution.coverage;
|
|
395
|
+
const sampled = new Set(data.sys_metadata.map(row => text(row.sys_id)));
|
|
396
|
+
data.sys_metadata = [...data.sys_metadata, ...resolution.rows.filter(row => !sampled.has(text(row.sys_id)))];
|
|
397
|
+
}
|
|
398
|
+
const scopes = new Map(data.sys_scope.map(row => [text(row.sys_id), row]));
|
|
399
|
+
const declared = new Set(customerScopes);
|
|
400
|
+
const logs = new Map();
|
|
401
|
+
for (const row of data.sys_upgrade_history_log) {
|
|
402
|
+
const name = text(row.file_name);
|
|
403
|
+
if (!logs.has(name)) logs.set(name, []);
|
|
404
|
+
logs.get(name).push(row);
|
|
405
|
+
}
|
|
406
|
+
const categories = { ootb_modifications: [], customer_app_artifacts: [], unclassified: [] };
|
|
407
|
+
for (const row of data.sys_metadata) {
|
|
408
|
+
const scopeId = text(row.sys_scope);
|
|
409
|
+
const scope = scopes.get(scopeId);
|
|
410
|
+
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) };
|
|
412
|
+
const evidence = [{ source: 'sys_metadata', sys_id: identity.sys_id, basis: 'Visible application metadata record', updated_on: text(row.sys_updated_on) }];
|
|
413
|
+
const item = {
|
|
414
|
+
identity, scope: { sys_id: scopeId || null, name: text(scope?.name) || null, scope: scopeName || null }, evidence,
|
|
415
|
+
risk: { rating: 'unknown', rationale: 'No trusted current OOTB baseline, runtime usage or dependency analysis was captured.' },
|
|
416
|
+
recommendation: 'Review ownership, current configuration and tests before changing this artifact.',
|
|
417
|
+
unknowns: ['Current content and behavioral differences were not captured.', 'Metadata timestamps can change without a behavioral change.'],
|
|
418
|
+
};
|
|
419
|
+
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)));
|
|
420
|
+
// Raw provider choices: 4 = Skipped, 104 = Skipped (second pass).
|
|
421
|
+
// The read is already filtered server-side; this check stays because a
|
|
422
|
+
// server filter is never trusted to be a classification.
|
|
423
|
+
const skipped = history.filter(log => Object.hasOwn(log, 'resolution_status') && SKIP_DISPOSITIONS.includes(text(log.disposition)) && text(log.resolution_status) !== 'reviewed_reverted');
|
|
424
|
+
// A declaration is caller evidence, not platform/vendor provenance. Keep it
|
|
425
|
+
// separate from OOTB findings and record the uncertainty explicitly.
|
|
426
|
+
if (scope && scopeId !== 'global' && scopeName !== 'global' && (declared.has(scopeName) || declared.has(scopeId))) {
|
|
427
|
+
evidence.push({ source: 'capture_options', sys_id: scopeId, basis: 'Customer ownership declared by caller; scope identity verified in sys_scope.' });
|
|
428
|
+
item.unknowns.push('Publisher/customer ownership is not independently verified.');
|
|
429
|
+
item.recommendation = 'Maintain as a declared customer application artifact; review with its owner, not as an OOTB deviation.';
|
|
430
|
+
categories.customer_app_artifacts.push(item);
|
|
431
|
+
} else if (identity.update_name && skipped.length) {
|
|
432
|
+
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 });
|
|
433
|
+
item.unknowns.push('Historical upgrade skips do not prove the customization still differs from OOTB. Later reversion or merge may have occurred.');
|
|
434
|
+
item.reversion = {
|
|
435
|
+
known_impact: ['The recorded upgrade did not overwrite the customization.'],
|
|
436
|
+
known_dependencies: [],
|
|
437
|
+
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.'],
|
|
438
|
+
};
|
|
439
|
+
item.recommendation = 'Retain pending review. Compare the current artifact with a trusted baseline, inspect callers and regression-test in non-production before any reversion.';
|
|
440
|
+
categories.ootb_modifications.push(item);
|
|
441
|
+
} else {
|
|
442
|
+
item.unknowns.push('Origin is unclassified. A non-global scope or an update timestamp is not proof of customer ownership or an OOTB modification.');
|
|
443
|
+
categories.unclassified.push(item);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
const visibleNames = new Set(data.sys_metadata.map(row => text(row.sys_update_name)).filter(Boolean));
|
|
447
|
+
const unresolvedNames = [...new Set(observedNames)].filter(name => !visibleNames.has(name)).sort();
|
|
448
|
+
return {
|
|
449
|
+
status: 'incomplete', categories,
|
|
450
|
+
coverage: { complete: false, sources,
|
|
451
|
+
upgrade_evidence: {
|
|
452
|
+
skipped_rows_observed: data.sys_upgrade_history_log.length,
|
|
453
|
+
update_names_observed: new Set(observedNames).size,
|
|
454
|
+
resolved_by_lookup: observedNames.length ? resolution.coverage.resolved_names : 0,
|
|
455
|
+
unresolved_update_names: unresolvedNames,
|
|
456
|
+
lookup_failures: observedNames.length ? resolution.coverage.failed_chunks : 0,
|
|
457
|
+
},
|
|
458
|
+
unmatched_upgrade_records: data.sys_upgrade_history_log.filter(row => !visibleNames.has(text(row.file_name))).length,
|
|
459
|
+
unmatched_customer_scopes: customerScopes.filter(id => !data.sys_scope.some(row => text(row.sys_id) === id || text(row.scope) === id)),
|
|
460
|
+
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.'],
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Reusable, provider-independent impact analysis. No CLI, prompts or writes.
|
|
2
|
+
const raw = value => String(value?.value ?? value ?? '');
|
|
3
|
+
const BOOKKEEPING_FIELDS = new Set(['sys_id', 'sys_created_on', 'sys_created_by', 'sys_updated_on',
|
|
4
|
+
'sys_updated_by', 'sys_mod_count', 'sys_recorded_at', 'sys_update_name']);
|
|
5
|
+
export const isImpactField = field => !BOOKKEEPING_FIELDS.has(field);
|
|
6
|
+
const decode = value => value.replace(/</g, '<').replace(/>/g, '>')
|
|
7
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
|
8
|
+
|
|
9
|
+
// Deliberately accepts only the observed flat record_update payload shape.
|
|
10
|
+
// Unknown XML constructs are evidence gaps, never silently discarded changes.
|
|
11
|
+
export function baselineFields(payload, artifact) {
|
|
12
|
+
if (!payload || /<!DOCTYPE|<!ENTITY/i.test(payload)) return null;
|
|
13
|
+
const table = artifact.table;
|
|
14
|
+
if (!/^[a-z][a-z0-9_]*$/.test(table || '')) return null;
|
|
15
|
+
const outer = payload.replace(/^\s*<\?xml[^?]*\?>\s*/, '').trim();
|
|
16
|
+
const match = outer.match(/^<record_update\s+table="([a-z0-9_]+)">\s*<([a-z0-9_]+)\s+action="INSERT_OR_UPDATE">([\s\S]*)<\/\2>\s*<\/record_update>$/);
|
|
17
|
+
if (!match || match[1] !== table || match[2] !== table) return null;
|
|
18
|
+
const fields = {};
|
|
19
|
+
let rest = match[3];
|
|
20
|
+
while (rest.trim()) {
|
|
21
|
+
const field = rest.match(/^\s*<([a-z0-9_]+)(?:\s+[^<>]*?)?(?:\s*\/>|>([\s\S]*?)<\/\1>)/);
|
|
22
|
+
if (!field || Object.hasOwn(fields, field[1])) return null;
|
|
23
|
+
let value = field[2] ?? '';
|
|
24
|
+
if (value.startsWith('<![CDATA[') && value.endsWith(']]>')) value = value.slice(9, -3);
|
|
25
|
+
else {
|
|
26
|
+
if (/[<>]/.test(value) || /&(?!lt;|gt;|quot;|apos;|amp;)/.test(value)) return null;
|
|
27
|
+
value = decode(value);
|
|
28
|
+
}
|
|
29
|
+
fields[field[1]] = value;
|
|
30
|
+
rest = rest.slice(field[0].length);
|
|
31
|
+
}
|
|
32
|
+
if (fields.sys_id !== artifact.sys_id) return null;
|
|
33
|
+
return fields;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function analyzeImpact({ artifact, current, baseline, dependencies = [], unknown = [] }) {
|
|
37
|
+
const fields = baselineFields(baseline?.payload, artifact);
|
|
38
|
+
const gaps = [...unknown, 'Dynamic script references, transitive dependencies and runtime outcomes are unknown; no execution testing was performed.'];
|
|
39
|
+
const changes = [];
|
|
40
|
+
if (!fields) gaps.push('The exact base payload is unavailable or its XML shape/identity is unsupported.');
|
|
41
|
+
if (!current) gaps.push('The current artifact is unavailable.');
|
|
42
|
+
if (fields && current) {
|
|
43
|
+
for (const field of Object.keys(fields).sort()) {
|
|
44
|
+
if (!isImpactField(field)) continue;
|
|
45
|
+
if (!Object.hasOwn(current, field)) {
|
|
46
|
+
gaps.push(`Current field ${field} was not returned; its revert impact is unknown.`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (raw(current[field]) !== fields[field]) {
|
|
50
|
+
changes.push({ field, current: raw(current[field]), base: fields[field],
|
|
51
|
+
effect: field === 'script' ? 'Replace the executable script with the base script; runtime behavior is unverified.'
|
|
52
|
+
: field === 'active' ? `Set activation to ${fields[field]}; execution eligibility changes.`
|
|
53
|
+
: `Replace ${field} with the base value; behavior beyond this field change is unknown.` });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
gaps.push('Fields absent from the base payload and provider-generated normalization are unknown.');
|
|
57
|
+
}
|
|
58
|
+
return { artifact, evidence: { baseline_version: baseline?.sys_id || null,
|
|
59
|
+
baseline_source: baseline?.source || null, baseline_source_table: baseline?.source_table || null },
|
|
60
|
+
direct_behavior: { status: fields && current ? 'field_comparison' : 'unknown',
|
|
61
|
+
summary: fields && current ? `${changes.length} observed field change(s) on return to base; this is not a runtime simulation.` : 'Unknown without both current artifact and a supported exact base payload.' },
|
|
62
|
+
changes, known_dependencies: dependencies, unknown: gaps,
|
|
63
|
+
recommendation: { action: 'retain', basis: changes.length
|
|
64
|
+
? 'Preserve the customization pending validation of the observed changes and unknown dependencies.'
|
|
65
|
+
: 'Preserve the customization until the unknown runtime and dependency effects are validated.',
|
|
66
|
+
automatic: false } };
|
|
67
|
+
}
|
package/src/sdk.js
CHANGED
|
@@ -4,6 +4,7 @@ import { gunzipSync } from 'node:zlib';
|
|
|
4
4
|
import { errAuth, errAPI, errNetwork } from './errors.js';
|
|
5
5
|
import { getStringField } from './helpers.js';
|
|
6
6
|
import { getAction, getStepTypes, createAction, updateAction, testAction } from './processflow-actions.js';
|
|
7
|
+
import { readSkippedBaseline, revertSkippedUpdate } from './skipped-update-provider.js';
|
|
7
8
|
|
|
8
9
|
const DEFAULT_TIMEOUT = 30000;
|
|
9
10
|
|
|
@@ -94,7 +95,8 @@ export class SDKClient {
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
async request(endpoint, opts = {}) {
|
|
97
|
-
const { timeout = this.timeout,
|
|
98
|
+
const { timeout = this.timeout, readOnly = false, touchLastSeen, ...requestOptions } = opts;
|
|
99
|
+
if (readOnly && (requestOptions.method || 'GET').toUpperCase() !== 'GET') throw new Error('Read-only SDK requests require GET');
|
|
98
100
|
const controller = new AbortController();
|
|
99
101
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
100
102
|
|
|
@@ -110,6 +112,8 @@ export class SDKClient {
|
|
|
110
112
|
if (this.domain) {
|
|
111
113
|
req.headers.set('X-Now-Domain', this.domain);
|
|
112
114
|
}
|
|
115
|
+
// readOnly guards the data request, not standard authentication bootstrap.
|
|
116
|
+
// OAuth refresh and session/GCK negotiation are not instance-record writes.
|
|
113
117
|
await this._setAuth(req);
|
|
114
118
|
|
|
115
119
|
const resp = await fetch(req);
|
|
@@ -125,7 +129,10 @@ export class SDKClient {
|
|
|
125
129
|
|
|
126
130
|
// Stamp last_seen on successful API call
|
|
127
131
|
try {
|
|
128
|
-
|
|
132
|
+
// readOnly suppresses the credential last-seen write unconditionally: a
|
|
133
|
+
// caller passing touchLastSeen cannot override it (GLOSSARY contract).
|
|
134
|
+
const shouldTouch = readOnly ? false : (touchLastSeen ?? true);
|
|
135
|
+
if (shouldTouch && this.authProvider && typeof this.authProvider.touchLastSeen === 'function') {
|
|
129
136
|
this.authProvider.touchLastSeen(this.baseURL);
|
|
130
137
|
}
|
|
131
138
|
} catch { /* non-fatal */ }
|
|
@@ -197,6 +204,14 @@ export class SDKClient {
|
|
|
197
204
|
}
|
|
198
205
|
}
|
|
199
206
|
|
|
207
|
+
async getSkippedUpdateBaseline(sysID) {
|
|
208
|
+
return readSkippedBaseline(this, sysID);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async revertSkippedUpdate(sysID, modCount) {
|
|
212
|
+
return revertSkippedUpdate(this, sysID, modCount);
|
|
213
|
+
}
|
|
214
|
+
|
|
200
215
|
async list(table, params = {}, opts = {}) {
|
|
201
216
|
const query = new URLSearchParams(params).toString();
|
|
202
217
|
const endpoint = `${this.baseURL}/api/now/table/${table}${query ? '?' + query : ''}`;
|
|
@@ -204,6 +219,22 @@ export class SDKClient {
|
|
|
204
219
|
return result?.result || [];
|
|
205
220
|
}
|
|
206
221
|
|
|
222
|
+
// Strict Table API seam. `list` stays best-effort for legacy callers, but a
|
|
223
|
+
// 2xx response without a result array is a malformed envelope, not an empty
|
|
224
|
+
// table. Capture paths use this so an unexpected body becomes an unavailable
|
|
225
|
+
// source instead of a complete-looking empty inventory.
|
|
226
|
+
async listStrict(table, params = {}, opts = {}) {
|
|
227
|
+
const query = new URLSearchParams(params).toString();
|
|
228
|
+
const endpoint = `${this.baseURL}/api/now/table/${table}${query ? '?' + query : ''}`;
|
|
229
|
+
const response = await this.request(endpoint, { method: 'GET', ...opts });
|
|
230
|
+
if (!response || typeof response !== 'object' || !Array.isArray(response.result)) {
|
|
231
|
+
const error = new Error(`Malformed table response for ${table}: expected a JSON envelope with a result array`);
|
|
232
|
+
error.code = 'malformed_response';
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
return response.result;
|
|
236
|
+
}
|
|
237
|
+
|
|
207
238
|
async get(table, sysID) {
|
|
208
239
|
const endpoint = `${this.baseURL}/api/now/table/${table}/${sysID}`;
|
|
209
240
|
const result = await this.request(endpoint, { method: 'GET' });
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { errUsage } from './errors.js';
|
|
2
|
+
|
|
3
|
+
function validateId(id) {
|
|
4
|
+
if (!/^[0-9a-fA-F]{32}$/.test(id || '')) throw errUsage('A literal 32-hex skipped finding sys_id is required.');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Contract observed in RevertUpdateVersionAjax.revertToBaseSystem, PDI Australia.
|
|
8
|
+
// Keep read and write separate. No Table API state update can implement revert.
|
|
9
|
+
export async function readSkippedBaseline(sdk, id) {
|
|
10
|
+
validateId(id);
|
|
11
|
+
const output = await sdk.executeScript(`(function() {
|
|
12
|
+
var record = new GlideRecordSecure('sys_upgrade_history_log');
|
|
13
|
+
if (!record.get('${id}') || !record.canRead()) throw new Error('Finding unavailable');
|
|
14
|
+
var name = record.getValue('file_name');
|
|
15
|
+
var storeAppID = GlideappUpdateVersion.getStoreAppID(record);
|
|
16
|
+
var baseline = storeAppID != null
|
|
17
|
+
? GlideappUpdateVersion.getVersion(name, storeAppID, 'sys_store_app', null)
|
|
18
|
+
: GlideappUpdateVersion.getVersion(name, record.upgrade_history, 'sys_upgrade_history', null);
|
|
19
|
+
if (!baseline || !baseline.isValidRecord()) throw new Error('No base system version found');
|
|
20
|
+
gs.print('JSN_SKIPPED_BASE:' + JSON.stringify({sys_id: baseline.getUniqueValue(),
|
|
21
|
+
payload: baseline.getValue('payload'), source: baseline.getValue('source'),
|
|
22
|
+
source_table: baseline.getValue('source_table')}));
|
|
23
|
+
})();`, 'global');
|
|
24
|
+
const match = output.match(/JSN_SKIPPED_BASE:(\{[^\r\n]*\})/);
|
|
25
|
+
if (!match) throw errUsage('Base version inspection returned no verifiable result.');
|
|
26
|
+
try { return JSON.parse(match[1]); } catch { throw errUsage('Base version inspection returned invalid JSON.'); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function revertSkippedUpdate(sdk, id, modCount) {
|
|
30
|
+
validateId(id);
|
|
31
|
+
if (!/^\d+$/.test(modCount || '')) throw errUsage('Finding update count is required for safe revert.');
|
|
32
|
+
const output = await sdk.executeScript(`(function() {
|
|
33
|
+
if (!gs.hasRole('admin')) throw new Error('Admin role required');
|
|
34
|
+
var record = new GlideRecordSecure('sys_upgrade_history_log');
|
|
35
|
+
if (!record.get('${id}') || !record.canWrite()) throw new Error('Finding unavailable or not writable');
|
|
36
|
+
if (record.getValue('disposition') !== '4' ||
|
|
37
|
+
['', 'not_reviewed'].indexOf(record.getValue('resolution_status') || '') < 0 ||
|
|
38
|
+
record.getValue('sys_mod_count') !== '${modCount}') throw new Error('Finding changed or is not eligible');
|
|
39
|
+
new RevertUpdateVersionAjax().revertToBaseSystem('${id}');
|
|
40
|
+
gs.print('JSN_SKIPPED_REVERT:dispatched');
|
|
41
|
+
})();`, 'global');
|
|
42
|
+
if (!output.includes('JSN_SKIPPED_REVERT:dispatched')) throw errUsage('Revert dispatch outcome is unknown; inspect the finding before retrying.');
|
|
43
|
+
// The processor returns no success boolean. Caller must verify disposition and
|
|
44
|
+
// resolution by GET; dispatch alone must never be reported as success.
|
|
45
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { errUsage, errNotFound, AppError } from './errors.js';
|
|
3
|
+
import { analyzeImpact, baselineFields, isImpactField } from './impact-analysis.js';
|
|
4
|
+
|
|
5
|
+
export const SKIP_TABLE = 'sys_upgrade_history_log';
|
|
6
|
+
const SKIPPED = ['4', '104', '9', '10', '11'];
|
|
7
|
+
export const raw = value => String(value?.value ?? value ?? '');
|
|
8
|
+
export const unresolved = record => Boolean(record && Object.hasOwn(record, 'disposition')
|
|
9
|
+
&& Object.hasOwn(record, 'resolution_status') && SKIPPED.includes(raw(record.disposition))
|
|
10
|
+
&& ['', 'not_reviewed'].includes(raw(record.resolution_status)));
|
|
11
|
+
const FIELDS = 'sys_id,file_name,target_name,disposition,resolution_status,upgrade_history,reason,changed,sys_mod_count,sys_updated_on';
|
|
12
|
+
const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
13
|
+
const validId = id => /^[0-9a-fA-F]{32}$/.test(id || '');
|
|
14
|
+
|
|
15
|
+
function validateSelection(ids, action) {
|
|
16
|
+
if (!Array.isArray(ids) || !ids.length || ids.length > 100 || ids.some(id => !validId(id))) {
|
|
17
|
+
throw errUsage('Select between 1 and 100 literal finding sys_ids.');
|
|
18
|
+
}
|
|
19
|
+
if (new Set(ids.map(id => id.toLowerCase())).size !== ids.length) throw errUsage('Duplicate finding sys_ids are not allowed.');
|
|
20
|
+
if (!['retain', 'revert'].includes(action)) throw errUsage('Select an explicit action: retain or revert. Recommendations are never actions.');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function reviewSkippedUpdate(sdk, id) {
|
|
24
|
+
if (!validId(id)) throw errUsage('A literal 32-hex finding sys_id is required.');
|
|
25
|
+
const finding = await sdk.get(SKIP_TABLE, id);
|
|
26
|
+
if (!finding) throw errNotFound('Skipped finding', id);
|
|
27
|
+
if (raw(finding.sys_id).toLowerCase() !== id.toLowerCase()) throw errUsage('Provider finding identity does not match selection.');
|
|
28
|
+
const fileName = raw(finding.file_name);
|
|
29
|
+
const match = fileName.match(/^([a-z][a-z0-9_]*)_([0-9a-fA-F]{32})$/);
|
|
30
|
+
const artifact = { update_name: fileName, name: raw(finding.target_name),
|
|
31
|
+
table: match?.[1] || null, sys_id: match?.[2] || null };
|
|
32
|
+
let current = null;
|
|
33
|
+
let baseline = null;
|
|
34
|
+
const unknown = [];
|
|
35
|
+
if (match) {
|
|
36
|
+
try { current = await sdk.get(artifact.table, artifact.sys_id); }
|
|
37
|
+
catch { unknown.push('Current artifact could not be read; access or availability is unknown.'); }
|
|
38
|
+
} else unknown.push('Artifact identity cannot be decoded from the provider update name.');
|
|
39
|
+
try { baseline = await sdk.getSkippedUpdateBaseline(id); }
|
|
40
|
+
catch { unknown.push('Base inspection is unavailable. Background-script access and version API availability are required.'); }
|
|
41
|
+
const dependencies = [];
|
|
42
|
+
if (match) {
|
|
43
|
+
try {
|
|
44
|
+
const refs = await sdk.list('sys_dictionary', new URLSearchParams({
|
|
45
|
+
sysparm_query: `reference=${artifact.table}^elementISNOTEMPTY^ORDERBYname^ORDERBYelement`,
|
|
46
|
+
sysparm_fields: 'name,element', sysparm_limit: '21', sysparm_display_value: 'false',
|
|
47
|
+
}));
|
|
48
|
+
if (refs.length > 20) unknown.push('Incoming reference discovery is truncated to 20 dictionary fields.');
|
|
49
|
+
for (const ref of refs.slice(0, 20)) {
|
|
50
|
+
const table = raw(ref.name), field = raw(ref.element);
|
|
51
|
+
if (!/^[a-z][a-z0-9_]*$/.test(table) || !/^[a-z][a-z0-9_]*$/.test(field)) continue;
|
|
52
|
+
try {
|
|
53
|
+
const rows = await sdk.list(table, new URLSearchParams({ sysparm_query: `${field}=${artifact.sys_id}^ORDERBYsys_id`,
|
|
54
|
+
sysparm_fields: 'sys_id', sysparm_limit: '11', sysparm_display_value: 'false' }));
|
|
55
|
+
for (const row of rows.slice(0, 10)) dependencies.push({ table, field, sys_id: raw(row.sys_id), relation: 'incoming_reference' });
|
|
56
|
+
if (rows.length > 10) unknown.push(`Incoming references from ${table}.${field} are truncated to 10 records.`);
|
|
57
|
+
} catch { unknown.push(`Incoming references from ${table}.${field} could not be read.`); }
|
|
58
|
+
}
|
|
59
|
+
} catch { unknown.push('Dictionary-backed incoming dependency discovery is unavailable.'); }
|
|
60
|
+
}
|
|
61
|
+
const impact = analyzeImpact({ artifact, current, baseline, dependencies, unknown });
|
|
62
|
+
const fields = baselineFields(baseline?.payload, artifact);
|
|
63
|
+
const fingerprint = digest({ finding, current, baseline });
|
|
64
|
+
return { sys_id: raw(finding.sys_id), artifact, finding: Object.fromEntries(FIELDS.split(',').map(key => [key, raw(finding[key])])),
|
|
65
|
+
impact, fingerprint, eligible: unresolved(finding),
|
|
66
|
+
// The provider's revert writes a NEW base version record whose payload is a
|
|
67
|
+
// copy of the version that was read here, so revert readback compares base
|
|
68
|
+
// CONTENT (this digest) instead of the previewed version sys_id.
|
|
69
|
+
baseline_payload_digest: typeof baseline?.payload === 'string' && baseline.payload
|
|
70
|
+
? digest(baseline.payload) : null,
|
|
71
|
+
revert_supported: raw(finding.disposition) === '4' && validId(baseline?.sys_id)
|
|
72
|
+
&& Boolean(current && raw(current.sys_id) === artifact.sys_id && fields
|
|
73
|
+
&& Object.keys(fields).every(field => !isImpactField(field) || Object.hasOwn(current, field))) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function prepareSkippedBatch(sdk, { ids, action, instance = '' }) {
|
|
77
|
+
validateSelection(ids, action);
|
|
78
|
+
const selected = [];
|
|
79
|
+
for (const id of ids) {
|
|
80
|
+
const review = await reviewSkippedUpdate(sdk, id);
|
|
81
|
+
if (!review.eligible) throw errUsage(`Finding ${id} is no longer unresolved and skipped.`);
|
|
82
|
+
if (action === 'revert' && !review.revert_supported) throw errUsage(`Revert is unsupported for ${id}: requires disposition 4, readable artifact and exact base version.`);
|
|
83
|
+
selected.push({ ...review, action, operation: action === 'retain'
|
|
84
|
+
? 'Set resolution_status=reviewed_retained only; leave artifact unchanged.'
|
|
85
|
+
: 'Call RevertUpdateVersionAjax.revertToBaseSystem; replace customization with provider-selected base version.' });
|
|
86
|
+
}
|
|
87
|
+
if (action === 'revert') {
|
|
88
|
+
const includes = await sdk.list('sys_script_include', new URLSearchParams({
|
|
89
|
+
sysparm_query: 'name=RevertUpdateVersionAjax', sysparm_fields: 'script', sysparm_limit: '2', sysparm_display_value: 'false' }));
|
|
90
|
+
if (includes.length !== 1 || !/revertToBaseSystem\s*:\s*function\s*\(\s*sys_id\s*\)/.test(raw(includes[0].script))
|
|
91
|
+
|| !raw(includes[0].script).includes('GlideappUpdateVersion.getVersion')) {
|
|
92
|
+
throw errUsage('Provider revert lifecycle contract is unavailable; no writes performed.');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const preview = { instance, selected, count: selected.length };
|
|
96
|
+
return { ...preview, confirmation_token: digest(preview), writes_performed: false };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
export async function processSkippedBatch({ sdk, ids, action, instance = '', readOnly = false,
|
|
101
|
+
confirmationToken, confirm, onPreview }) {
|
|
102
|
+
if (readOnly) throw new AppError('read_only', 'Read-only profiles cannot process findings.');
|
|
103
|
+
const preview = await prepareSkippedBatch(sdk, { ids, action, instance });
|
|
104
|
+
// Preview precedes the sole confirmation. Never honor force or a profile's
|
|
105
|
+
// skip_confirmations here; this batch always requires explicit consent.
|
|
106
|
+
if (onPreview) await onPreview(preview);
|
|
107
|
+
if (confirmationToken) {
|
|
108
|
+
if (confirmationToken !== preview.confirmation_token) throw errUsage('Preview changed or confirmation token does not match. Review a new preview before confirming.');
|
|
109
|
+
} else if (confirm) {
|
|
110
|
+
if (!await confirm(preview)) return { preview, cancelled: true, results: [], success_count: 0, failure_count: 0, partial: false, writes_performed: false };
|
|
111
|
+
} else {
|
|
112
|
+
throw new AppError('confirmation_required', 'Review the exact batch preview before processing.',
|
|
113
|
+
`Run jsn skipped-updates preview --ids ${ids.join(',')} --action ${action}; review its exact selection, then process that selection with --confirm <preview-token>.`);
|
|
114
|
+
}
|
|
115
|
+
const results = [];
|
|
116
|
+
for (const selected of preview.selected) {
|
|
117
|
+
let attempted = false;
|
|
118
|
+
try {
|
|
119
|
+
const latest = await reviewSkippedUpdate(sdk, selected.sys_id);
|
|
120
|
+
if (!latest.eligible || latest.fingerprint !== selected.fingerprint) throw errUsage('Finding, artifact or base version changed after preview; no write attempted.');
|
|
121
|
+
attempted = true;
|
|
122
|
+
if (action === 'retain') await sdk.update(SKIP_TABLE, selected.sys_id, { resolution_status: 'reviewed_retained' });
|
|
123
|
+
else await sdk.revertSkippedUpdate(selected.sys_id, selected.finding.sys_mod_count);
|
|
124
|
+
const persisted = await sdk.get(SKIP_TABLE, selected.sys_id);
|
|
125
|
+
const expected = action === 'retain' ? 'reviewed_retained' : 'reviewed_reverted';
|
|
126
|
+
if (!persisted || raw(persisted.sys_id) !== selected.sys_id || raw(persisted.resolution_status) !== expected
|
|
127
|
+
|| raw(persisted.disposition) !== (action === 'revert' ? '5' : selected.finding.disposition)) {
|
|
128
|
+
throw errUsage('Provider state readback did not verify the selected action.');
|
|
129
|
+
}
|
|
130
|
+
if (action === 'revert') {
|
|
131
|
+
const artifact = await sdk.get(selected.artifact.table, selected.artifact.sys_id);
|
|
132
|
+
const baseline = await sdk.getSkippedUpdateBaseline(selected.sys_id);
|
|
133
|
+
const fields = baselineFields(baseline?.payload, selected.artifact);
|
|
134
|
+
// RevertToBaseSystem reverts to the previewed base version and leaves a new
|
|
135
|
+
// base version record behind, so the sys_id legitimately changes. What must
|
|
136
|
+
// still match is the base CONTENT we previewed and the artifact now at base.
|
|
137
|
+
if (!artifact || raw(artifact.sys_id) !== selected.artifact.sys_id || !fields
|
|
138
|
+
|| !validId(baseline?.sys_id) || typeof baseline?.payload !== 'string'
|
|
139
|
+
|| digest(baseline.payload) !== selected.baseline_payload_digest
|
|
140
|
+
|| Object.entries(fields).some(([field, value]) => isImpactField(field)
|
|
141
|
+
&& (!Object.hasOwn(artifact, field) || raw(artifact[field]) !== value))) {
|
|
142
|
+
throw errUsage('Artifact readback did not verify the previewed base content on the reverted artifact.');
|
|
143
|
+
}
|
|
144
|
+
results.push({ sys_id: selected.sys_id, action, success: true, write_attempted: true,
|
|
145
|
+
resolution_status: raw(persisted.resolution_status), disposition: raw(persisted.disposition),
|
|
146
|
+
base_version: raw(baseline.sys_id), verified: true });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
results.push({ sys_id: selected.sys_id, action, success: true, write_attempted: true,
|
|
150
|
+
resolution_status: raw(persisted.resolution_status), disposition: raw(persisted.disposition), verified: true });
|
|
151
|
+
} catch (err) {
|
|
152
|
+
results.push({ sys_id: selected.sys_id, action, success: false, write_attempted: attempted,
|
|
153
|
+
effect: attempted ? 'unknown' : 'unchanged', code: err.code || 'provider_error',
|
|
154
|
+
message: attempted ? 'Selected action or its readback failed; inspect this finding before retrying.' : err.message });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const successCount = results.filter(result => result.success).length;
|
|
158
|
+
return { preview, results, cancelled: false, success_count: successCount,
|
|
159
|
+
failure_count: results.length - successCount, partial: successCount > 0 && successCount < results.length,
|
|
160
|
+
writes_performed: results.some(result => result.write_attempted) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function discoverSkippedUpdates(sdk, { limit = 100, offset = 0 } = {}) {
|
|
164
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 500 || !Number.isInteger(offset) || offset < 0) {
|
|
165
|
+
throw errUsage('Use a limit between 1 and 500 and a nonnegative integer offset.');
|
|
166
|
+
}
|
|
167
|
+
const params = new URLSearchParams({
|
|
168
|
+
sysparm_query: 'dispositionIN4,104,9,10,11^resolution_statusIN,not_reviewed^ORDERBYsys_id',
|
|
169
|
+
sysparm_fields: FIELDS, sysparm_display_value: 'all',
|
|
170
|
+
sysparm_limit: String(limit + 1), sysparm_offset: String(offset),
|
|
171
|
+
});
|
|
172
|
+
const rows = await sdk.list(SKIP_TABLE, params);
|
|
173
|
+
// Recheck locally: an instance can ignore a query field the caller cannot read.
|
|
174
|
+
if (rows.some(row => !unresolved(row))) throw errUsage('Provider returned a finding outside the unresolved skipped state.');
|
|
175
|
+
return { findings: rows.slice(0, limit), count: Math.min(rows.length, limit),
|
|
176
|
+
limit, offset, has_more: rows.length > limit, total: null };
|
|
177
|
+
}
|