@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.
Files changed (268) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +74 -124
  4. package/SECURITY.md +6 -3
  5. package/bin/whoisleuth.mjs +7 -4
  6. package/cli/archive-inspect.mjs +28 -10
  7. package/cli/arguments.mjs +450 -173
  8. package/cli/artifact-structure.mjs +2507 -0
  9. package/cli/artifact-verify.mjs +286 -64
  10. package/cli/bounded-json.mjs +1 -0
  11. package/cli/bulk-checkpoint.mjs +35 -23
  12. package/cli/bulk-command-runner.mjs +2 -5
  13. package/cli/bulk.mjs +9 -2
  14. package/cli/case-pack.mjs +435 -46
  15. package/cli/ci-report.mjs +6 -1
  16. package/cli/collection-preflight.mjs +2 -2
  17. package/cli/command-catalogue.mjs +12 -3
  18. package/cli/command-reference.mjs +948 -106
  19. package/cli/completion.mjs +504 -218
  20. package/cli/config-profile.mjs +13 -3
  21. package/cli/ct-event-intake.mjs +32 -2
  22. package/cli/discover.mjs +7 -1
  23. package/cli/discovery-observation-snapshot.mjs +24 -28
  24. package/cli/discovery-scan-command-runner.mjs +13 -14
  25. package/cli/discovery-scan.mjs +17 -3
  26. package/cli/discovery-snapshot.mjs +15 -7
  27. package/cli/discovery-workflow.mjs +2 -3
  28. package/cli/doctor.mjs +2 -2
  29. package/cli/domain-control-monitor.mjs +215 -36
  30. package/cli/domain-control-observations.mjs +34 -17
  31. package/cli/errors.mjs +8 -3
  32. package/cli/evidence-command-runner.mjs +17 -11
  33. package/cli/evidence-signing.mjs +68 -50
  34. package/cli/export-evidence.mjs +11 -4
  35. package/cli/external-observation-mapping.mjs +3 -3
  36. package/cli/formatters/html.mjs +3 -3
  37. package/cli/formatters/json.mjs +20 -32
  38. package/cli/formatters/terminal.mjs +586 -22
  39. package/cli/input.mjs +62 -0
  40. package/cli/interactive-launcher.mjs +226 -0
  41. package/cli/interchange-report.mjs +226 -0
  42. package/cli/investigation-manifest.mjs +16 -17
  43. package/cli/investigation-plan.mjs +167 -16
  44. package/cli/investigation-run.mjs +80 -24
  45. package/cli/local-mmdb-review.mjs +45 -14
  46. package/cli/lookalike-calibration.mjs +3 -1
  47. package/cli/lookup-brief.mjs +13 -8
  48. package/cli/lookup-browser.mjs +844 -0
  49. package/cli/lookup-command-runner.mjs +96 -33
  50. package/cli/lookup-diff.mjs +11 -2
  51. package/cli/lookup-plan.mjs +1 -0
  52. package/cli/mail-review.mjs +206 -94
  53. package/cli/manual.mjs +5 -5
  54. package/cli/network-command-runner.mjs +78 -0
  55. package/cli/offline-evidence-review.mjs +45 -14
  56. package/cli/open-asset-model-bridge.mjs +1 -1
  57. package/cli/output-file.mjs +70 -14
  58. package/cli/page-compare.mjs +18 -10
  59. package/cli/progress-events.mjs +2 -1
  60. package/cli/progress.mjs +3 -3
  61. package/cli/registry-cohort.mjs +343 -61
  62. package/cli/registry-doctor.mjs +67 -28
  63. package/cli/registry-support.mjs +2 -1
  64. package/cli/retained-artifact-diff.mjs +375 -0
  65. package/cli/risk-calibration.mjs +89 -40
  66. package/cli/runner.mjs +322 -246
  67. package/cli/saved-lookup.mjs +439 -49
  68. package/cli/sharing-review.mjs +18 -7
  69. package/cli/source-reliability.mjs +75 -36
  70. package/cli/terminal-presentation.mjs +43 -18
  71. package/docs/cli-reference.md +125 -1334
  72. package/docs/cli.md +87 -236
  73. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
  74. package/frontend/src/lib/analysis/domain-control-records.js +3 -0
  75. package/lib/application-version.mjs +3 -0
  76. package/lib/availability.mjs +72 -52
  77. package/lib/bounded-contract-normalizers.mjs +2 -4
  78. package/lib/bounded-file.mjs +91 -4
  79. package/lib/bounded-json.mjs +287 -0
  80. package/lib/browser-library-profile.mjs +1 -1
  81. package/lib/certificate-extension-profile.mjs +2 -1
  82. package/lib/classify.mjs +56 -5
  83. package/lib/client-behavior-profile.mjs +1 -1
  84. package/lib/credential-surface-profile.mjs +1 -1
  85. package/lib/cryptographic-assurance.mjs +173 -0
  86. package/lib/ct-query.mjs +3 -3
  87. package/lib/ct-search.mjs +120 -64
  88. package/lib/distributed-operation-budget.mjs +4 -2
  89. package/lib/dns-delegation-health.mjs +9 -6
  90. package/lib/dns-intelligence.mjs +4 -3
  91. package/lib/dns-resolver-selection.mjs +6 -4
  92. package/lib/dnssec-chain-validation.mjs +1052 -0
  93. package/lib/dnssec-evidence-validation.mjs +23 -10
  94. package/lib/domain-assurance.mjs +12 -12
  95. package/lib/domain-change-packet.mjs +7 -5
  96. package/lib/domain-control-flight-recorder.mjs +442 -78
  97. package/lib/domain-control-manifest.mjs +314 -171
  98. package/lib/domain-posture-analysis.mjs +5 -2
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/encrypted-dns-contract.mjs +2 -3
  101. package/lib/evidence-export.mjs +1243 -88
  102. package/lib/evidence-report-markdown.mjs +3 -0
  103. package/lib/evidence-report.mjs +5 -0
  104. package/lib/external-intelligence-risk.mjs +26 -10
  105. package/lib/favicon.mjs +8 -5
  106. package/lib/feature-policy.mjs +1 -1
  107. package/lib/homepage-metadata-contract.mjs +290 -0
  108. package/lib/hostname.mjs +1 -1
  109. package/lib/html-signals.mjs +89 -5
  110. package/lib/http-evidence-bounds.mjs +3 -0
  111. package/lib/http-intelligence.mjs +332 -6
  112. package/lib/interchange-fidelity-registry.mjs +124 -0
  113. package/lib/local-geoip-evidence.mjs +2 -1
  114. package/lib/lookup-cache.mjs +6 -4
  115. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  116. package/lib/lookup.mjs +27 -4
  117. package/lib/observed-network-context.mjs +1 -1
  118. package/lib/outbound-identity.mjs +2 -3
  119. package/lib/page-fingerprints.mjs +2 -2
  120. package/lib/page-role-profile.mjs +1 -1
  121. package/lib/perceptual-hash.mjs +12 -4
  122. package/lib/portable-generator.mjs +29 -0
  123. package/lib/provider-policy-admission.mjs +11 -4
  124. package/lib/rdap-bootstrap.mjs +76 -16
  125. package/lib/rdap-client.mjs +20 -12
  126. package/lib/rdap-normalization.mjs +324 -80
  127. package/lib/rdap-registrar.mjs +5 -1
  128. package/lib/rdap-transport.mjs +2 -2
  129. package/lib/rdap-validation.mjs +65 -14
  130. package/lib/rdap.mjs +2 -2
  131. package/lib/registrable-domain.mjs +36 -0
  132. package/lib/registry-capabilities.mjs +3 -0
  133. package/lib/registry-capability-catalogue.mjs +12 -2
  134. package/lib/registry-comparison.mjs +28 -11
  135. package/lib/registry-insights.mjs +7 -0
  136. package/lib/response-policy.mjs +27 -4
  137. package/lib/risk-calibration-summary.mjs +340 -0
  138. package/lib/risk-scoring.mjs +4 -3
  139. package/lib/rpki-evidence.mjs +9 -8
  140. package/lib/safe-fetch.mjs +121 -50
  141. package/lib/security-txt.mjs +27 -4
  142. package/lib/service-binding-dns.mjs +6 -1
  143. package/lib/smtp-transport-review.mjs +787 -0
  144. package/lib/sslbl-certificates.generated.mjs +136 -134
  145. package/lib/sslbl-intelligence.mjs +6 -8
  146. package/lib/static-html-analysis.mjs +301 -4
  147. package/lib/structured-data-identity.mjs +51 -19
  148. package/lib/threat-intelligence-fixtures.mjs +2 -4
  149. package/lib/threat-intelligence-manifests.mjs +2 -7
  150. package/lib/threat-intelligence-runtime.mjs +5 -15
  151. package/lib/threat-intelligence-types.mjs +21 -1
  152. package/lib/threatfox-intelligence.mjs +7 -3
  153. package/lib/tls-intelligence.mjs +15 -9
  154. package/lib/tlsa-evidence.mjs +37 -7
  155. package/lib/urlhaus-intelligence.mjs +4 -2
  156. package/lib/urlscan-intelligence.mjs +6 -5
  157. package/lib/website-security-posture.mjs +1 -1
  158. package/lib/website-technology.mjs +1 -1
  159. package/lib/whois-authority.mjs +43 -8
  160. package/lib/whois-chain.mjs +25 -3
  161. package/lib/whois-normalization.mjs +3 -1
  162. package/lib/whois-transport.mjs +12 -3
  163. package/lib/zone-intent-review.mjs +1 -1
  164. package/package.json +12 -4
  165. package/packages/cases/case-brand-profile-references.mjs +85 -0
  166. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
  167. package/packages/cases/case-investigation-branch-model.mjs +132 -0
  168. package/packages/cases/case-migration-model.mjs +476 -0
  169. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  170. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
  171. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  172. package/packages/cases/case-record-model.mjs +9 -0
  173. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
  174. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
  175. package/packages/cases/case-response-model.mjs +1604 -0
  176. package/packages/cases/case-response-packet.mjs +1312 -0
  177. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  178. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  179. package/packages/cases/opaque-reference-id.mjs +9 -0
  180. package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
  181. package/packages/comparison/comparison-ledger-contract.mjs +241 -0
  182. package/packages/comparison/comparison-ledger-json.mjs +80 -0
  183. package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
  184. package/packages/comparison/favicon-similarity.mjs +78 -0
  185. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  186. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  187. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  188. package/packages/contracts/analyst-review-state.mjs +50 -0
  189. package/packages/contracts/case-portability.mjs +1310 -0
  190. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  191. package/packages/contracts/cli-lookup.mjs +394 -0
  192. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  193. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  194. package/packages/contracts/domain-control-monitor.mjs +310 -0
  195. package/packages/contracts/domain-control-review.mjs +828 -0
  196. package/packages/contracts/domain-name.mjs +4 -0
  197. package/packages/contracts/external-observation-interchange.mjs +224 -0
  198. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  199. package/packages/contracts/investigation-portability.mjs +331 -0
  200. package/packages/contracts/offline-comparison.mjs +160 -0
  201. package/packages/contracts/risk-calibration.mjs +1288 -0
  202. package/packages/contracts/schema-compatibility.mjs +71 -0
  203. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  204. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  205. package/packages/contracts/workspace-portability.mjs +1347 -0
  206. package/packages/evidence/artifact-integrity.mjs +65 -0
  207. package/packages/evidence/decision-fact.mjs +735 -0
  208. package/packages/evidence/domain-control-runtime.mjs +547 -0
  209. package/packages/evidence/domain-name.mjs +41 -0
  210. package/packages/evidence/observation.mjs +384 -0
  211. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
  212. package/packages/interchange/retained-text.mjs +8 -0
  213. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
  214. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
  215. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
  216. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
  217. package/packages/investigation/bulk-review-policy.mjs +1 -0
  218. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
  219. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  220. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  221. package/packages/investigation/lookup-claim-passport.mjs +232 -0
  222. package/packages/investigation/lookup-claim-readiness.mjs +287 -0
  223. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  224. package/packages/monitoring/analyst-review-state.mjs +680 -0
  225. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
  226. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
  227. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
  228. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
  229. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  230. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  231. package/packages/workspace/hostile-input.mjs +172 -0
  232. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
  233. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
  234. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  235. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  236. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  237. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  238. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  239. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
  240. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  241. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
  242. package/third-party-notices.txt +112 -5834
  243. package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
  244. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  245. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  246. package/frontend/src/lib/analysis/case-migration-model.js +0 -253
  247. package/frontend/src/lib/analysis/case-record-model.js +0 -7
  248. package/frontend/src/lib/analysis/case-response-model.js +0 -669
  249. package/frontend/src/lib/analysis/case-response-packet.js +0 -573
  250. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
  251. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  252. package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
  253. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  254. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  255. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  256. package/frontend/src/lib/analysis/scoring.js +0 -55
  257. package/frontend/src/lib/analysis/utils.js +0 -232
  258. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  259. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  260. package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
  261. package/frontend/src/lib/analysis/visualization-models.js +0 -369
  262. package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
  263. package/lib/bounded-json-response.mjs +0 -157
  264. package/lib/lookup-response-contract.mjs +0 -346
  265. package/lib/observation.mjs +0 -114
  266. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  267. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  268. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -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 = 1;
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
- function record(value) {
10
- return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
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 documents(text) {
13
- if (Buffer.byteLength(text, 'utf8') > MAX_REGISTRY_COHORT_INPUT_BYTES) {
14
- throw new CliUsageError(`Registry cohort input is limited to ${MAX_REGISTRY_COHORT_INPUT_BYTES} bytes.`);
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
- parsed = JSON.parse(trimmed);
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
- throw new CliUsageError('Registry cohort input must be valid JSON or JSONL.');
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
- export function buildRegistryCohortReport(text, generatedAt = new Date().toISOString()) {
52
- const reports = documents(text).map((item) => buildRegistryDoctorReport(JSON.stringify(item), generatedAt));
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
- const group = groups.get(key) ?? [];
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
- if (!sample)
63
- throw new CliUsageError('Registry cohort grouping failed.');
64
- const sampleCount = group.length;
65
- const investigate = group.reduce((total, item) => total + item.summary.investigate, 0);
66
- const publicationReviewItems = group.reduce((total, item) => total + item.publication.reviewItems, 0);
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: sample.suffix,
69
- profileId: sample.profile.id,
70
- sampleCount,
71
- state: sampleCount < MIN_REGISTRY_COHORT_SAMPLE
72
- ? 'insufficient_sample'
73
- : investigate || publicationReviewItems
74
- ? 'review'
75
- : 'consistent',
76
- sourceAlignment: Object.freeze({
77
- investigate,
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
- sampleCount: reports.length,
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
- limitations: Object.freeze([
98
- 'The report intentionally omits domains, queries and raw evidence. It groups saved observations only by suffix and capability profile.',
99
- `Cohorts with fewer than ${MIN_REGISTRY_COHORT_SAMPLE} observations remain insufficient sample and must not drive catalogue changes.`,
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
- `Saved observations ${report.sampleCount}`,
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(` Samples: ${cohort.sampleCount}`);
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}`);
@@ -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 = 2;
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 declarations = stringArray(parsed.conformance);
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 links = Array.isArray(parsed.links) ? parsed.links.slice(0, 100).map(recordOrEmpty) : [];
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 events = Array.isArray(parsed.events) ? parsed.events.slice(0, 100).map(recordOrEmpty) : [];
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 = typeof event.date === 'string' ? event.date.trim() : '';
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 published = typeof lifecycle[field] === 'string' ? Date.parse(lifecycle[field]) : Number.NaN;
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') ? 'observed' : 'not_observed';
94
- const selfLinkState = relations.includes('self') ? 'observed' : 'not_observed';
95
- const hasLifecycle = lifecycleMappings.some(([, field]) => typeof lifecycle[field] === 'string');
96
- const eventsState = conflictingActions.length
97
- ? 'inconsistent'
98
- : events.length && hasLifecycle
99
- ? 'observed'
100
- : events.length
101
- ? 'unavailable'
102
- : 'not_observed';
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: parsed.redactionsTruncated === true,
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 === 'inconsistent' || state === 'not_observed').length,
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:');
@@ -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;
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),