@slicedearth/whoisleuth-cli 1.45.0 → 2.0.0
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 +74 -124
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +7 -4
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +450 -173
- package/cli/artifact-structure.mjs +2507 -0
- package/cli/artifact-verify.mjs +286 -64
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +35 -23
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +435 -46
- package/cli/ci-report.mjs +6 -1
- package/cli/collection-preflight.mjs +2 -2
- package/cli/command-catalogue.mjs +12 -3
- package/cli/command-reference.mjs +948 -106
- package/cli/completion.mjs +504 -218
- package/cli/config-profile.mjs +13 -3
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +13 -14
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +215 -36
- package/cli/domain-control-observations.mjs +34 -17
- package/cli/errors.mjs +8 -3
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +68 -50
- package/cli/export-evidence.mjs +11 -4
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +586 -22
- package/cli/input.mjs +62 -0
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +80 -24
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookalike-calibration.mjs +3 -1
- package/cli/lookup-brief.mjs +13 -8
- package/cli/lookup-browser.mjs +844 -0
- package/cli/lookup-command-runner.mjs +96 -33
- package/cli/lookup-diff.mjs +11 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +45 -14
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +18 -10
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +343 -61
- package/cli/registry-doctor.mjs +67 -28
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +375 -0
- package/cli/risk-calibration.mjs +89 -40
- package/cli/runner.mjs +322 -246
- package/cli/saved-lookup.mjs +439 -49
- package/cli/sharing-review.mjs +18 -7
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1334
- package/docs/cli.md +87 -236
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
- package/frontend/src/lib/analysis/domain-control-records.js +3 -0
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +72 -52
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +91 -4
- package/lib/bounded-json.mjs +287 -0
- package/lib/browser-library-profile.mjs +1 -1
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/classify.mjs +56 -5
- package/lib/client-behavior-profile.mjs +1 -1
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/cryptographic-assurance.mjs +173 -0
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +120 -64
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +4 -3
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1052 -0
- package/lib/dnssec-evidence-validation.mjs +23 -10
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -171
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/domain-posture.mjs +7 -4
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +1243 -88
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/external-intelligence-risk.mjs +26 -10
- package/lib/favicon.mjs +8 -5
- package/lib/feature-policy.mjs +1 -1
- package/lib/homepage-metadata-contract.mjs +290 -0
- package/lib/hostname.mjs +1 -1
- package/lib/html-signals.mjs +89 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +332 -6
- package/lib/interchange-fidelity-registry.mjs +124 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup.mjs +27 -4
- package/lib/observed-network-context.mjs +1 -1
- package/lib/outbound-identity.mjs +2 -3
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +29 -0
- package/lib/provider-policy-admission.mjs +11 -4
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +324 -80
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/rdap-transport.mjs +2 -2
- package/lib/rdap-validation.mjs +65 -14
- package/lib/rdap.mjs +2 -2
- package/lib/registrable-domain.mjs +36 -0
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/registry-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +340 -0
- package/lib/risk-scoring.mjs +4 -3
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +121 -50
- package/lib/security-txt.mjs +27 -4
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +787 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/sslbl-intelligence.mjs +6 -8
- package/lib/static-html-analysis.mjs +301 -4
- package/lib/structured-data-identity.mjs +51 -19
- package/lib/threat-intelligence-fixtures.mjs +2 -4
- package/lib/threat-intelligence-manifests.mjs +2 -7
- package/lib/threat-intelligence-runtime.mjs +5 -15
- package/lib/threat-intelligence-types.mjs +21 -1
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +15 -9
- package/lib/tlsa-evidence.mjs +37 -7
- package/lib/urlhaus-intelligence.mjs +4 -2
- package/lib/urlscan-intelligence.mjs +6 -5
- package/lib/website-security-posture.mjs +1 -1
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-authority.mjs +43 -8
- package/lib/whois-chain.mjs +25 -3
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-transport.mjs +12 -3
- package/lib/zone-intent-review.mjs +1 -1
- package/package.json +12 -4
- package/packages/cases/case-brand-profile-references.mjs +85 -0
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
- package/packages/cases/case-investigation-branch-model.mjs +132 -0
- package/packages/cases/case-migration-model.mjs +476 -0
- package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
- package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
- package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
- package/packages/cases/case-record-model.mjs +9 -0
- package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
- package/packages/cases/case-response-model.mjs +1604 -0
- package/packages/cases/case-response-packet.mjs +1312 -0
- package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
- package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
- package/packages/cases/opaque-reference-id.mjs +9 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
- package/packages/comparison/comparison-ledger-contract.mjs +241 -0
- package/packages/comparison/comparison-ledger-json.mjs +80 -0
- package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
- package/packages/comparison/favicon-similarity.mjs +78 -0
- package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
- package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
- package/packages/contracts/analyst-review-state-contract.mjs +61 -0
- package/packages/contracts/analyst-review-state.mjs +50 -0
- package/packages/contracts/case-portability.mjs +1310 -0
- package/packages/contracts/cli-command-catalogue.mjs +2 -0
- package/packages/contracts/cli-lookup.mjs +394 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
- package/packages/contracts/domain-control-manifest.mjs +1027 -0
- package/packages/contracts/domain-control-monitor.mjs +310 -0
- package/packages/contracts/domain-control-review.mjs +828 -0
- package/packages/contracts/domain-name.mjs +4 -0
- package/packages/contracts/external-observation-interchange.mjs +224 -0
- package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
- package/packages/contracts/investigation-portability.mjs +331 -0
- package/packages/contracts/offline-comparison.mjs +160 -0
- package/packages/contracts/risk-calibration.mjs +1288 -0
- package/packages/contracts/schema-compatibility.mjs +71 -0
- package/packages/contracts/schema-lifecycle.mjs +1702 -0
- package/packages/contracts/sslbl-snapshot.mjs +17 -0
- package/packages/contracts/workspace-portability.mjs +1347 -0
- package/packages/evidence/artifact-integrity.mjs +65 -0
- package/packages/evidence/decision-fact.mjs +735 -0
- package/packages/evidence/domain-control-runtime.mjs +547 -0
- package/packages/evidence/domain-name.mjs +41 -0
- package/packages/evidence/observation.mjs +384 -0
- package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
- package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
- package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
- package/packages/investigation/lookup-claim-passport.mjs +232 -0
- package/packages/investigation/lookup-claim-readiness.mjs +287 -0
- package/packages/investigation/lookup-investigation-brief.mjs +172 -0
- package/packages/monitoring/analyst-review-state.mjs +680 -0
- package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
- package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
- package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
- package/packages/workspace/hostile-input.mjs +172 -0
- package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
- package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
- package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
- package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
- package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
- package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
- package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
- package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
- package/third-party-notices.txt +112 -5834
- package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
- package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
- package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
- package/frontend/src/lib/analysis/case-migration-model.js +0 -253
- package/frontend/src/lib/analysis/case-record-model.js +0 -7
- package/frontend/src/lib/analysis/case-response-model.js +0 -669
- package/frontend/src/lib/analysis/case-response-packet.js +0 -573
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
- package/frontend/src/lib/analysis/lookup-response.js +0 -2
- package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
- package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
- package/frontend/src/lib/analysis/scoring.js +0 -55
- package/frontend/src/lib/analysis/utils.js +0 -232
- package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
- package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
- package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
- package/frontend/src/lib/analysis/visualization-models.js +0 -369
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
- package/lib/bounded-json-response.mjs +0 -157
- package/lib/lookup-response-contract.mjs +0 -346
- package/lib/observation.mjs +0 -114
- /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
- /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
- /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
package/cli/registry-cohort.mjs
CHANGED
|
@@ -1,119 +1,401 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { canonicalArtifactJsonV2 } from "../packages/evidence/artifact-integrity.mjs";
|
|
3
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
4
|
+
import { scanBoundedJson } from "./bounded-json.mjs";
|
|
2
5
|
import { CliUsageError } from "./errors.mjs";
|
|
3
6
|
import { buildRegistryDoctorReport } from "./registry-doctor.mjs";
|
|
7
|
+
import { SAVED_LOOKUP_SCHEMA, parseSavedLookupDocument, } from "./saved-lookup.mjs";
|
|
4
8
|
export const REGISTRY_COHORT_SCHEMA = 'whoisleuth.cli.registry-cohort';
|
|
5
|
-
export const REGISTRY_COHORT_VERSION =
|
|
9
|
+
export const REGISTRY_COHORT_VERSION = 2;
|
|
6
10
|
export const MAX_REGISTRY_COHORT_INPUT_BYTES = 16 * 1024 * 1024;
|
|
7
11
|
export const MAX_REGISTRY_COHORT_SAMPLES = 500;
|
|
12
|
+
export const MAX_REGISTRY_COHORT_GROUPS = 500;
|
|
13
|
+
export const MAX_REGISTRY_COHORT_INPUT_POINTS = 2_000;
|
|
14
|
+
export const MAX_REGISTRY_COHORT_TIMELINE_POINTS = 50;
|
|
15
|
+
export const MAX_REGISTRY_COHORT_OMISSIONS = 1_000_000;
|
|
8
16
|
export const MIN_REGISTRY_COHORT_SAMPLE = 5;
|
|
9
|
-
|
|
10
|
-
|
|
17
|
+
const ROOT_KEYS = new Set(['schema', 'version', 'generatedAt', 'inputFamily', 'sampleCount', 'reportsMerged', 'minimumCohortSample', 'sampleWindow', 'cohorts', 'omissions', 'truncated', 'limitations']);
|
|
18
|
+
const COHORT_KEYS = new Set(['suffix', 'profileId', 'sampleCount', 'state', 'latestState', 'sampleWindow', 'sourceAlignment', 'publication', 'timeline', 'timelineOmitted']);
|
|
19
|
+
const POINT_KEYS = new Set(['reportGeneratedAt', 'sampleWindow', 'sampleCount', 'state', 'sourceAlignment', 'publication']);
|
|
20
|
+
const SOURCE_KEYS = new Set(['investigate', 'expectedConstraints', 'observed']);
|
|
21
|
+
const PUBLICATION_KEYS = new Set(['reviewItems', 'objectIdentifiersObserved', 'baseConformanceObserved', 'selfLinksObserved', 'redactionMetadataObserved']);
|
|
22
|
+
const WINDOW_KEYS = new Set(['from', 'to']);
|
|
23
|
+
const OMISSION_KEYS = new Set(['duplicateTimelinePoints', 'timelinePoints']);
|
|
24
|
+
const LIMITATIONS = Object.freeze([
|
|
25
|
+
'The report intentionally omits domains, queries and raw evidence. It groups retained observations only by suffix and capability profile.',
|
|
26
|
+
`Every timeline point retains its original state. Cohorts with fewer than ${MIN_REGISTRY_COHORT_SAMPLE} observations remain insufficient sample and are never promoted by summing overlapping reports.`,
|
|
27
|
+
'A merged cohort keeps the most conservative retained state and separately reports the latest point. Duplicate points are suppressed and inherited omission counts are not summed across overlapping retained reports.',
|
|
28
|
+
'Review fixture provenance and source health before changing a parser or registry access profile.',
|
|
29
|
+
]);
|
|
30
|
+
function record(value, label) {
|
|
31
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
32
|
+
throw new CliUsageError(`${label} must be an object.`);
|
|
33
|
+
return value;
|
|
11
34
|
}
|
|
12
|
-
function
|
|
13
|
-
|
|
14
|
-
|
|
35
|
+
function exact(value, keys, label) {
|
|
36
|
+
const item = record(value, label);
|
|
37
|
+
if (Object.keys(item).some((key) => !keys.has(key)) || [...keys].some((key) => !Object.hasOwn(item, key))) {
|
|
38
|
+
throw new CliUsageError(`${label} does not match its exact retained contract.`);
|
|
39
|
+
}
|
|
40
|
+
return item;
|
|
41
|
+
}
|
|
42
|
+
function integer(value, minimum, maximum, label) {
|
|
43
|
+
if (!Number.isSafeInteger(value) || Number(value) < minimum || Number(value) > maximum) {
|
|
44
|
+
throw new CliUsageError(`${label} is outside its supported bound.`);
|
|
15
45
|
}
|
|
46
|
+
return Number(value);
|
|
47
|
+
}
|
|
48
|
+
function timestamp(value, label) {
|
|
49
|
+
if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
50
|
+
throw new CliUsageError(`${label} is invalid.`);
|
|
51
|
+
}
|
|
52
|
+
const normalized = normalizeExplicitIsoTimestamp(value);
|
|
53
|
+
if (normalized)
|
|
54
|
+
return normalized;
|
|
55
|
+
throw new CliUsageError(`${label} is invalid.`);
|
|
56
|
+
}
|
|
57
|
+
function identifier(value, label) {
|
|
58
|
+
if (typeof value !== 'string' || !/^[a-z0-9][a-z0-9_-]{0,119}$/u.test(value))
|
|
59
|
+
throw new CliUsageError(`${label} is invalid.`);
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
function suffix(value) {
|
|
63
|
+
if (typeof value !== 'string' || !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(value))
|
|
64
|
+
throw new CliUsageError('Registry cohort suffix is invalid.');
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
function state(value, label) {
|
|
68
|
+
if (value !== 'consistent' && value !== 'insufficient_sample' && value !== 'review')
|
|
69
|
+
throw new CliUsageError(`${label} is invalid.`);
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
function window(value, label) {
|
|
73
|
+
const item = exact(value, WINDOW_KEYS, label);
|
|
74
|
+
const from = timestamp(item.from, `${label} from`);
|
|
75
|
+
const to = timestamp(item.to, `${label} to`);
|
|
76
|
+
if (from > to)
|
|
77
|
+
throw new CliUsageError(`${label} is reversed.`);
|
|
78
|
+
return Object.freeze({ from, to });
|
|
79
|
+
}
|
|
80
|
+
function sourceAlignment(value, sampleCount, label) {
|
|
81
|
+
const item = exact(value, SOURCE_KEYS, label);
|
|
82
|
+
return Object.freeze({
|
|
83
|
+
investigate: integer(item.investigate, 0, sampleCount * 2, `${label} investigate`),
|
|
84
|
+
expectedConstraints: integer(item.expectedConstraints, 0, sampleCount * 2, `${label} expected constraints`),
|
|
85
|
+
observed: integer(item.observed, 0, sampleCount * 2, `${label} observed`),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function publication(value, sampleCount, label) {
|
|
89
|
+
const item = exact(value, PUBLICATION_KEYS, label);
|
|
90
|
+
return Object.freeze({
|
|
91
|
+
reviewItems: integer(item.reviewItems, 0, sampleCount * 4, `${label} review items`),
|
|
92
|
+
objectIdentifiersObserved: integer(item.objectIdentifiersObserved, 0, sampleCount, `${label} object identifiers`),
|
|
93
|
+
baseConformanceObserved: integer(item.baseConformanceObserved, 0, sampleCount, `${label} base conformance`),
|
|
94
|
+
selfLinksObserved: integer(item.selfLinksObserved, 0, sampleCount, `${label} self links`),
|
|
95
|
+
redactionMetadataObserved: integer(item.redactionMetadataObserved, 0, sampleCount, `${label} redaction metadata`),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
function derivedState(sampleCount, sources, published) {
|
|
99
|
+
return sampleCount < MIN_REGISTRY_COHORT_SAMPLE
|
|
100
|
+
? 'insufficient_sample'
|
|
101
|
+
: sources.investigate || published.reviewItems
|
|
102
|
+
? 'review'
|
|
103
|
+
: 'consistent';
|
|
104
|
+
}
|
|
105
|
+
function conservativeState(states) {
|
|
106
|
+
return states.includes('review') ? 'review' : states.includes('insufficient_sample') ? 'insufficient_sample' : 'consistent';
|
|
107
|
+
}
|
|
108
|
+
function timelinePoint(value, label) {
|
|
109
|
+
const item = exact(value, POINT_KEYS, label);
|
|
110
|
+
const sampleCount = integer(item.sampleCount, 1, MAX_REGISTRY_COHORT_SAMPLES, `${label} sample count`);
|
|
111
|
+
const sources = sourceAlignment(item.sourceAlignment, sampleCount, `${label} source alignment`);
|
|
112
|
+
const published = publication(item.publication, sampleCount, `${label} publication`);
|
|
113
|
+
const pointState = state(item.state, `${label} state`);
|
|
114
|
+
if (pointState !== derivedState(sampleCount, sources, published))
|
|
115
|
+
throw new CliUsageError(`${label} state does not match its retained counts.`);
|
|
116
|
+
return Object.freeze({
|
|
117
|
+
reportGeneratedAt: timestamp(item.reportGeneratedAt, `${label} report generatedAt`),
|
|
118
|
+
sampleWindow: window(item.sampleWindow, `${label} sample window`),
|
|
119
|
+
sampleCount,
|
|
120
|
+
state: pointState,
|
|
121
|
+
sourceAlignment: sources,
|
|
122
|
+
publication: published,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
function boundedLimitations(value, expected, label) {
|
|
126
|
+
if (!Array.isArray(value) || value.length !== expected.length || value.some((item, index) => item !== expected[index])) {
|
|
127
|
+
throw new CliUsageError(`${label} limitations do not match the supported target-free contract.`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function parseInputDocuments(text) {
|
|
131
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_REGISTRY_COHORT_INPUT_BYTES)
|
|
132
|
+
throw new CliUsageError(`Registry cohort input is limited to ${MAX_REGISTRY_COHORT_INPUT_BYTES} bytes.`);
|
|
16
133
|
const trimmed = text.replace(/^\uFEFF/u, '').trim();
|
|
17
134
|
if (!trimmed)
|
|
18
135
|
throw new CliUsageError('Registry cohort input is empty.');
|
|
19
136
|
let values;
|
|
20
137
|
if (trimmed.startsWith('[')) {
|
|
21
|
-
let parsed;
|
|
22
138
|
try {
|
|
23
|
-
|
|
139
|
+
scanBoundedJson(trimmed);
|
|
140
|
+
const parsed = JSON.parse(trimmed);
|
|
141
|
+
if (!Array.isArray(parsed))
|
|
142
|
+
throw new Error('not array');
|
|
143
|
+
values = parsed;
|
|
24
144
|
}
|
|
25
|
-
catch {
|
|
26
|
-
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (error instanceof TypeError && /duplicate|nesting|value limit|key limit/iu.test(error.message))
|
|
147
|
+
throw new CliUsageError(error.message);
|
|
148
|
+
throw new CliUsageError('Registry cohort JSON input must be a valid array.');
|
|
27
149
|
}
|
|
28
|
-
if (!Array.isArray(parsed))
|
|
29
|
-
throw new CliUsageError('Registry cohort JSON input must be an array.');
|
|
30
|
-
values = parsed;
|
|
31
150
|
}
|
|
32
151
|
else {
|
|
33
152
|
const lines = trimmed.split(/\r?\n/u).filter((line) => line.trim());
|
|
153
|
+
if (!lines.length || lines.length > MAX_REGISTRY_COHORT_SAMPLES) {
|
|
154
|
+
throw new CliUsageError(`Registry cohort input must contain from 1 to ${MAX_REGISTRY_COHORT_SAMPLES} saved Lookups or retained cohort reports.`);
|
|
155
|
+
}
|
|
34
156
|
values = lines.map((line, index) => {
|
|
35
157
|
try {
|
|
158
|
+
scanBoundedJson(line);
|
|
36
159
|
return JSON.parse(line);
|
|
37
160
|
}
|
|
38
|
-
catch {
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (error instanceof TypeError && /duplicate|nesting|value limit|key limit/iu.test(error.message))
|
|
163
|
+
throw new CliUsageError(`Registry cohort JSONL line ${index + 1}: ${error.message}`);
|
|
39
164
|
throw new CliUsageError(`Registry cohort JSONL line ${index + 1} is invalid.`);
|
|
40
165
|
}
|
|
41
166
|
});
|
|
42
167
|
}
|
|
43
|
-
if (!values.length || values.length > MAX_REGISTRY_COHORT_SAMPLES)
|
|
44
|
-
throw new CliUsageError(`Registry cohort input must contain from 1 to ${MAX_REGISTRY_COHORT_SAMPLES} saved Lookups.`);
|
|
45
|
-
}
|
|
46
|
-
const output = values.map(record);
|
|
47
|
-
if (output.some((item) => !Object.keys(item).length))
|
|
48
|
-
throw new CliUsageError('Every registry cohort item must be one saved Lookup object.');
|
|
49
|
-
return output;
|
|
168
|
+
if (!values.length || values.length > MAX_REGISTRY_COHORT_SAMPLES)
|
|
169
|
+
throw new CliUsageError(`Registry cohort input must contain from 1 to ${MAX_REGISTRY_COHORT_SAMPLES} saved Lookups or retained cohort reports.`);
|
|
170
|
+
return values.map((value, index) => record(value, `Registry cohort item ${index + 1}`));
|
|
50
171
|
}
|
|
51
|
-
|
|
52
|
-
const
|
|
172
|
+
function groupPoint(reports, reportGeneratedAt) {
|
|
173
|
+
const sampleCount = reports.length;
|
|
174
|
+
const sources = Object.freeze({
|
|
175
|
+
investigate: reports.reduce((total, item) => total + item.summary.investigate, 0),
|
|
176
|
+
expectedConstraints: reports.reduce((total, item) => total + item.summary.expectedConstraints, 0),
|
|
177
|
+
observed: reports.reduce((total, item) => total + item.summary.observed, 0),
|
|
178
|
+
});
|
|
179
|
+
const published = Object.freeze({
|
|
180
|
+
reviewItems: reports.reduce((total, item) => total + item.publication.reviewItems, 0),
|
|
181
|
+
objectIdentifiersObserved: reports.filter((item) => item.publication.objectIdentifier.state === 'observed').length,
|
|
182
|
+
baseConformanceObserved: reports.filter((item) => item.publication.baseConformance.state === 'observed').length,
|
|
183
|
+
selfLinksObserved: reports.filter((item) => item.publication.selfLink.state === 'observed').length,
|
|
184
|
+
redactionMetadataObserved: reports.filter((item) => item.publication.redactionMetadata.state === 'observed').length,
|
|
185
|
+
});
|
|
186
|
+
const observed = reports.map((report) => report.generatedAt).sort();
|
|
187
|
+
return Object.freeze({
|
|
188
|
+
reportGeneratedAt,
|
|
189
|
+
sampleWindow: Object.freeze({ from: observed[0], to: observed.at(-1) }),
|
|
190
|
+
sampleCount,
|
|
191
|
+
state: derivedState(sampleCount, sources, published),
|
|
192
|
+
sourceAlignment: sources,
|
|
193
|
+
publication: published,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
function sourceReportsFromLookups(documents, generatedAt) {
|
|
197
|
+
const reports = documents.map((item) => {
|
|
198
|
+
const raw = JSON.stringify(item);
|
|
199
|
+
const lookup = parseSavedLookupDocument(raw, { label: 'Registry cohort saved Lookup' });
|
|
200
|
+
return buildRegistryDoctorReport(raw, timestamp(lookup.generatedAt, 'Saved Lookup generatedAt'));
|
|
201
|
+
});
|
|
53
202
|
const groups = new Map();
|
|
54
203
|
for (const report of reports) {
|
|
55
204
|
const key = `${report.suffix}\u0000${report.profile.id}`;
|
|
56
|
-
|
|
57
|
-
group.push(report);
|
|
58
|
-
groups.set(key, group);
|
|
205
|
+
groups.set(key, [...(groups.get(key) ?? []), report]);
|
|
59
206
|
}
|
|
60
207
|
const cohorts = [...groups.values()].map((group) => {
|
|
61
208
|
const sample = group[0];
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
209
|
+
const point = groupPoint(group, generatedAt);
|
|
210
|
+
return Object.freeze({ suffix: sample.suffix, profileId: sample.profile.id, sampleWindow: point.sampleWindow, points: Object.freeze([point]), inheritedState: point.state, upstreamTimelineOmitted: 0 });
|
|
211
|
+
});
|
|
212
|
+
const observed = reports.map((report) => report.generatedAt).sort();
|
|
213
|
+
return [Object.freeze({ generatedAt, sampleWindow: Object.freeze({ from: observed[0], to: observed.at(-1) }), cohorts: Object.freeze(cohorts), duplicateTimelinePoints: 0 })];
|
|
214
|
+
}
|
|
215
|
+
function parseCurrentReport(value) {
|
|
216
|
+
const root = exact(value, ROOT_KEYS, 'Registry cohort v2 report');
|
|
217
|
+
if (root.schema !== REGISTRY_COHORT_SCHEMA || root.version !== REGISTRY_COHORT_VERSION)
|
|
218
|
+
throw new CliUsageError('Registry cohort report version is unsupported.');
|
|
219
|
+
const generatedAt = timestamp(root.generatedAt, 'Registry cohort v2 generatedAt');
|
|
220
|
+
if (root.inputFamily !== 'saved_lookups' && root.inputFamily !== 'retained_reports')
|
|
221
|
+
throw new CliUsageError('Registry cohort v2 input family is invalid.');
|
|
222
|
+
const reportsMerged = integer(root.reportsMerged, 0, MAX_REGISTRY_COHORT_SAMPLES, 'Registry cohort v2 reports merged');
|
|
223
|
+
if ((root.inputFamily === 'saved_lookups') !== (reportsMerged === 0))
|
|
224
|
+
throw new CliUsageError('Registry cohort v2 report count does not match its input family.');
|
|
225
|
+
if (root.minimumCohortSample !== MIN_REGISTRY_COHORT_SAMPLE)
|
|
226
|
+
throw new CliUsageError('Registry cohort v2 minimum sample is unsupported.');
|
|
227
|
+
const reportWindow = window(root.sampleWindow, 'Registry cohort v2 sample window');
|
|
228
|
+
boundedLimitations(root.limitations, LIMITATIONS, 'Registry cohort v2');
|
|
229
|
+
const omissions = exact(root.omissions, OMISSION_KEYS, 'Registry cohort v2 omissions');
|
|
230
|
+
const duplicateTimelinePoints = integer(omissions.duplicateTimelinePoints, 0, MAX_REGISTRY_COHORT_OMISSIONS, 'Registry cohort v2 duplicate points');
|
|
231
|
+
const omittedTimelinePoints = integer(omissions.timelinePoints, 0, MAX_REGISTRY_COHORT_OMISSIONS, 'Registry cohort v2 omitted points');
|
|
232
|
+
if (root.truncated !== (omittedTimelinePoints > 0))
|
|
233
|
+
throw new CliUsageError('Registry cohort v2 truncation state is inconsistent.');
|
|
234
|
+
if (!Array.isArray(root.cohorts) || !root.cohorts.length || root.cohorts.length > MAX_REGISTRY_COHORT_GROUPS)
|
|
235
|
+
throw new CliUsageError('Registry cohort v2 groups are invalid.');
|
|
236
|
+
const seen = new Set();
|
|
237
|
+
let represented = 0;
|
|
238
|
+
let cohortOmitted = 0;
|
|
239
|
+
const cohorts = root.cohorts.map((candidate, index) => {
|
|
240
|
+
const item = exact(candidate, COHORT_KEYS, `Registry cohort v2 group ${index + 1}`);
|
|
241
|
+
const groupSuffix = suffix(item.suffix);
|
|
242
|
+
const profileId = identifier(item.profileId, 'Registry cohort v2 profile ID');
|
|
243
|
+
const key = `${groupSuffix}\u0000${profileId}`;
|
|
244
|
+
if (seen.has(key))
|
|
245
|
+
throw new CliUsageError('Registry cohort v2 groups must be unique.');
|
|
246
|
+
seen.add(key);
|
|
247
|
+
const count = integer(item.sampleCount, 1, MAX_REGISTRY_COHORT_SAMPLES, 'Registry cohort v2 group sample count');
|
|
248
|
+
const groupWindow = window(item.sampleWindow, 'Registry cohort v2 group sample window');
|
|
249
|
+
const timelineOmitted = integer(item.timelineOmitted, 0, MAX_REGISTRY_COHORT_OMISSIONS, 'Registry cohort v2 group omitted points');
|
|
250
|
+
cohortOmitted += timelineOmitted;
|
|
251
|
+
if (!Array.isArray(item.timeline) || !item.timeline.length || item.timeline.length > MAX_REGISTRY_COHORT_TIMELINE_POINTS)
|
|
252
|
+
throw new CliUsageError('Registry cohort v2 timeline is invalid.');
|
|
253
|
+
const points = item.timeline.map((point, pointIndex) => timelinePoint(point, `Registry cohort v2 point ${pointIndex + 1}`));
|
|
254
|
+
const identities = points.map(canonicalArtifactJsonV2);
|
|
255
|
+
if (new Set(identities).size !== points.length || identities.some((identity, pointIndex) => pointIndex > 0 && pointOrder(points[pointIndex - 1], points[pointIndex]) > 0))
|
|
256
|
+
throw new CliUsageError('Registry cohort v2 timeline must be unique and ordered.');
|
|
257
|
+
const latest = points.at(-1);
|
|
258
|
+
const groupState = state(item.state, 'Registry cohort v2 group state');
|
|
259
|
+
const latestState = state(item.latestState, 'Registry cohort v2 latest state');
|
|
260
|
+
const sources = sourceAlignment(item.sourceAlignment, count, 'Registry cohort v2 group source alignment');
|
|
261
|
+
const published = publication(item.publication, count, 'Registry cohort v2 group publication');
|
|
262
|
+
const visibleState = conservativeState(points.map((point) => point.state));
|
|
263
|
+
const pointFrom = points.map((point) => point.sampleWindow.from).sort()[0];
|
|
264
|
+
const pointTo = points.map((point) => point.sampleWindow.to).sort().at(-1);
|
|
265
|
+
if (latestState !== latest.state || count !== latest.sampleCount
|
|
266
|
+
|| canonicalArtifactJsonV2(sources) !== canonicalArtifactJsonV2(latest.sourceAlignment)
|
|
267
|
+
|| canonicalArtifactJsonV2(published) !== canonicalArtifactJsonV2(latest.publication)
|
|
268
|
+
|| (timelineOmitted === 0 && groupState !== visibleState)
|
|
269
|
+
|| (timelineOmitted > 0 && conservativeState([groupState, visibleState]) !== groupState)
|
|
270
|
+
|| groupWindow.from > pointFrom || groupWindow.to < pointTo) {
|
|
271
|
+
throw new CliUsageError('Registry cohort v2 current group projection is inconsistent with its timeline.');
|
|
272
|
+
}
|
|
273
|
+
represented += count;
|
|
274
|
+
return Object.freeze({ suffix: groupSuffix, profileId, sampleWindow: groupWindow, points: Object.freeze(points), inheritedState: groupState, upstreamTimelineOmitted: timelineOmitted });
|
|
275
|
+
});
|
|
276
|
+
if (integer(root.sampleCount, 1, MAX_REGISTRY_COHORT_GROUPS * MAX_REGISTRY_COHORT_SAMPLES, 'Registry cohort v2 sample count') !== represented
|
|
277
|
+
|| cohortOmitted !== omittedTimelinePoints
|
|
278
|
+
|| reportWindow.from !== [...cohorts].sort((a, b) => a.sampleWindow.from.localeCompare(b.sampleWindow.from))[0]?.sampleWindow.from
|
|
279
|
+
|| reportWindow.to !== [...cohorts].sort((a, b) => b.sampleWindow.to.localeCompare(a.sampleWindow.to))[0]?.sampleWindow.to) {
|
|
280
|
+
throw new CliUsageError('Registry cohort v2 report summary is inconsistent with its groups.');
|
|
281
|
+
}
|
|
282
|
+
return Object.freeze({ generatedAt, sampleWindow: reportWindow, cohorts: Object.freeze(cohorts), duplicateTimelinePoints });
|
|
283
|
+
}
|
|
284
|
+
function pointOrder(left, right) {
|
|
285
|
+
return left.sampleWindow.to.localeCompare(right.sampleWindow.to)
|
|
286
|
+
|| left.reportGeneratedAt.localeCompare(right.reportGeneratedAt)
|
|
287
|
+
|| canonicalArtifactJsonV2(left).localeCompare(canonicalArtifactJsonV2(right));
|
|
288
|
+
}
|
|
289
|
+
function buildCurrentReport(sourceReports, generatedAt, inputFamily, directSampleCount) {
|
|
290
|
+
const totalPoints = sourceReports.reduce((sum, report) => sum + report.cohorts.reduce((groupSum, cohort) => groupSum + cohort.points.length, 0), 0);
|
|
291
|
+
if (totalPoints > MAX_REGISTRY_COHORT_INPUT_POINTS)
|
|
292
|
+
throw new CliUsageError(`Retained registry cohort input contains more than ${MAX_REGISTRY_COHORT_INPUT_POINTS} timeline points.`);
|
|
293
|
+
const groups = new Map();
|
|
294
|
+
for (const report of sourceReports)
|
|
295
|
+
for (const cohort of report.cohorts) {
|
|
296
|
+
const key = `${cohort.suffix}\u0000${cohort.profileId}`;
|
|
297
|
+
groups.set(key, [...(groups.get(key) ?? []), cohort]);
|
|
298
|
+
}
|
|
299
|
+
if (!groups.size || groups.size > MAX_REGISTRY_COHORT_GROUPS)
|
|
300
|
+
throw new CliUsageError(`Registry cohort output is limited to ${MAX_REGISTRY_COHORT_GROUPS} suffix/profile groups.`);
|
|
301
|
+
let duplicateTimelinePoints = Math.max(0, ...sourceReports.map((report) => report.duplicateTimelinePoints));
|
|
302
|
+
const cohorts = [...groups.entries()].map(([key, inputs]) => {
|
|
303
|
+
const unique = new Map();
|
|
304
|
+
for (const input of inputs)
|
|
305
|
+
for (const point of input.points) {
|
|
306
|
+
const identity = canonicalArtifactJsonV2(point);
|
|
307
|
+
if (unique.has(identity))
|
|
308
|
+
duplicateTimelinePoints += 1;
|
|
309
|
+
else
|
|
310
|
+
unique.set(identity, point);
|
|
311
|
+
}
|
|
312
|
+
const allPoints = [...unique.values()].sort(pointOrder);
|
|
313
|
+
const locallyOmitted = Math.max(0, allPoints.length - MAX_REGISTRY_COHORT_TIMELINE_POINTS);
|
|
314
|
+
const timeline = allPoints.slice(-MAX_REGISTRY_COHORT_TIMELINE_POINTS);
|
|
315
|
+
const latest = timeline.at(-1);
|
|
316
|
+
const upstreamTimelineOmitted = Math.max(0, ...inputs.map((input) => input.upstreamTimelineOmitted));
|
|
317
|
+
const groupWindow = Object.freeze({
|
|
318
|
+
from: inputs.map((input) => input.sampleWindow.from).sort()[0],
|
|
319
|
+
to: inputs.map((input) => input.sampleWindow.to).sort().at(-1),
|
|
320
|
+
});
|
|
67
321
|
return Object.freeze({
|
|
68
|
-
suffix:
|
|
69
|
-
profileId:
|
|
70
|
-
sampleCount,
|
|
71
|
-
state:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
expectedConstraints: group.reduce((total, item) => total + item.summary.expectedConstraints, 0),
|
|
79
|
-
observed: group.reduce((total, item) => total + item.summary.observed, 0),
|
|
80
|
-
}),
|
|
81
|
-
publication: Object.freeze({
|
|
82
|
-
reviewItems: publicationReviewItems,
|
|
83
|
-
objectIdentifiersObserved: group.filter((item) => item.publication.objectIdentifier.state === 'observed').length,
|
|
84
|
-
baseConformanceObserved: group.filter((item) => item.publication.baseConformance.state === 'observed').length,
|
|
85
|
-
selfLinksObserved: group.filter((item) => item.publication.selfLink.state === 'observed').length,
|
|
86
|
-
redactionMetadataObserved: group.filter((item) => item.publication.redactionMetadata.state === 'observed').length,
|
|
87
|
-
}),
|
|
322
|
+
suffix: key.split('\u0000')[0],
|
|
323
|
+
profileId: key.split('\u0000')[1],
|
|
324
|
+
sampleCount: latest.sampleCount,
|
|
325
|
+
state: conservativeState([...inputs.map((input) => input.inheritedState), ...allPoints.map((point) => point.state)]),
|
|
326
|
+
latestState: latest.state,
|
|
327
|
+
sampleWindow: groupWindow,
|
|
328
|
+
sourceAlignment: latest.sourceAlignment,
|
|
329
|
+
publication: latest.publication,
|
|
330
|
+
timeline: Object.freeze(timeline),
|
|
331
|
+
timelineOmitted: upstreamTimelineOmitted + locallyOmitted,
|
|
88
332
|
});
|
|
89
333
|
}).sort((left, right) => left.suffix.localeCompare(right.suffix) || left.profileId.localeCompare(right.profileId));
|
|
334
|
+
const timelinePoints = cohorts.reduce((sum, cohort) => sum + cohort.timelineOmitted, 0);
|
|
335
|
+
if (duplicateTimelinePoints > MAX_REGISTRY_COHORT_OMISSIONS || timelinePoints > MAX_REGISTRY_COHORT_OMISSIONS) {
|
|
336
|
+
throw new CliUsageError(`Registry cohort omission accounting exceeds the ${MAX_REGISTRY_COHORT_OMISSIONS}-item bound.`);
|
|
337
|
+
}
|
|
338
|
+
const allWindows = sourceReports.map((report) => report.sampleWindow);
|
|
90
339
|
return Object.freeze({
|
|
91
340
|
schema: REGISTRY_COHORT_SCHEMA,
|
|
92
341
|
version: REGISTRY_COHORT_VERSION,
|
|
93
|
-
generatedAt,
|
|
94
|
-
|
|
342
|
+
generatedAt: timestamp(generatedAt, 'Registry cohort generatedAt'),
|
|
343
|
+
inputFamily,
|
|
344
|
+
sampleCount: inputFamily === 'saved_lookups' ? directSampleCount : cohorts.reduce((sum, cohort) => sum + cohort.sampleCount, 0),
|
|
345
|
+
reportsMerged: inputFamily === 'retained_reports' ? sourceReports.length : 0,
|
|
95
346
|
minimumCohortSample: MIN_REGISTRY_COHORT_SAMPLE,
|
|
347
|
+
sampleWindow: Object.freeze({
|
|
348
|
+
from: allWindows.map((item) => item.from).sort()[0],
|
|
349
|
+
to: allWindows.map((item) => item.to).sort().at(-1),
|
|
350
|
+
}),
|
|
96
351
|
cohorts: Object.freeze(cohorts),
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
'Repeated observations from one environment are not representative by themselves. Review fixture provenance and source health before changing a parser or access profile.',
|
|
101
|
-
]),
|
|
352
|
+
omissions: Object.freeze({ duplicateTimelinePoints, timelinePoints }),
|
|
353
|
+
truncated: timelinePoints > 0,
|
|
354
|
+
limitations: LIMITATIONS,
|
|
102
355
|
});
|
|
103
356
|
}
|
|
357
|
+
export function buildRegistryCohortReport(text, generatedAt = new Date().toISOString()) {
|
|
358
|
+
const documents = parseInputDocuments(text);
|
|
359
|
+
const families = new Set(documents.map((document) => document.schema === SAVED_LOOKUP_SCHEMA ? 'saved_lookups' : document.schema === REGISTRY_COHORT_SCHEMA ? 'retained_reports' : 'unsupported'));
|
|
360
|
+
if (families.size !== 1 || families.has('unsupported'))
|
|
361
|
+
throw new CliUsageError('Registry cohort input must contain only saved Lookups or only retained registry cohort reports; mixed families are rejected.');
|
|
362
|
+
const inputFamily = [...families][0];
|
|
363
|
+
if (inputFamily === 'saved_lookups') {
|
|
364
|
+
return buildCurrentReport(sourceReportsFromLookups(documents, timestamp(generatedAt, 'Registry cohort generatedAt')), generatedAt, inputFamily, documents.length);
|
|
365
|
+
}
|
|
366
|
+
const reports = [];
|
|
367
|
+
let retainedPoints = 0;
|
|
368
|
+
for (const document of documents) {
|
|
369
|
+
const report = document.version === REGISTRY_COHORT_VERSION
|
|
370
|
+
? parseCurrentReport(document)
|
|
371
|
+
: (() => { throw new CliUsageError('Registry cohort report version is unsupported.'); })();
|
|
372
|
+
retainedPoints += report.cohorts.reduce((total, cohort) => total + cohort.points.length, 0);
|
|
373
|
+
if (retainedPoints > MAX_REGISTRY_COHORT_INPUT_POINTS) {
|
|
374
|
+
throw new CliUsageError(`Retained registry cohort input contains more than ${MAX_REGISTRY_COHORT_INPUT_POINTS} timeline points.`);
|
|
375
|
+
}
|
|
376
|
+
reports.push(report);
|
|
377
|
+
}
|
|
378
|
+
return buildCurrentReport(reports, generatedAt, inputFamily, 0);
|
|
379
|
+
}
|
|
104
380
|
export function formatRegistryCohortReport(report) {
|
|
105
381
|
const output = [
|
|
106
|
-
'Registry quality cohort',
|
|
107
|
-
`
|
|
382
|
+
'Registry quality cohort timeline',
|
|
383
|
+
`Input family ${report.inputFamily.replaceAll('_', ' ')}`,
|
|
384
|
+
`Current samples ${report.sampleCount}`,
|
|
385
|
+
`Reports merged ${report.reportsMerged}`,
|
|
386
|
+
`Sample window ${report.sampleWindow.from} → ${report.sampleWindow.to}`,
|
|
108
387
|
`Minimum sample ${report.minimumCohortSample}`,
|
|
109
388
|
'',
|
|
110
389
|
];
|
|
111
390
|
for (const cohort of report.cohorts) {
|
|
112
391
|
output.push(`.${cohort.suffix} · ${cohort.profileId} [${cohort.state.replaceAll('_', ' ')}]`);
|
|
113
|
-
output.push(`
|
|
392
|
+
output.push(` Latest: ${cohort.latestState.replaceAll('_', ' ')} · ${cohort.sampleCount} samples`);
|
|
393
|
+
output.push(` Timeline points: ${cohort.timeline.length}${cohort.timelineOmitted ? ` · ${cohort.timelineOmitted} omitted` : ''}`);
|
|
114
394
|
output.push(` Source review items: ${cohort.sourceAlignment.investigate}`);
|
|
115
395
|
output.push(` Publication review items: ${cohort.publication.reviewItems}`);
|
|
116
396
|
}
|
|
397
|
+
if (report.omissions.duplicateTimelinePoints)
|
|
398
|
+
output.push('', `Duplicate timeline points suppressed: ${report.omissions.duplicateTimelinePoints}`);
|
|
117
399
|
output.push('', 'Limitations:');
|
|
118
400
|
for (const limitation of report.limitations)
|
|
119
401
|
output.push(` - ${limitation}`);
|
package/cli/registry-doctor.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { registryCapabilityFor, REGISTRY_CAPABILITIES_VERSION } from "../lib/registry-capabilities.mjs";
|
|
2
2
|
import { recordOrEmpty } from "../lib/bounded-contract-normalizers.mjs";
|
|
3
|
+
import { registryDateIso } from "../lib/registry-dates.mjs";
|
|
3
4
|
import { CliUsageError } from "./errors.mjs";
|
|
4
5
|
import { parseSavedLookupDocument } from "./saved-lookup.mjs";
|
|
5
6
|
const REGISTRY_DOCTOR_SCHEMA = 'whoisleuth.cli.registry-doctor';
|
|
6
|
-
const REGISTRY_DOCTOR_VERSION =
|
|
7
|
+
const REGISTRY_DOCTOR_VERSION = 3;
|
|
7
8
|
function status(value) {
|
|
8
9
|
return typeof value === 'string'
|
|
9
10
|
? value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim().toLowerCase().slice(0, 40) || 'unavailable'
|
|
@@ -41,27 +42,37 @@ function publicationQuality(parsed, rdapStatus) {
|
|
|
41
42
|
objectClass: unavailable,
|
|
42
43
|
objectIdentifier: unavailable,
|
|
43
44
|
mediaType: unavailable,
|
|
44
|
-
baseConformance: { state: 'unavailable', declarations: [] },
|
|
45
|
+
baseConformance: { state: 'unavailable', declarations: [], truncated: false },
|
|
45
46
|
redactionMetadata: { state: 'unavailable', count: 0, truncated: false },
|
|
46
|
-
selfLink: { state: 'unavailable', observedRelations: [] },
|
|
47
|
-
events: { state: 'unavailable', count: 0, conflictingActions: [] },
|
|
47
|
+
selfLink: { state: 'unavailable', observedRelations: [], truncated: false },
|
|
48
|
+
events: { state: 'unavailable', count: 0, conflictingActions: [], truncated: false },
|
|
48
49
|
reviewItems: 0,
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
52
|
const objectClassName = typeof parsed.objectClassName === 'string' ? parsed.objectClassName.trim().toLowerCase() : '';
|
|
52
53
|
const identifier = [parsed.handle, parsed.objectIdentifier, parsed.registryObjectId, parsed.id]
|
|
53
54
|
.find((value) => typeof value === 'string' && Boolean(value.trim()))?.trim() ?? null;
|
|
54
|
-
const
|
|
55
|
+
const conformanceValues = Array.isArray(parsed.conformance) ? parsed.conformance : [];
|
|
56
|
+
const conformanceTruncated = parsed.conformanceTruncated === true || conformanceValues.length > 100;
|
|
57
|
+
const declarations = stringArray(conformanceValues);
|
|
55
58
|
const redactions = Array.isArray(parsed.redactions) ? parsed.redactions.slice(0, 100) : [];
|
|
56
|
-
const
|
|
59
|
+
const redactionsTruncated = parsed.redactionsTruncated === true
|
|
60
|
+
|| (Array.isArray(parsed.redactions) && parsed.redactions.length > 100);
|
|
61
|
+
const linkValues = Array.isArray(parsed.links) ? parsed.links : [];
|
|
62
|
+
const links = linkValues.slice(0, 100).map(recordOrEmpty);
|
|
63
|
+
const linksTruncated = parsed.linksTruncated === true
|
|
64
|
+
|| linkValues.length > 100
|
|
65
|
+
|| links.some((link) => Array.isArray(link.rel) && link.rel.length > 10);
|
|
57
66
|
const relations = [...new Set(links.flatMap((link) => stringArray(link.rel, 10).length
|
|
58
67
|
? stringArray(link.rel, 10)
|
|
59
68
|
: typeof link.rel === 'string' ? [link.rel.trim().toLowerCase()] : []).filter(Boolean))];
|
|
60
|
-
const
|
|
69
|
+
const eventValues = Array.isArray(parsed.events) ? parsed.events : [];
|
|
70
|
+
const events = eventValues.slice(0, 100).map(recordOrEmpty);
|
|
71
|
+
const eventsTruncated = parsed.eventsTruncated === true || eventValues.length > 100;
|
|
61
72
|
const actionDates = new Map();
|
|
62
73
|
for (const event of events) {
|
|
63
74
|
const action = typeof event.action === 'string' ? event.action.trim().toLowerCase() : '';
|
|
64
|
-
const date =
|
|
75
|
+
const date = registryDateIso(event.date);
|
|
65
76
|
if (!action || !date)
|
|
66
77
|
continue;
|
|
67
78
|
const dates = actionDates.get(action) ?? [];
|
|
@@ -79,8 +90,21 @@ function publicationQuality(parsed, rdapStatus) {
|
|
|
79
90
|
['reinstantiation', 'reinstantiationDateIso', true],
|
|
80
91
|
['last update of rdap database', 'databaseUpdatedDateIso', true],
|
|
81
92
|
];
|
|
93
|
+
const lifecycleDates = new Map();
|
|
94
|
+
let lifecycleMalformed = false;
|
|
95
|
+
for (const [, field] of lifecycleMappings) {
|
|
96
|
+
const supplied = lifecycle[field];
|
|
97
|
+
if (supplied === undefined || supplied === null)
|
|
98
|
+
continue;
|
|
99
|
+
const normalized = registryDateIso(supplied);
|
|
100
|
+
if (normalized)
|
|
101
|
+
lifecycleDates.set(field, normalized);
|
|
102
|
+
else
|
|
103
|
+
lifecycleMalformed = true;
|
|
104
|
+
}
|
|
82
105
|
const conflictingActions = lifecycleMappings.flatMap(([action, field, newest]) => {
|
|
83
|
-
const
|
|
106
|
+
const publishedDate = lifecycleDates.get(field);
|
|
107
|
+
const published = publishedDate ? Date.parse(publishedDate) : Number.NaN;
|
|
84
108
|
if (!Number.isFinite(published))
|
|
85
109
|
return [];
|
|
86
110
|
const dates = (actionDates.get(action) ?? []).map(Date.parse).filter(Number.isFinite);
|
|
@@ -90,30 +114,41 @@ function publicationQuality(parsed, rdapStatus) {
|
|
|
90
114
|
return selected === published ? [] : [action];
|
|
91
115
|
}).sort();
|
|
92
116
|
const objectClassState = !objectClassName ? 'not_observed' : objectClassName === 'domain' ? 'observed' : 'inconsistent';
|
|
93
|
-
const baseConformanceState = declarations.includes('rdap_level_0')
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
? '
|
|
98
|
-
:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
117
|
+
const baseConformanceState = declarations.includes('rdap_level_0')
|
|
118
|
+
? 'observed'
|
|
119
|
+
: conformanceTruncated ? 'unavailable' : 'not_observed';
|
|
120
|
+
const selfLinkState = relations.includes('self')
|
|
121
|
+
? 'observed'
|
|
122
|
+
: linksTruncated ? 'unavailable' : 'not_observed';
|
|
123
|
+
const hasLifecycle = lifecycleDates.size > 0;
|
|
124
|
+
const eventsState = eventsTruncated || lifecycleMalformed
|
|
125
|
+
? 'unavailable'
|
|
126
|
+
: conflictingActions.length
|
|
127
|
+
? 'inconsistent'
|
|
128
|
+
: events.length && hasLifecycle
|
|
129
|
+
? 'observed'
|
|
130
|
+
: events.length
|
|
131
|
+
? 'unavailable'
|
|
132
|
+
: 'not_observed';
|
|
103
133
|
return {
|
|
104
134
|
objectClass: { state: objectClassState, value: objectClassName || null },
|
|
105
135
|
objectIdentifier: { state: identifier ? 'observed' : 'not_observed', value: identifier },
|
|
106
136
|
mediaType: { state: 'unavailable', value: null },
|
|
107
|
-
baseConformance: { state: baseConformanceState, declarations },
|
|
137
|
+
baseConformance: { state: baseConformanceState, declarations, truncated: conformanceTruncated },
|
|
108
138
|
redactionMetadata: {
|
|
109
|
-
state: redactions.length ? 'observed' : 'not_observed',
|
|
139
|
+
state: redactions.length ? 'observed' : redactionsTruncated ? 'unavailable' : 'not_observed',
|
|
110
140
|
count: redactions.length,
|
|
111
|
-
truncated:
|
|
141
|
+
truncated: redactionsTruncated,
|
|
142
|
+
},
|
|
143
|
+
selfLink: { state: selfLinkState, observedRelations: relations.sort(), truncated: linksTruncated },
|
|
144
|
+
events: {
|
|
145
|
+
state: eventsState,
|
|
146
|
+
count: events.length,
|
|
147
|
+
conflictingActions: eventsTruncated || lifecycleMalformed ? [] : conflictingActions,
|
|
148
|
+
truncated: eventsTruncated,
|
|
112
149
|
},
|
|
113
|
-
selfLink: { state: selfLinkState, observedRelations: relations.sort() },
|
|
114
|
-
events: { state: eventsState, count: events.length, conflictingActions },
|
|
115
150
|
reviewItems: [objectClassState, baseConformanceState, selfLinkState, eventsState]
|
|
116
|
-
.filter((state) => state
|
|
151
|
+
.filter((state) => state !== 'observed').length,
|
|
117
152
|
};
|
|
118
153
|
}
|
|
119
154
|
function buildRegistryDoctorReport(raw, generatedAt = new Date().toISOString()) {
|
|
@@ -182,6 +217,10 @@ function buildRegistryDoctorReport(raw, generatedAt = new Date().toISOString())
|
|
|
182
217
|
'A catalogue alignment does not prove live reachability, parser completeness, current registry policy, registration, availability, ownership, safety, or maliciousness.',
|
|
183
218
|
'A missing registry object identifier can be a publication characteristic. It remains not observed and is not converted into a collection failure.',
|
|
184
219
|
'Publication-quality observations describe the saved normalised RDAP object. The saved Lookup format does not retain the response media type, so that field remains unavailable.',
|
|
220
|
+
...(publication.baseConformance.truncated || publication.redactionMetadata.truncated
|
|
221
|
+
|| publication.selfLink.truncated || publication.events.truncated
|
|
222
|
+
? ['One or more bounded RDAP publication families were truncated; omitted values remain unavailable and cannot support absence or consistency conclusions.']
|
|
223
|
+
: []),
|
|
185
224
|
'Use fixture-based maintenance checks before changing a registry parser or access profile; automated tests must not contact live registries.',
|
|
186
225
|
],
|
|
187
226
|
};
|
|
@@ -203,9 +242,9 @@ function formatRegistryDoctorReport(report) {
|
|
|
203
242
|
output.push('', 'RDAP publication quality');
|
|
204
243
|
output.push(` Object class: ${report.publication.objectClass.state.replaceAll('_', ' ')}`);
|
|
205
244
|
output.push(` Object identifier: ${report.publication.objectIdentifier.state.replaceAll('_', ' ')}`);
|
|
206
|
-
output.push(` Base conformance: ${report.publication.baseConformance.state.replaceAll('_', ' ')}`);
|
|
207
|
-
output.push(` Self link: ${report.publication.selfLink.state.replaceAll('_', ' ')}`);
|
|
208
|
-
output.push(` Events: ${report.publication.events.state.replaceAll('_', ' ')}`);
|
|
245
|
+
output.push(` Base conformance: ${report.publication.baseConformance.state.replaceAll('_', ' ')}${report.publication.baseConformance.truncated ? ' (partial)' : ''}`);
|
|
246
|
+
output.push(` Self link: ${report.publication.selfLink.state.replaceAll('_', ' ')}${report.publication.selfLink.truncated ? ' (partial)' : ''}`);
|
|
247
|
+
output.push(` Events: ${report.publication.events.state.replaceAll('_', ' ')}${report.publication.events.truncated ? ' (partial)' : ''}`);
|
|
209
248
|
output.push(' Media type: unavailable in the saved Lookup contract');
|
|
210
249
|
if (report.recommendations.length) {
|
|
211
250
|
output.push('', 'Recommendations:');
|
package/cli/registry-support.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { registryStandardsCoverageSnapshot, } from "../lib/registry-capabilities.mjs";
|
|
2
2
|
const REGISTRY_SUPPORT_SCHEMA = 'whoisleuth.cli.registry-support';
|
|
3
|
-
const REGISTRY_SUPPORT_SCHEMA_VERSION =
|
|
3
|
+
const REGISTRY_SUPPORT_SCHEMA_VERSION = 4;
|
|
4
4
|
const REGISTRY_STANDARDS_COVERAGE_SCHEMA = 'whoisleuth.registry-standards-coverage';
|
|
5
5
|
const MAX_REGISTRY_SUPPORT_TEXT_LENGTH = 2048;
|
|
6
6
|
const MAX_REGISTRY_SUPPORT_REFERENCES = 20;
|
|
@@ -88,6 +88,7 @@ function buildRegistrySupportDocument(requestedInput, capability, catalogueVersi
|
|
|
88
88
|
explicitSuffixProfile: capability.explicitSuffixProfile === true,
|
|
89
89
|
registryClass: boundedText(capability.registryClass, 'unknown', 64),
|
|
90
90
|
coverageState: boundedText(capability.coverageState, 'unknown', 64),
|
|
91
|
+
officialLookupUrl: boundedList([capability.officialLookupUrl], 'url')[0] ?? null,
|
|
91
92
|
rdap: {
|
|
92
93
|
discovery: boundedText(capability.rdapDiscovery, 'unknown', 64),
|
|
93
94
|
accessProfile: boundedText(capability.rdapAccessProfile, 'unknown', 128),
|