@slicedearth/whoisleuth-cli 1.45.0 → 1.47.4
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/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +59 -6
- package/bin/whoisleuth.mjs +5 -3
- package/cli/arguments.mjs +256 -9
- package/cli/artifact-structure.mjs +1833 -0
- package/cli/artifact-verify.mjs +242 -49
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +2 -3
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +16 -8
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-scan-command-runner.mjs +4 -5
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +21 -15
- package/cli/domain-control-observations.mjs +25 -2
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +73 -45
- package/cli/export-evidence.mjs +11 -4
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/terminal.mjs +3 -0
- package/cli/input.mjs +57 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-run.mjs +71 -23
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookup-command-runner.mjs +2 -2
- package/cli/lookup-diff.mjs +9 -0
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +28 -8
- package/cli/page-compare.mjs +8 -5
- package/cli/registry-cohort.mjs +385 -61
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +376 -0
- package/cli/risk-calibration.mjs +7 -5
- package/cli/runner.mjs +147 -185
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +444 -70
- package/docs/cli.md +24 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
- package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
- package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
- package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
- package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
- package/frontend/src/lib/analysis/bulk-sort.js +4 -0
- package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
- package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
- package/frontend/src/lib/analysis/case-migration-model.js +83 -31
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +42 -12
- package/frontend/src/lib/analysis/case-report.js +39 -6
- package/frontend/src/lib/analysis/case-response-model.js +66 -7
- package/frontend/src/lib/analysis/case-response-packet.js +15 -5
- package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
- package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
- package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
- package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
- package/frontend/src/lib/analysis/domain-control-records.js +88 -0
- package/frontend/src/lib/analysis/external-findings-import.js +70 -6
- package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
- package/frontend/src/lib/analysis/investigation-guide.js +52 -10
- package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
- package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
- package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
- package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +26 -3
- package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
- package/frontend/src/lib/analysis/visualization-models.js +63 -94
- package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
- package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
- package/frontend/src/lib/analysis/workspace-archive.js +104 -14
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +13 -0
- package/lib/bounded-json-response.mjs +17 -4
- package/lib/bounded-json.mjs +249 -0
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/cryptographic-assurance.mjs +155 -0
- package/lib/ct-search.mjs +87 -47
- package/lib/dns-intelligence.mjs +3 -2
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1053 -0
- package/lib/dnssec-evidence-validation.mjs +10 -3
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-manifest.mjs +17 -129
- package/lib/domain-posture.mjs +7 -4
- package/lib/evidence-export.mjs +751 -78
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/favicon.mjs +8 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +2 -5
- package/lib/interchange-fidelity-registry.mjs +122 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup-response-contract.mjs +691 -3
- package/lib/lookup.mjs +2 -2
- package/lib/observation.mjs +7 -6
- package/lib/outbound-identity.mjs +2 -3
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +21 -0
- package/lib/provider-policy-admission.mjs +9 -4
- package/lib/rdap-normalization.mjs +18 -5
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/risk-calibration-summary.mjs +338 -0
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +59 -10
- package/lib/security-txt.mjs +26 -3
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +784 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/threat-intelligence-runtime.mjs +3 -11
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/package.json +1 -1
- package/third-party-notices.txt +95 -5817
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Pure abuse-evidence packet builder. It creates local review artifacts only:
|
|
2
2
|
// no network requests, mailto links, submissions, or provider side effects.
|
|
3
3
|
import { buildCaseActionOutcomeSummary } from "./case-response-model.js";
|
|
4
|
+
import { canonicalArtifactJsonV2, SORTED_JSON_V2 } from "./artifact-integrity.js";
|
|
4
5
|
export const CASE_RESPONSE_PACKET_SCHEMA = 'whoisleuth.case-response-packet';
|
|
5
|
-
export const CASE_RESPONSE_PACKET_VERSION =
|
|
6
|
+
export const CASE_RESPONSE_PACKET_VERSION = 6;
|
|
7
|
+
export const LEGACY_CASE_RESPONSE_PACKET_VERSION = 5;
|
|
6
8
|
export const MAX_ABUSIVE_URLS = 20;
|
|
7
9
|
export const MAX_RESPONSE_CONTACTS = 12;
|
|
8
10
|
export const MAX_RESPONSE_ACTION_HISTORY = 20;
|
|
@@ -387,13 +389,21 @@ async function sha256(value) {
|
|
|
387
389
|
}
|
|
388
390
|
export async function verifyCaseResponsePacketIntegrity(packet) {
|
|
389
391
|
const { integrity, ...unsigned } = packet;
|
|
392
|
+
const canonicalization = packet.schemaVersion === LEGACY_CASE_RESPONSE_PACKET_VERSION
|
|
393
|
+
&& integrity.canonicalization === 'sorted-json-v1'
|
|
394
|
+
? canonicalJson
|
|
395
|
+
: packet.schemaVersion === CASE_RESPONSE_PACKET_VERSION
|
|
396
|
+
&& integrity.canonicalization === SORTED_JSON_V2
|
|
397
|
+
? canonicalArtifactJsonV2
|
|
398
|
+
: null;
|
|
390
399
|
if (integrity.algorithm !== 'SHA-256'
|
|
391
|
-
||
|
|
400
|
+
|| !canonicalization
|
|
392
401
|
|| integrity.scope !== 'packet excluding integrity'
|
|
402
|
+
|| typeof integrity.digestSha256 !== 'string'
|
|
393
403
|
|| !/^[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
|
|
394
404
|
return false;
|
|
395
405
|
}
|
|
396
|
-
return integrity.digestSha256 === await sha256(
|
|
406
|
+
return integrity.digestSha256 === await sha256(canonicalization(unsigned));
|
|
397
407
|
}
|
|
398
408
|
function escapeMarkdown(value) {
|
|
399
409
|
return value.replace(/([\\`*_[\]<>|])/gu, '\\$1').replace(/\r?\n/gu, ' ');
|
|
@@ -472,12 +482,12 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
|
|
|
472
482
|
limitations,
|
|
473
483
|
},
|
|
474
484
|
};
|
|
475
|
-
const digestSha256 = await sha256(
|
|
485
|
+
const digestSha256 = await sha256(canonicalArtifactJsonV2(unsigned));
|
|
476
486
|
const json = {
|
|
477
487
|
...unsigned,
|
|
478
488
|
integrity: {
|
|
479
489
|
algorithm: 'SHA-256',
|
|
480
|
-
canonicalization:
|
|
490
|
+
canonicalization: SORTED_JSON_V2,
|
|
481
491
|
scope: 'packet excluding integrity',
|
|
482
492
|
digestSha256,
|
|
483
493
|
},
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { MAX_BULK_SESSIONS, compareBulkSessions, normalizeBulkSession, } from "./bulk-session-model.js";
|
|
2
|
+
import { MAX_COMPARISON_LEDGER_BULK_PAIRS, boundedComparisonLedgerId, comparisonLedgerCollector, comparisonLedgerInputArray, comparisonLedgerRecord, comparisonLedgerValuePresent, comparisonLedgerValueState, makeComparisonLedgerCandidate, stableComparisonLedgerId, } from "./comparison-ledger-contract.js";
|
|
3
|
+
function bulkCompleteness(session) {
|
|
4
|
+
return session.state === 'complete' ? 'complete' : session.state === 'partial' ? 'partial' : 'unavailable';
|
|
5
|
+
}
|
|
6
|
+
function sourceMap(result) {
|
|
7
|
+
return new Map(result.sourceCoverage.map((item) => [item.source, item.state]));
|
|
8
|
+
}
|
|
9
|
+
function namedBulkSourceState(result, source) {
|
|
10
|
+
return result.sourceCoverage.find((item) => item.source === source)?.state ?? 'not_reported';
|
|
11
|
+
}
|
|
12
|
+
function bulkFamilyState(result, family) {
|
|
13
|
+
if (family === 'model')
|
|
14
|
+
return 'derived';
|
|
15
|
+
if (family === 'collection')
|
|
16
|
+
return 'retained';
|
|
17
|
+
return result.status;
|
|
18
|
+
}
|
|
19
|
+
function bulkSide(session, result, family, value, sourceState) {
|
|
20
|
+
return {
|
|
21
|
+
source: `Saved Bulk session · ${session.name}`,
|
|
22
|
+
sourceState: sourceState ?? (result ? bulkFamilyState(result, family) : session.state),
|
|
23
|
+
value,
|
|
24
|
+
observedAt: session.state === 'complete' ? session.completedAt : null,
|
|
25
|
+
retainedAt: session.updatedAt,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const BULK_FIELDS = Object.freeze([
|
|
29
|
+
{ field: 'Availability', family: 'registration', kind: 'source', read: (item) => item.availability, sourceState: (item) => namedBulkSourceState(item, 'availability') },
|
|
30
|
+
{ field: 'Registrar', family: 'registration', kind: 'registrar', read: (item) => item.registrar, sourceState: () => 'not_reported' },
|
|
31
|
+
{ field: 'Website activity', family: 'website', kind: 'source', read: (item) => item.activityStatus, sourceState: (item) => namedBulkSourceState(item, 'http') },
|
|
32
|
+
{ field: 'Risk score', family: 'model', kind: 'model', read: (item) => item.risk, sourceState: () => 'derived' },
|
|
33
|
+
{ field: 'Opportunity score', family: 'model', kind: 'model', read: (item) => item.opportunity, sourceState: () => 'derived' },
|
|
34
|
+
{ field: 'Technology IDs', family: 'technology', kind: 'source', read: (item) => item.comparisonEvidence?.technology.ids ?? null, sourceState: (item) => item.comparisonEvidence?.technology.state ?? 'not_reported' },
|
|
35
|
+
{ field: 'TLS issuer', family: 'certificate', kind: 'source', read: (item) => item.comparisonEvidence?.tls.issuerLabel ?? null, sourceState: (item) => item.comparisonEvidence?.tls.state ?? 'not_reported' },
|
|
36
|
+
{ field: 'TLS public key', family: 'certificate', kind: 'source', read: (item) => item.comparisonEvidence?.tls.spkiSha256 ?? null, sourceState: (item) => item.comparisonEvidence?.tls.state ?? 'not_reported' },
|
|
37
|
+
]);
|
|
38
|
+
function completeBulkSourceState(value) { return value === 'complete' || value === 'success'; }
|
|
39
|
+
function settledBulkCoverageState(value) {
|
|
40
|
+
return value === 'complete' || value === 'skipped' || value === 'unsupported';
|
|
41
|
+
}
|
|
42
|
+
function bulkFieldDecision(field, earlier, later, before, after) {
|
|
43
|
+
const earlierState = field.sourceState(earlier);
|
|
44
|
+
const laterState = field.sourceState(later);
|
|
45
|
+
if (earlier.scanDepth !== later.scanDepth)
|
|
46
|
+
return { state: 'not_compared', completeness: 'partial', earlierState, laterState, limitation: 'Unlike Fast and Deep result rows are not compared as an observed field change.' };
|
|
47
|
+
if (field.kind === 'registrar')
|
|
48
|
+
return { state: 'not_compared', completeness: 'not_reported', earlierState, laterState, limitation: 'The exact retained registrar texts are shown, but saved Bulk rows do not retain field-level registrar provenance; these values are not compared as a publication or target change.' };
|
|
49
|
+
if (field.kind === 'model') {
|
|
50
|
+
const comparable = comparisonLedgerValuePresent(before) && comparisonLedgerValuePresent(after);
|
|
51
|
+
return { state: comparable ? 'different' : 'not_compared', completeness: comparable ? 'complete' : 'partial', earlierState, laterState, limitation: comparable ? null : 'A score absent from either retained row is not represented as an observed score change.' };
|
|
52
|
+
}
|
|
53
|
+
if (earlierState !== laterState)
|
|
54
|
+
return { state: 'collection_changed', completeness: 'partial', earlierState, laterState, limitation: 'The exact retained family source state changed, so the values are not represented as an observed field change.' };
|
|
55
|
+
if (!completeBulkSourceState(earlierState) || !completeBulkSourceState(laterState)) {
|
|
56
|
+
const state = laterState === 'unsupported' ? 'unsupported' : ['error', 'not_found', 'unavailable'].includes(laterState) ? 'unavailable' : 'not_compared';
|
|
57
|
+
return { state, completeness: state === 'not_compared' ? 'partial' : 'unavailable', earlierState, laterState, limitation: 'Both retained family source states must be complete before values are compared.' };
|
|
58
|
+
}
|
|
59
|
+
return { state: comparisonLedgerValueState(before, after, true), completeness: 'complete', earlierState, laterState, limitation: null };
|
|
60
|
+
}
|
|
61
|
+
function bulkPairCompleteness(earlier, later) {
|
|
62
|
+
if (bulkCompleteness(earlier) !== 'complete' || bulkCompleteness(later) !== 'complete' || earlier.mode !== later.mode)
|
|
63
|
+
return 'partial';
|
|
64
|
+
const before = new Map(earlier.results.map((item) => [item.domain, item]));
|
|
65
|
+
const after = new Map(later.results.map((item) => [item.domain, item]));
|
|
66
|
+
if (before.size !== after.size)
|
|
67
|
+
return 'partial';
|
|
68
|
+
for (const [domain, left] of before) {
|
|
69
|
+
const right = after.get(domain);
|
|
70
|
+
if (!right || left.scanDepth !== right.scanDepth)
|
|
71
|
+
return 'partial';
|
|
72
|
+
if (left.riskModelVersion === null || right.riskModelVersion === null || left.riskModelVersion !== right.riskModelVersion
|
|
73
|
+
|| left.opportunityModelVersion === null || right.opportunityModelVersion === null || left.opportunityModelVersion !== right.opportunityModelVersion)
|
|
74
|
+
return 'partial';
|
|
75
|
+
for (const field of BULK_FIELDS) {
|
|
76
|
+
if (field.kind === 'registrar' && exactEqual(field.read(left), field.read(right)))
|
|
77
|
+
continue;
|
|
78
|
+
const decision = bulkFieldDecision(field, left, right, field.read(left), field.read(right));
|
|
79
|
+
if (decision.completeness !== 'complete')
|
|
80
|
+
return 'partial';
|
|
81
|
+
}
|
|
82
|
+
const leftSources = sourceMap(left);
|
|
83
|
+
const rightSources = sourceMap(right);
|
|
84
|
+
if ([...new Set([...leftSources.keys(), ...rightSources.keys()])].some((source) => leftSources.get(source) !== rightSources.get(source)
|
|
85
|
+
|| !settledBulkCoverageState(leftSources.get(source)) || !settledBulkCoverageState(rightSources.get(source))))
|
|
86
|
+
return 'partial';
|
|
87
|
+
}
|
|
88
|
+
return 'complete';
|
|
89
|
+
}
|
|
90
|
+
function exactEqual(left, right) {
|
|
91
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
92
|
+
return Array.isArray(left) && Array.isArray(right)
|
|
93
|
+
&& left.length === right.length
|
|
94
|
+
&& left.every((value, index) => value === right[index]);
|
|
95
|
+
}
|
|
96
|
+
return Object.is(left, right);
|
|
97
|
+
}
|
|
98
|
+
function buildBulkRows(ownerId, earlier, later, pairCompleteness) {
|
|
99
|
+
const output = comparisonLedgerCollector();
|
|
100
|
+
const before = new Map(earlier.results.map((item) => [item.domain, item]));
|
|
101
|
+
const after = new Map(later.results.map((item) => [item.domain, item]));
|
|
102
|
+
for (const domain of [...new Set([...before.keys(), ...after.keys()])].sort()) {
|
|
103
|
+
const left = before.get(domain) ?? null;
|
|
104
|
+
const right = after.get(domain) ?? null;
|
|
105
|
+
if (!left && right) {
|
|
106
|
+
output.add({
|
|
107
|
+
comparisonId: ownerId,
|
|
108
|
+
ownerId,
|
|
109
|
+
entityId: domain,
|
|
110
|
+
mode: 'membership',
|
|
111
|
+
state: 'added',
|
|
112
|
+
field: 'Settled result-set membership',
|
|
113
|
+
family: 'membership',
|
|
114
|
+
earlier: bulkSide(earlier, null, 'membership', null),
|
|
115
|
+
later: bulkSide(later, right, 'membership', 'Included in the later settled result set'),
|
|
116
|
+
completeness: bulkCompleteness(later),
|
|
117
|
+
limitations: ['This is membership in two explicitly selected saved sessions, not evidence that the domain itself was created or became active.'],
|
|
118
|
+
});
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (left && !right) {
|
|
122
|
+
output.add({
|
|
123
|
+
comparisonId: ownerId,
|
|
124
|
+
ownerId,
|
|
125
|
+
entityId: domain,
|
|
126
|
+
mode: 'membership',
|
|
127
|
+
state: 'not_compared',
|
|
128
|
+
field: 'Settled result-set membership',
|
|
129
|
+
family: 'membership',
|
|
130
|
+
earlier: bulkSide(earlier, left, 'membership', 'Included in the earlier settled result set'),
|
|
131
|
+
later: bulkSide(later, null, 'membership', null),
|
|
132
|
+
completeness: 'partial',
|
|
133
|
+
limitations: ['No settled row is retained in the later saved session. This is not domain removal, release, resolution, or an availability finding.'],
|
|
134
|
+
});
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!left || !right)
|
|
138
|
+
continue;
|
|
139
|
+
const riskModelsComparable = left.riskModelVersion !== null
|
|
140
|
+
&& right.riskModelVersion !== null
|
|
141
|
+
&& left.riskModelVersion === right.riskModelVersion;
|
|
142
|
+
const riskModelChanged = left.riskModelVersion !== null
|
|
143
|
+
&& right.riskModelVersion !== null
|
|
144
|
+
&& left.riskModelVersion !== right.riskModelVersion;
|
|
145
|
+
const leftOpportunityVersion = left.opportunityModelVersion ?? null;
|
|
146
|
+
const rightOpportunityVersion = right.opportunityModelVersion ?? null;
|
|
147
|
+
const opportunityModelsComparable = leftOpportunityVersion !== null
|
|
148
|
+
&& rightOpportunityVersion !== null
|
|
149
|
+
&& leftOpportunityVersion === rightOpportunityVersion;
|
|
150
|
+
const opportunityModelChanged = leftOpportunityVersion !== null
|
|
151
|
+
&& rightOpportunityVersion !== null
|
|
152
|
+
&& leftOpportunityVersion !== rightOpportunityVersion;
|
|
153
|
+
for (const field of BULK_FIELDS) {
|
|
154
|
+
const beforeValue = field.read(left);
|
|
155
|
+
const afterValue = field.read(right);
|
|
156
|
+
if ((field.field === 'Risk score' && !riskModelsComparable)
|
|
157
|
+
|| (field.field === 'Opportunity score' && !opportunityModelsComparable))
|
|
158
|
+
continue;
|
|
159
|
+
const decision = bulkFieldDecision(field, left, right, beforeValue, afterValue);
|
|
160
|
+
if (exactEqual(beforeValue, afterValue) && decision.state !== 'collection_changed')
|
|
161
|
+
continue;
|
|
162
|
+
output.add({
|
|
163
|
+
comparisonId: ownerId,
|
|
164
|
+
ownerId,
|
|
165
|
+
entityId: domain,
|
|
166
|
+
mode: 'temporal',
|
|
167
|
+
state: decision.state,
|
|
168
|
+
field: field.field,
|
|
169
|
+
family: field.family,
|
|
170
|
+
earlier: bulkSide(earlier, left, field.family, beforeValue, decision.earlierState),
|
|
171
|
+
later: bulkSide(later, right, field.family, afterValue, decision.laterState),
|
|
172
|
+
completeness: decision.completeness,
|
|
173
|
+
limitations: [
|
|
174
|
+
'Saved Bulk rows are compact derived observations, not raw source publications or current provider state.',
|
|
175
|
+
...(decision.limitation ? [decision.limitation] : []),
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (riskModelChanged) {
|
|
180
|
+
output.add({
|
|
181
|
+
comparisonId: ownerId,
|
|
182
|
+
ownerId,
|
|
183
|
+
entityId: domain,
|
|
184
|
+
mode: 'temporal',
|
|
185
|
+
state: 'model_changed',
|
|
186
|
+
field: 'Risk model version',
|
|
187
|
+
family: 'model',
|
|
188
|
+
earlier: bulkSide(earlier, left, 'model', left.riskModelVersion),
|
|
189
|
+
later: bulkSide(later, right, 'model', right.riskModelVersion),
|
|
190
|
+
completeness: 'partial',
|
|
191
|
+
limitations: ['Risk scores from unlike model versions are not represented as an observed domain change.'],
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
else if (!riskModelsComparable && (left.risk !== null || right.risk !== null)) {
|
|
195
|
+
output.add({
|
|
196
|
+
comparisonId: ownerId,
|
|
197
|
+
ownerId,
|
|
198
|
+
entityId: domain,
|
|
199
|
+
mode: 'temporal',
|
|
200
|
+
state: 'not_compared',
|
|
201
|
+
field: 'Risk model version',
|
|
202
|
+
family: 'model',
|
|
203
|
+
earlier: bulkSide(earlier, left, 'model', left.riskModelVersion),
|
|
204
|
+
later: bulkSide(later, right, 'model', right.riskModelVersion),
|
|
205
|
+
completeness: 'partial',
|
|
206
|
+
limitations: ['At least one saved Risk score lacks a retained model version, so score values are not compared.'],
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
if (opportunityModelChanged) {
|
|
210
|
+
output.add({
|
|
211
|
+
comparisonId: ownerId,
|
|
212
|
+
ownerId,
|
|
213
|
+
entityId: domain,
|
|
214
|
+
mode: 'temporal',
|
|
215
|
+
state: 'model_changed',
|
|
216
|
+
field: 'Opportunity model version',
|
|
217
|
+
family: 'model',
|
|
218
|
+
earlier: bulkSide(earlier, left, 'model', leftOpportunityVersion),
|
|
219
|
+
later: bulkSide(later, right, 'model', rightOpportunityVersion),
|
|
220
|
+
completeness: 'partial',
|
|
221
|
+
limitations: ['Opportunity scores from unlike model versions are not represented as an observed domain change.'],
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
else if (!opportunityModelsComparable && (left.opportunity !== null || right.opportunity !== null)) {
|
|
225
|
+
output.add({
|
|
226
|
+
comparisonId: ownerId,
|
|
227
|
+
ownerId,
|
|
228
|
+
entityId: domain,
|
|
229
|
+
mode: 'temporal',
|
|
230
|
+
state: 'not_compared',
|
|
231
|
+
field: 'Opportunity model version',
|
|
232
|
+
family: 'model',
|
|
233
|
+
earlier: bulkSide(earlier, left, 'model', leftOpportunityVersion),
|
|
234
|
+
later: bulkSide(later, right, 'model', rightOpportunityVersion),
|
|
235
|
+
completeness: 'partial',
|
|
236
|
+
limitations: ['At least one saved Opportunity score lacks a retained model version, so score values are not compared.'],
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
const beforeSources = sourceMap(left);
|
|
240
|
+
const afterSources = sourceMap(right);
|
|
241
|
+
for (const source of [...new Set([...beforeSources.keys(), ...afterSources.keys()])].sort()) {
|
|
242
|
+
const beforeState = beforeSources.get(source) ?? 'not_reported';
|
|
243
|
+
const afterState = afterSources.get(source) ?? 'not_reported';
|
|
244
|
+
if (beforeState === afterState)
|
|
245
|
+
continue;
|
|
246
|
+
output.add({
|
|
247
|
+
comparisonId: ownerId,
|
|
248
|
+
ownerId,
|
|
249
|
+
entityId: domain,
|
|
250
|
+
mode: 'temporal',
|
|
251
|
+
state: 'collection_changed',
|
|
252
|
+
field: `${source} source state`,
|
|
253
|
+
family: 'collection',
|
|
254
|
+
earlier: bulkSide(earlier, left, 'collection', beforeState),
|
|
255
|
+
later: bulkSide(later, right, 'collection', afterState),
|
|
256
|
+
completeness: 'partial',
|
|
257
|
+
limitations: ['A source-state difference can reflect collection conditions rather than a target change.'],
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (earlier.mode !== later.mode) {
|
|
262
|
+
output.add({
|
|
263
|
+
comparisonId: ownerId,
|
|
264
|
+
ownerId,
|
|
265
|
+
entityId: `${earlier.id}:${later.id}`,
|
|
266
|
+
mode: 'temporal',
|
|
267
|
+
state: 'collection_changed',
|
|
268
|
+
field: 'Bulk collection mode',
|
|
269
|
+
family: 'collection',
|
|
270
|
+
earlier: bulkSide(earlier, null, 'collection', earlier.mode),
|
|
271
|
+
later: bulkSide(later, null, 'collection', later.mode),
|
|
272
|
+
completeness: 'partial',
|
|
273
|
+
limitations: ['Fast and Deep saved sessions have different collection contracts; unlike fields remain not compared.'],
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
if (!output.totalRows) {
|
|
277
|
+
output.add({
|
|
278
|
+
comparisonId: ownerId,
|
|
279
|
+
ownerId,
|
|
280
|
+
entityId: `${earlier.id}:${later.id}`,
|
|
281
|
+
mode: 'temporal',
|
|
282
|
+
state: pairCompleteness === 'complete' ? 'equivalent' : 'incomplete',
|
|
283
|
+
field: 'Compact comparable saved rows',
|
|
284
|
+
family: 'summary',
|
|
285
|
+
earlier: bulkSide(earlier, null, 'summary', 'No bounded material difference'),
|
|
286
|
+
later: bulkSide(later, null, 'summary', 'No bounded material difference'),
|
|
287
|
+
completeness: pairCompleteness,
|
|
288
|
+
limitations: [pairCompleteness === 'complete'
|
|
289
|
+
? 'Equivalence applies only to the compact fields retained in both explicitly selected saved sessions.'
|
|
290
|
+
: 'At least one compact field or source state is not completely comparable across both saved sessions, so the absence of a retained row difference is not represented as equivalence or resolution.'],
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return { rows: output.rows, totalRows: output.totalRows, sourceOmittedRows: 0 };
|
|
294
|
+
}
|
|
295
|
+
export function comparisonLedgerBulkPairIndexId(earlierSessionId, laterSessionId) {
|
|
296
|
+
return stableComparisonLedgerId('ledger-bulk_session_pair', [String(earlierSessionId ?? ''), String(laterSessionId ?? '')]);
|
|
297
|
+
}
|
|
298
|
+
export function buildBulkComparisonCandidates(sessionsRaw, pairsRaw, counters) {
|
|
299
|
+
const input = comparisonLedgerInputArray(sessionsRaw);
|
|
300
|
+
counters.inputRecords += Math.max(0, input.length - MAX_BULK_SESSIONS);
|
|
301
|
+
const sessions = new Map();
|
|
302
|
+
for (const value of input.slice(0, MAX_BULK_SESSIONS)) {
|
|
303
|
+
const item = normalizeBulkSession(value);
|
|
304
|
+
if (!item) {
|
|
305
|
+
counters.invalidRecords += 1;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (sessions.has(item.id)) {
|
|
309
|
+
counters.duplicateRecords += 1;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
sessions.set(item.id, item);
|
|
313
|
+
}
|
|
314
|
+
const pairs = comparisonLedgerInputArray(pairsRaw);
|
|
315
|
+
counters.inputRecords += Math.max(0, pairs.length - MAX_COMPARISON_LEDGER_BULK_PAIRS);
|
|
316
|
+
const candidates = [];
|
|
317
|
+
const seen = new Set();
|
|
318
|
+
for (const rawPair of pairs.slice(0, MAX_COMPARISON_LEDGER_BULK_PAIRS)) {
|
|
319
|
+
const pair = comparisonLedgerRecord(rawPair);
|
|
320
|
+
const earlierId = boundedComparisonLedgerId(pair?.earlierSessionId, counters);
|
|
321
|
+
const laterId = boundedComparisonLedgerId(pair?.laterSessionId, counters);
|
|
322
|
+
const earlier = sessions.get(earlierId);
|
|
323
|
+
const later = sessions.get(laterId);
|
|
324
|
+
if (!pair || !earlierId || !laterId || earlierId === laterId || !earlier || !later
|
|
325
|
+
|| earlier.updatedAt > later.updatedAt) {
|
|
326
|
+
counters.invalidRecords += 1;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const id = comparisonLedgerBulkPairIndexId(earlierId, laterId);
|
|
330
|
+
if (seen.has(id)) {
|
|
331
|
+
counters.duplicateRecords += 1;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
seen.add(id);
|
|
335
|
+
const comparison = compareBulkSessions(earlier, later);
|
|
336
|
+
if (!comparison) {
|
|
337
|
+
counters.invalidRecords += 1;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const ownerId = stableComparisonLedgerId('bulk-pair', [earlierId, laterId]);
|
|
341
|
+
const pairCompleteness = bulkPairCompleteness(earlier, later);
|
|
342
|
+
const candidate = makeComparisonLedgerCandidate({
|
|
343
|
+
idParts: [earlierId, laterId],
|
|
344
|
+
ownerType: 'bulk_session_pair',
|
|
345
|
+
ownerId,
|
|
346
|
+
entityId: `${earlierId}:${laterId}`,
|
|
347
|
+
label: `${earlier.name} → ${later.name} · explicit saved-session pair`,
|
|
348
|
+
mode: 'temporal',
|
|
349
|
+
earlier: { source: `Saved Bulk session · ${earlier.name}`, sourceState: earlier.state, observedAt: earlier.completedAt, retainedAt: earlier.updatedAt },
|
|
350
|
+
later: { source: `Saved Bulk session · ${later.name}`, sourceState: later.state, observedAt: later.completedAt, retainedAt: later.updatedAt },
|
|
351
|
+
completeness: pairCompleteness,
|
|
352
|
+
truncated: false,
|
|
353
|
+
limitations: [
|
|
354
|
+
...comparison.limitations,
|
|
355
|
+
...(pairCompleteness === 'complete' ? [] : ['At least one compact field or retained source state is not completely comparable across both selected sessions.']),
|
|
356
|
+
],
|
|
357
|
+
ownerHref: '/bulk#bulk-sessions-title',
|
|
358
|
+
buildDetails: () => buildBulkRows(ownerId, earlier, later, pairCompleteness),
|
|
359
|
+
}, counters);
|
|
360
|
+
if (candidate)
|
|
361
|
+
candidates.push(candidate);
|
|
362
|
+
else
|
|
363
|
+
counters.invalidRecords += 1;
|
|
364
|
+
}
|
|
365
|
+
return candidates;
|
|
366
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { safeComparisonLedgerHref, stableComparisonLedgerId, } from "./comparison-ledger-serialization.js";
|
|
2
|
+
import { projectComparisonLedgerJson } from "./comparison-ledger-json.js";
|
|
3
|
+
export { stableComparisonLedgerJson } from "./comparison-ledger-json.js";
|
|
4
|
+
export { safeComparisonLedgerHref, stableComparisonLedgerId } from "./comparison-ledger-serialization.js";
|
|
5
|
+
export const COMPARISON_LEDGER_MODES = Object.freeze([
|
|
6
|
+
'publication',
|
|
7
|
+
'entity',
|
|
8
|
+
'temporal',
|
|
9
|
+
'expectation',
|
|
10
|
+
'membership',
|
|
11
|
+
'reconciliation',
|
|
12
|
+
]);
|
|
13
|
+
export const COMPARISON_LEDGER_STATES = Object.freeze([
|
|
14
|
+
'equivalent',
|
|
15
|
+
'added',
|
|
16
|
+
'removed',
|
|
17
|
+
'different',
|
|
18
|
+
'conflict',
|
|
19
|
+
'collection_changed',
|
|
20
|
+
'model_changed',
|
|
21
|
+
'incomplete',
|
|
22
|
+
'unavailable',
|
|
23
|
+
'unsupported',
|
|
24
|
+
'not_compared',
|
|
25
|
+
]);
|
|
26
|
+
export const MAX_COMPARISON_LEDGER_INDEX_ITEMS = 2_000;
|
|
27
|
+
export const MAX_COMPARISON_LEDGER_ENTITIES_PER_REQUEST = 20;
|
|
28
|
+
export const MAX_COMPARISON_LEDGER_DETAIL_ROWS = 256;
|
|
29
|
+
export const MAX_COMPARISON_LEDGER_LIMITATIONS = 8;
|
|
30
|
+
export const MAX_COMPARISON_LEDGER_STRING_LENGTH = 500;
|
|
31
|
+
export const MAX_COMPARISON_LEDGER_ID_LENGTH = 128;
|
|
32
|
+
export const MAX_COMPARISON_LEDGER_BULK_PAIRS = MAX_COMPARISON_LEDGER_INDEX_ITEMS;
|
|
33
|
+
const MAX_LABEL_LENGTH = 160;
|
|
34
|
+
const MAX_FIELD_LENGTH = 120;
|
|
35
|
+
const MAX_SOURCE_LENGTH = 120;
|
|
36
|
+
const MAX_LIMITATION_SCAN = MAX_COMPARISON_LEDGER_LIMITATIONS * 4;
|
|
37
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]/gu;
|
|
38
|
+
const CONTROL_TEST_RE = /[\u0000-\u001f\u007f]/u;
|
|
39
|
+
export function freshComparisonLedgerCounters() {
|
|
40
|
+
return { inputRecords: 0, inputScanTruncations: 0, invalidRecords: 0, duplicateRecords: 0, limitations: 0, truncatedStrings: 0 };
|
|
41
|
+
}
|
|
42
|
+
export function comparisonLedgerRecord(value) {
|
|
43
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
44
|
+
? value
|
|
45
|
+
: null;
|
|
46
|
+
}
|
|
47
|
+
export function comparisonLedgerInputArray(value) {
|
|
48
|
+
return Array.isArray(value) ? value : [];
|
|
49
|
+
}
|
|
50
|
+
export function boundedComparisonLedgerText(value, maximum, counters, preserveWhitespace = false) {
|
|
51
|
+
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean')
|
|
52
|
+
return '';
|
|
53
|
+
const raw = String(value).replace(CONTROL_RE, ' ');
|
|
54
|
+
const normalised = preserveWhitespace ? raw.trim() : raw.replace(/\s+/gu, ' ').trim();
|
|
55
|
+
if (normalised.length > maximum)
|
|
56
|
+
counters && (counters.truncatedStrings += 1);
|
|
57
|
+
return normalised.slice(0, maximum).trim();
|
|
58
|
+
}
|
|
59
|
+
export function boundedComparisonLedgerId(value, counters) {
|
|
60
|
+
const text = boundedComparisonLedgerText(value, MAX_COMPARISON_LEDGER_ID_LENGTH * 2, counters);
|
|
61
|
+
const safe = text.replace(/[^A-Za-z0-9:._-]+/gu, '-').replace(/^-+|-+$/gu, '');
|
|
62
|
+
if (safe.length > MAX_COMPARISON_LEDGER_ID_LENGTH)
|
|
63
|
+
counters && (counters.truncatedStrings += 1);
|
|
64
|
+
return safe.slice(0, MAX_COMPARISON_LEDGER_ID_LENGTH);
|
|
65
|
+
}
|
|
66
|
+
function timestamp(value) {
|
|
67
|
+
if (typeof value !== 'string' || value.length > 64 || CONTROL_TEST_RE.test(value))
|
|
68
|
+
return null;
|
|
69
|
+
const parsed = Date.parse(value);
|
|
70
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
71
|
+
}
|
|
72
|
+
function normaliseCompleteness(value) {
|
|
73
|
+
return value === 'complete' || value === 'partial' || value === 'unavailable' || value === 'not_reported'
|
|
74
|
+
? value
|
|
75
|
+
: 'not_reported';
|
|
76
|
+
}
|
|
77
|
+
export function boundedComparisonLedgerLimitations(value, counters) {
|
|
78
|
+
const raw = Array.isArray(value) ? value : [];
|
|
79
|
+
const unscanned = Math.max(0, raw.length - MAX_LIMITATION_SCAN);
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
const all = [];
|
|
82
|
+
for (const item of raw.slice(0, MAX_LIMITATION_SCAN)) {
|
|
83
|
+
const entry = boundedComparisonLedgerText(item, MAX_COMPARISON_LEDGER_STRING_LENGTH, counters);
|
|
84
|
+
if (!entry || seen.has(entry))
|
|
85
|
+
continue;
|
|
86
|
+
seen.add(entry);
|
|
87
|
+
all.push(entry);
|
|
88
|
+
}
|
|
89
|
+
const values = all.slice(0, MAX_COMPARISON_LEDGER_LIMITATIONS);
|
|
90
|
+
const omitted = unscanned + Math.max(0, all.length - values.length);
|
|
91
|
+
counters.limitations += omitted;
|
|
92
|
+
return { values: Object.freeze(values), omitted };
|
|
93
|
+
}
|
|
94
|
+
function exactValue(value, counters) {
|
|
95
|
+
if (value === null || value === undefined)
|
|
96
|
+
return { value: null, truncated: false };
|
|
97
|
+
const traversal = typeof value === 'string'
|
|
98
|
+
? { value, truncated: false }
|
|
99
|
+
: projectComparisonLedgerJson(value);
|
|
100
|
+
const before = counters.truncatedStrings;
|
|
101
|
+
if (traversal.truncated)
|
|
102
|
+
counters.truncatedStrings += 1;
|
|
103
|
+
const projected = boundedComparisonLedgerText(traversal.value, MAX_COMPARISON_LEDGER_STRING_LENGTH, counters, true);
|
|
104
|
+
return { value: projected || null, truncated: counters.truncatedStrings > before };
|
|
105
|
+
}
|
|
106
|
+
function sourceSide(raw, counters) {
|
|
107
|
+
const projected = exactValue(raw.value, counters);
|
|
108
|
+
return {
|
|
109
|
+
side: Object.freeze({
|
|
110
|
+
source: boundedComparisonLedgerText(raw.source, MAX_SOURCE_LENGTH, counters) || 'Not recorded',
|
|
111
|
+
sourceState: boundedComparisonLedgerText(raw.sourceState, 64, counters) || 'not_reported',
|
|
112
|
+
value: projected.value,
|
|
113
|
+
observedAt: timestamp(raw.observedAt),
|
|
114
|
+
publishedAt: timestamp(raw.publishedAt),
|
|
115
|
+
retainedAt: timestamp(raw.retainedAt),
|
|
116
|
+
}),
|
|
117
|
+
truncated: projected.truncated,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function isMode(value) {
|
|
121
|
+
return typeof value === 'string' && COMPARISON_LEDGER_MODES.includes(value);
|
|
122
|
+
}
|
|
123
|
+
function isState(value) {
|
|
124
|
+
return typeof value === 'string' && COMPARISON_LEDGER_STATES.includes(value);
|
|
125
|
+
}
|
|
126
|
+
export function normaliseComparisonLedgerRowWithCounters(raw, counters) {
|
|
127
|
+
const item = comparisonLedgerRecord(raw);
|
|
128
|
+
const earlierRaw = comparisonLedgerRecord(item?.earlier);
|
|
129
|
+
const laterRaw = comparisonLedgerRecord(item?.later);
|
|
130
|
+
const ownerId = boundedComparisonLedgerId(item?.ownerId, counters);
|
|
131
|
+
const entityId = boundedComparisonLedgerId(item?.entityId, counters);
|
|
132
|
+
const mode = item?.mode;
|
|
133
|
+
const state = item?.state;
|
|
134
|
+
const field = boundedComparisonLedgerText(item?.field, MAX_FIELD_LENGTH, counters);
|
|
135
|
+
const family = boundedComparisonLedgerText(item?.family, MAX_FIELD_LENGTH, counters);
|
|
136
|
+
if (!item || !earlierRaw || !laterRaw || !ownerId || !entityId || !isMode(mode) || !isState(state) || !field || !family) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const earlier = sourceSide(earlierRaw, counters);
|
|
140
|
+
const later = sourceSide(laterRaw, counters);
|
|
141
|
+
const rowLimitations = boundedComparisonLedgerLimitations(item.limitations, counters);
|
|
142
|
+
const truncated = item.truncated === true || earlier.truncated || later.truncated || rowLimitations.omitted > 0;
|
|
143
|
+
const completeness = normaliseCompleteness(item.completeness);
|
|
144
|
+
const comparisonId = boundedComparisonLedgerId(item.comparisonId, counters);
|
|
145
|
+
const id = stableComparisonLedgerId('ledger-row', [
|
|
146
|
+
comparisonId, ownerId, entityId, mode, state, family, field,
|
|
147
|
+
earlier.side.source, earlier.side.sourceState, earlier.side.value,
|
|
148
|
+
earlier.side.observedAt, earlier.side.publishedAt, earlier.side.retainedAt,
|
|
149
|
+
later.side.source, later.side.sourceState, later.side.value,
|
|
150
|
+
later.side.observedAt, later.side.publishedAt, later.side.retainedAt,
|
|
151
|
+
completeness, truncated, rowLimitations.omitted, ...rowLimitations.values,
|
|
152
|
+
]);
|
|
153
|
+
return Object.freeze({
|
|
154
|
+
id,
|
|
155
|
+
ownerId,
|
|
156
|
+
entityId,
|
|
157
|
+
mode,
|
|
158
|
+
state,
|
|
159
|
+
field,
|
|
160
|
+
family,
|
|
161
|
+
earlier: earlier.side,
|
|
162
|
+
later: later.side,
|
|
163
|
+
completeness,
|
|
164
|
+
truncated,
|
|
165
|
+
limitations: rowLimitations.values,
|
|
166
|
+
omittedLimitations: rowLimitations.omitted,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
export function normaliseComparisonLedgerRow(raw) {
|
|
170
|
+
return normaliseComparisonLedgerRowWithCounters(raw, freshComparisonLedgerCounters());
|
|
171
|
+
}
|
|
172
|
+
export function comparisonLedgerCollector() {
|
|
173
|
+
const output = {
|
|
174
|
+
rows: [],
|
|
175
|
+
totalRows: 0,
|
|
176
|
+
add(row) {
|
|
177
|
+
output.totalRows += 1;
|
|
178
|
+
if (output.rows.length < MAX_COMPARISON_LEDGER_DETAIL_ROWS)
|
|
179
|
+
output.rows.push(row);
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
return output;
|
|
183
|
+
}
|
|
184
|
+
export function comparisonLedgerValuePresent(value) {
|
|
185
|
+
if (value === null || value === undefined)
|
|
186
|
+
return false;
|
|
187
|
+
if (typeof value === 'string')
|
|
188
|
+
return value.trim() !== '' && value.trim() !== '—';
|
|
189
|
+
if (Array.isArray(value))
|
|
190
|
+
return value.length > 0;
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
export function comparisonLedgerValueState(before, after, removalAllowed) {
|
|
194
|
+
if (!comparisonLedgerValuePresent(before) && comparisonLedgerValuePresent(after))
|
|
195
|
+
return 'added';
|
|
196
|
+
if (comparisonLedgerValuePresent(before) && !comparisonLedgerValuePresent(after))
|
|
197
|
+
return removalAllowed ? 'removed' : 'incomplete';
|
|
198
|
+
return 'different';
|
|
199
|
+
}
|
|
200
|
+
function indexSide(raw, counters) {
|
|
201
|
+
return Object.freeze({
|
|
202
|
+
source: boundedComparisonLedgerText(raw.source, MAX_SOURCE_LENGTH, counters) || 'Not recorded',
|
|
203
|
+
sourceState: boundedComparisonLedgerText(raw.sourceState, 64, counters) || 'not_reported',
|
|
204
|
+
observedAt: timestamp(raw.observedAt),
|
|
205
|
+
publishedAt: timestamp(raw.publishedAt),
|
|
206
|
+
retainedAt: timestamp(raw.retainedAt),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
export function makeComparisonLedgerCandidate(raw, counters) {
|
|
210
|
+
const ownerId = boundedComparisonLedgerId(raw.ownerId, counters);
|
|
211
|
+
const entityId = boundedComparisonLedgerId(raw.entityId, counters);
|
|
212
|
+
const label = boundedComparisonLedgerText(raw.label, MAX_LABEL_LENGTH, counters);
|
|
213
|
+
if (!ownerId || !entityId || !label)
|
|
214
|
+
return null;
|
|
215
|
+
const projectedLimitations = boundedComparisonLedgerLimitations(raw.limitations, counters);
|
|
216
|
+
const id = stableComparisonLedgerId(`ledger-${raw.ownerType}`, raw.idParts);
|
|
217
|
+
const item = Object.freeze({
|
|
218
|
+
id,
|
|
219
|
+
ownerType: raw.ownerType,
|
|
220
|
+
ownerId,
|
|
221
|
+
entityId,
|
|
222
|
+
label,
|
|
223
|
+
mode: raw.mode,
|
|
224
|
+
earlier: indexSide(raw.earlier, counters),
|
|
225
|
+
later: indexSide(raw.later, counters),
|
|
226
|
+
completeness: raw.completeness,
|
|
227
|
+
truncated: raw.truncated || projectedLimitations.omitted > 0,
|
|
228
|
+
limitations: projectedLimitations.values,
|
|
229
|
+
omittedLimitations: projectedLimitations.omitted,
|
|
230
|
+
sourceOmittedRows: Math.max(0, Math.trunc(Number(raw.sourceOmittedRows) || 0)),
|
|
231
|
+
ownerHref: safeComparisonLedgerHref(raw.ownerHref),
|
|
232
|
+
});
|
|
233
|
+
return Object.freeze({ item, buildDetails: raw.buildDetails });
|
|
234
|
+
}
|
|
235
|
+
export function validComparisonLedgerRequestId(value) {
|
|
236
|
+
return typeof value === 'string'
|
|
237
|
+
&& value.length > 0
|
|
238
|
+
&& value.length <= MAX_COMPARISON_LEDGER_ID_LENGTH
|
|
239
|
+
&& !CONTROL_TEST_RE.test(value)
|
|
240
|
+
&& boundedComparisonLedgerId(value) === value;
|
|
241
|
+
}
|