@jacebenson/jsn 4.3.8 → 4.3.9-dev.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -417
- package/package.json +4 -2
- package/skills/servicenow/SKILL.md +54 -0
- package/src/cli.js +4 -0
- package/src/commands/actions.js +89 -2
- package/src/commands/catalog.js +2 -2
- package/src/commands/customizations.js +82 -0
- package/src/commands/flows.js +22 -3
- package/src/commands/skipped-updates.js +102 -0
- package/src/commands/updatesets.js +91 -31
- package/src/context.js +16 -25
- package/src/customizations-report.js +158 -0
- package/src/customizations.js +517 -0
- package/src/flow-publish.js +15 -3
- 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/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
|
+
}
|