@slicedearth/whoisleuth-cli 1.47.4 → 2.0.1

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 (236) hide show
  1. package/README.md +74 -177
  2. package/SECURITY.md +6 -3
  3. package/bin/whoisleuth.mjs +2 -1
  4. package/cli/archive-inspect.mjs +28 -10
  5. package/cli/arguments.mjs +204 -174
  6. package/cli/artifact-structure.mjs +837 -163
  7. package/cli/artifact-verify.mjs +90 -61
  8. package/cli/bounded-json.mjs +1 -1
  9. package/cli/bulk-checkpoint.mjs +33 -20
  10. package/cli/bulk.mjs +9 -2
  11. package/cli/case-pack.mjs +180 -576
  12. package/cli/ci-report.mjs +6 -1
  13. package/cli/collection-preflight.mjs +2 -2
  14. package/cli/command-catalogue.mjs +12 -3
  15. package/cli/command-reference.mjs +910 -92
  16. package/cli/completion.mjs +504 -226
  17. package/cli/config-profile.mjs +13 -3
  18. package/cli/discover.mjs +7 -1
  19. package/cli/discovery-observation-snapshot.mjs +24 -28
  20. package/cli/discovery-scan-command-runner.mjs +9 -9
  21. package/cli/discovery-scan.mjs +17 -3
  22. package/cli/discovery-snapshot.mjs +15 -7
  23. package/cli/domain-control-monitor.mjs +203 -30
  24. package/cli/domain-control-observations.mjs +15 -21
  25. package/cli/errors.mjs +8 -3
  26. package/cli/evidence-signing.mjs +10 -20
  27. package/cli/external-observation-mapping.mjs +3 -3
  28. package/cli/formatters/json.mjs +20 -32
  29. package/cli/formatters/terminal.mjs +583 -22
  30. package/cli/input.mjs +7 -2
  31. package/cli/interactive-launcher.mjs +226 -0
  32. package/cli/interchange-report.mjs +19 -19
  33. package/cli/investigation-manifest.mjs +1 -1
  34. package/cli/investigation-plan.mjs +167 -16
  35. package/cli/investigation-run.mjs +9 -1
  36. package/cli/lookalike-calibration.mjs +3 -1
  37. package/cli/lookup-brief.mjs +13 -8
  38. package/cli/lookup-browser.mjs +844 -0
  39. package/cli/lookup-command-runner.mjs +94 -31
  40. package/cli/lookup-diff.mjs +2 -2
  41. package/cli/lookup-plan.mjs +1 -0
  42. package/cli/mail-review.mjs +206 -94
  43. package/cli/manual.mjs +5 -5
  44. package/cli/offline-evidence-review.mjs +18 -7
  45. package/cli/open-asset-model-bridge.mjs +1 -1
  46. package/cli/output-file.mjs +70 -14
  47. package/cli/page-compare.mjs +11 -6
  48. package/cli/progress-events.mjs +2 -1
  49. package/cli/progress.mjs +3 -3
  50. package/cli/registry-cohort.mjs +9 -51
  51. package/cli/registry-doctor.mjs +67 -28
  52. package/cli/retained-artifact-diff.mjs +7 -8
  53. package/cli/risk-calibration.mjs +85 -38
  54. package/cli/runner.mjs +189 -75
  55. package/cli/saved-lookup.mjs +424 -57
  56. package/cli/sharing-review.mjs +6 -1
  57. package/cli/source-reliability.mjs +75 -36
  58. package/cli/terminal-presentation.mjs +43 -18
  59. package/docs/cli-reference.md +125 -1708
  60. package/docs/cli.md +86 -254
  61. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
  62. package/frontend/src/lib/analysis/domain-control-records.js +3 -88
  63. package/lib/availability.mjs +58 -40
  64. package/lib/bounded-contract-normalizers.mjs +2 -4
  65. package/lib/bounded-file.mjs +78 -4
  66. package/lib/bounded-json.mjs +51 -13
  67. package/lib/browser-library-profile.mjs +1 -1
  68. package/lib/classify.mjs +56 -5
  69. package/lib/client-behavior-profile.mjs +1 -1
  70. package/lib/credential-surface-profile.mjs +1 -1
  71. package/lib/cryptographic-assurance.mjs +35 -17
  72. package/lib/ct-query.mjs +3 -3
  73. package/lib/ct-search.mjs +37 -21
  74. package/lib/distributed-operation-budget.mjs +4 -2
  75. package/lib/dns-delegation-health.mjs +9 -6
  76. package/lib/dns-intelligence.mjs +1 -1
  77. package/lib/dnssec-chain-validation.mjs +2 -3
  78. package/lib/dnssec-evidence-validation.mjs +13 -7
  79. package/lib/domain-assurance.mjs +12 -12
  80. package/lib/domain-change-packet.mjs +1 -1
  81. package/lib/domain-control-flight-recorder.mjs +442 -78
  82. package/lib/domain-control-manifest.mjs +314 -59
  83. package/lib/domain-posture-analysis.mjs +5 -2
  84. package/lib/encrypted-dns-contract.mjs +2 -3
  85. package/lib/evidence-export.mjs +518 -36
  86. package/lib/external-intelligence-risk.mjs +26 -10
  87. package/lib/feature-policy.mjs +1 -1
  88. package/lib/homepage-metadata-contract.mjs +290 -0
  89. package/lib/hostname.mjs +1 -1
  90. package/lib/html-signals.mjs +89 -5
  91. package/lib/http-intelligence.mjs +331 -2
  92. package/lib/interchange-fidelity-registry.mjs +28 -26
  93. package/lib/lookup-cache.mjs +6 -4
  94. package/lib/lookup.mjs +25 -2
  95. package/lib/observed-network-context.mjs +1 -1
  96. package/lib/page-fingerprints.mjs +2 -2
  97. package/lib/page-role-profile.mjs +1 -1
  98. package/lib/portable-generator.mjs +8 -0
  99. package/lib/project-metadata.mjs +1 -1
  100. package/lib/provider-policy-admission.mjs +3 -1
  101. package/lib/rdap-bootstrap.mjs +76 -16
  102. package/lib/rdap-client.mjs +20 -12
  103. package/lib/rdap-normalization.mjs +306 -75
  104. package/lib/rdap-transport.mjs +2 -2
  105. package/lib/rdap-validation.mjs +65 -14
  106. package/lib/rdap.mjs +2 -2
  107. package/lib/registrable-domain.mjs +36 -0
  108. package/lib/registry-insights.mjs +7 -0
  109. package/lib/response-policy.mjs +27 -4
  110. package/lib/risk-calibration-summary.mjs +35 -33
  111. package/lib/risk-scoring.mjs +4 -3
  112. package/lib/safe-fetch.mjs +62 -40
  113. package/lib/security-txt.mjs +1 -1
  114. package/lib/smtp-transport-review.mjs +8 -5
  115. package/lib/sslbl-intelligence.mjs +6 -8
  116. package/lib/static-html-analysis.mjs +301 -4
  117. package/lib/structured-data-identity.mjs +51 -19
  118. package/lib/threat-intelligence-fixtures.mjs +2 -4
  119. package/lib/threat-intelligence-manifests.mjs +2 -7
  120. package/lib/threat-intelligence-runtime.mjs +3 -5
  121. package/lib/threat-intelligence-types.mjs +3 -0
  122. package/lib/threatfox-intelligence.mjs +7 -3
  123. package/lib/tls-intelligence.mjs +4 -4
  124. package/lib/urlhaus-intelligence.mjs +4 -2
  125. package/lib/urlscan-intelligence.mjs +6 -5
  126. package/lib/website-security-posture.mjs +1 -1
  127. package/lib/website-technology.mjs +1 -1
  128. package/lib/whois-authority.mjs +43 -8
  129. package/lib/whois-chain.mjs +25 -3
  130. package/lib/whois-normalization.mjs +3 -1
  131. package/lib/whois-transport.mjs +12 -3
  132. package/lib/zone-intent-review.mjs +1 -1
  133. package/package.json +13 -5
  134. package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
  135. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
  136. package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
  137. package/packages/cases/case-migration-model.mjs +476 -0
  138. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  139. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
  140. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  141. package/packages/cases/case-record-model.mjs +9 -0
  142. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
  143. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
  144. package/packages/cases/case-response-model.mjs +1604 -0
  145. package/packages/cases/case-response-packet.mjs +1312 -0
  146. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  147. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  148. package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
  149. package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
  150. package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
  151. package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
  152. package/packages/comparison/favicon-similarity.mjs +78 -0
  153. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  154. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  155. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  156. package/packages/contracts/analyst-review-state.mjs +50 -0
  157. package/packages/contracts/case-portability.mjs +1310 -0
  158. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  159. package/packages/contracts/cli-lookup.mjs +394 -0
  160. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  161. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  162. package/packages/contracts/domain-control-monitor.mjs +310 -0
  163. package/packages/contracts/domain-control-review.mjs +828 -0
  164. package/packages/contracts/domain-name.mjs +4 -0
  165. package/packages/contracts/external-observation-interchange.mjs +224 -0
  166. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  167. package/packages/contracts/investigation-portability.mjs +331 -0
  168. package/packages/contracts/offline-comparison.mjs +160 -0
  169. package/packages/contracts/risk-calibration.mjs +1288 -0
  170. package/packages/contracts/schema-compatibility.mjs +71 -0
  171. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  172. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  173. package/packages/contracts/workspace-portability.mjs +1347 -0
  174. package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
  175. package/packages/evidence/decision-fact.mjs +735 -0
  176. package/packages/evidence/domain-control-runtime.mjs +547 -0
  177. package/packages/evidence/domain-name.mjs +41 -0
  178. package/packages/evidence/observation.mjs +384 -0
  179. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
  180. package/packages/interchange/retained-text.mjs +8 -0
  181. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
  182. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
  183. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
  184. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
  185. package/packages/investigation/bulk-review-policy.mjs +1 -0
  186. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
  187. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  188. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  189. package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
  190. package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
  191. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  192. package/packages/monitoring/analyst-review-state.mjs +680 -0
  193. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +71 -48
  194. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
  195. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
  196. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
  197. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  198. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  199. package/packages/workspace/hostile-input.mjs +172 -0
  200. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
  201. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
  202. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  203. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  204. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  205. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  206. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  207. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
  208. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  209. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
  210. package/third-party-notices.txt +14 -14
  211. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  212. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  213. package/frontend/src/lib/analysis/case-migration-model.js +0 -305
  214. package/frontend/src/lib/analysis/case-record-model.js +0 -9
  215. package/frontend/src/lib/analysis/case-response-model.js +0 -728
  216. package/frontend/src/lib/analysis/case-response-packet.js +0 -583
  217. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
  218. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  219. package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
  220. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  221. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  222. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  223. package/frontend/src/lib/analysis/scoring.js +0 -55
  224. package/frontend/src/lib/analysis/utils.js +0 -255
  225. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  226. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  227. package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
  228. package/frontend/src/lib/analysis/visualization-models.js +0 -338
  229. package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
  230. package/lib/bounded-json-response.mjs +0 -170
  231. package/lib/lookup-response-contract.mjs +0 -1034
  232. package/lib/observation.mjs +0 -115
  233. /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
  234. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  235. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  236. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -1,4 +1,5 @@
1
1
  import { Buffer } from 'node:buffer';
2
+ import { scanBoundedJson } from "../lib/bounded-json.mjs";
2
3
  import { normalizeEncryptedDnsAdapter, planEncryptedDnsQuery, } from "../lib/encrypted-dns-contract.mjs";
3
4
  import { validateDnssecEvidence } from "../lib/dnssec-evidence-validation.mjs";
4
5
  import { reviewDomainChange } from "../lib/domain-change-review.mjs";
@@ -13,6 +14,7 @@ import { reviewZoneIntent } from "../lib/zone-intent-review.mjs";
13
14
  import { reviewDnsConvergence } from "../lib/dns-convergence-review.mjs";
14
15
  import { compareTrustStoreEvidence } from "../lib/trust-store-comparison.mjs";
15
16
  import { CliUsageError } from "./errors.mjs";
17
+ import { safeTerminalValue } from "./formatters/terminal.mjs";
16
18
  import { LOCAL_MMDB_QUERY_SCHEMA, reviewLocalMmdb } from "./local-mmdb-review.mjs";
17
19
  const OFFLINE_EVIDENCE_REVIEW_SCHEMA = 'whoisleuth.cli.offline-evidence-review';
18
20
  const OFFLINE_EVIDENCE_REVIEW_VERSION = 1;
@@ -31,12 +33,14 @@ function parseInput(value) {
31
33
  if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > MAX_OFFLINE_EVIDENCE_INPUT_BYTES) {
32
34
  throw new CliUsageError(`Offline evidence input is limited to ${MAX_OFFLINE_EVIDENCE_INPUT_BYTES} bytes.`);
33
35
  }
36
+ const normalized = value.replace(/^\uFEFF/u, '');
34
37
  let parsed;
35
38
  try {
36
- parsed = JSON.parse(value.replace(/^\uFEFF/u, ''));
39
+ scanBoundedJson(normalized);
40
+ parsed = JSON.parse(normalized);
37
41
  }
38
42
  catch {
39
- throw new CliUsageError('Offline evidence review requires one valid JSON document.');
43
+ throw new CliUsageError('Offline evidence review requires one valid bounded JSON document without duplicate keys.');
40
44
  }
41
45
  const document = record(parsed);
42
46
  if (document.version !== 1 || typeof document.schema !== 'string') {
@@ -81,8 +85,8 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
81
85
  kind = 'tlsa';
82
86
  result = analyzeTlsaEvidence({
83
87
  serviceName: input.serviceName,
84
- dnssecState: input.dnssecState,
85
- pkixValidationState: input.pkixValidationState,
88
+ dnssecState: 'unavailable',
89
+ pkixValidationState: 'unavailable',
86
90
  records: input.records,
87
91
  certificateDerBase64: input.certificateDerBase64,
88
92
  spkiDerBase64: input.spkiDerBase64,
@@ -237,7 +241,14 @@ function formatOfflineEvidenceReview(document) {
237
241
  const cards = Array.isArray(result.cards) ? result.cards : [];
238
242
  for (const value of cards) {
239
243
  const card = record(value);
240
- lines.push(`${String(card.label ?? card.family ?? 'Evidence')} ${String(card.state ?? 'unavailable').replaceAll('_', ' ')} (${String(card.completeness ?? 'unavailable')})`);
244
+ lines.push(`${safeTerminalValue(card.label ?? card.family, 'Evidence')} ${safeTerminalValue(card.state, 'unavailable').replaceAll('_', ' ')} (${safeTerminalValue(card.completeness, 'unavailable')})`);
245
+ lines.push(` Source ${safeTerminalValue(card.source, 'not supplied')}`);
246
+ lines.push(` Authority ${safeTerminalValue(card.authority, 'not supplied')}`);
247
+ const cardLimitations = Array.isArray(card.limitations) ? card.limitations : [];
248
+ for (const limitation of cardLimitations) {
249
+ if (typeof limitation === 'string')
250
+ lines.push(` Limitation: ${safeTerminalValue(limitation)}`);
251
+ }
241
252
  }
242
253
  }
243
254
  else {
@@ -258,7 +269,7 @@ function formatOfflineEvidenceReview(document) {
258
269
  lines.push('', 'Review reasons:');
259
270
  for (const reason of reasons.slice(0, 8)) {
260
271
  if (typeof reason === 'string')
261
- lines.push(` - ${reason.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim().slice(0, 240)}`);
272
+ lines.push(` - ${safeTerminalValue(reason)}`);
262
273
  }
263
274
  if (reasons.length > 8)
264
275
  lines.push(` - ${reasons.length - 8} more reason(s) omitted.`);
@@ -269,7 +280,7 @@ function formatOfflineEvidenceReview(document) {
269
280
  const nestedLimitations = Array.isArray(result.limitations) ? result.limitations : [];
270
281
  for (const limitation of nestedLimitations) {
271
282
  if (typeof limitation === 'string')
272
- lines.push(` - ${limitation}`);
283
+ lines.push(` - ${safeTerminalValue(limitation)}`);
273
284
  }
274
285
  return `${lines.join('\n')}\n`;
275
286
  }
@@ -1,5 +1,5 @@
1
1
  import { isIP } from 'node:net';
2
- import { parseExternalFindingsDocument, } from "../frontend/src/lib/analysis/external-findings-import.js";
2
+ import { parseExternalFindingsDocument, } from "../packages/interchange/external-findings-import.mjs";
3
3
  import { requireIsoTimestamp } from "../lib/bounded-contract-normalizers.mjs";
4
4
  export const OPEN_ASSET_MODEL_BRIDGE_SCHEMA = 'whoisleuth.open-asset-model-bridge';
5
5
  export const OPEN_ASSET_MODEL_BRIDGE_VERSION = 1;
@@ -5,7 +5,14 @@ import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { CliUsageError } from "./errors.mjs";
6
6
  export const MAX_CLI_OUTPUT_BYTES = 32 * 1024 * 1024;
7
7
  const MAX_CLI_OUTPUT_PATH_LENGTH = 4096;
8
- const pendingOutputFiles = new Set();
8
+ const pendingOutputFiles = new Map();
9
+ const OUTPUT_FILE_OPERATIONS = Object.freeze({
10
+ randomUUID,
11
+ open,
12
+ link,
13
+ rename,
14
+ unlink,
15
+ });
9
16
  function safeOutputPath(value) {
10
17
  if (typeof value !== 'string' || !value || value.length > MAX_CLI_OUTPUT_PATH_LENGTH) {
11
18
  throw new CliUsageError(`Output paths are limited to ${MAX_CLI_OUTPUT_PATH_LENGTH} characters.`);
@@ -35,18 +42,22 @@ function createBufferedOutput() {
35
42
  value: () => chunks.join(''),
36
43
  });
37
44
  }
38
- async function writePrivateFile(pathValue, content, options = {}) {
45
+ async function writePrivateFile(pathValue, content, options = {}, operations = OUTPUT_FILE_OPERATIONS) {
39
46
  const target = safeOutputPath(pathValue);
40
47
  if (Buffer.byteLength(content, 'utf8') > MAX_CLI_OUTPUT_BYTES) {
41
48
  throw new CliUsageError(`Generated output is limited to ${MAX_CLI_OUTPUT_BYTES} bytes.`);
42
49
  }
43
50
  const directory = dirname(target);
44
- const temporary = join(directory, `.${basename(target)}.${process.pid}.${randomUUID()}.tmp`);
51
+ const temporary = join(directory, `.${basename(target)}.${process.pid}.${operations.randomUUID()}.tmp`);
45
52
  let temporaryCreated = false;
46
53
  try {
47
- const handle = await open(temporary, 'wx', 0o600);
54
+ const handle = await operations.open(temporary, 'wx', 0o600);
48
55
  temporaryCreated = true;
49
- pendingOutputFiles.add(temporary);
56
+ pendingOutputFiles.set(temporary, {
57
+ remove: () => operations.unlink(temporary),
58
+ published: false,
59
+ settled: false,
60
+ });
50
61
  try {
51
62
  await handle.writeFile(content, { encoding: 'utf8' });
52
63
  await handle.sync();
@@ -55,12 +66,12 @@ async function writePrivateFile(pathValue, content, options = {}) {
55
66
  await handle.close();
56
67
  }
57
68
  if (options.force) {
58
- await rename(temporary, target);
69
+ await operations.rename(temporary, target);
59
70
  temporaryCreated = false;
60
71
  }
61
72
  else {
62
73
  try {
63
- await link(temporary, target);
74
+ await operations.link(temporary, target);
64
75
  }
65
76
  catch (error) {
66
77
  const code = error && typeof error === 'object' && 'code' in error ? error.code : null;
@@ -69,19 +80,64 @@ async function writePrivateFile(pathValue, content, options = {}) {
69
80
  }
70
81
  throw error;
71
82
  }
72
- await unlink(temporary);
73
- temporaryCreated = false;
83
+ const pending = pendingOutputFiles.get(temporary);
84
+ if (pending)
85
+ pending.published = true;
86
+ try {
87
+ await operations.unlink(temporary);
88
+ temporaryCreated = false;
89
+ }
90
+ catch {
91
+ // The target is already published. A second best-effort cleanup runs
92
+ // below, and signal cleanup retains ownership if it still cannot run.
93
+ }
74
94
  }
75
95
  return target;
76
96
  }
77
97
  finally {
78
- if (temporaryCreated)
79
- await unlink(temporary).catch(() => { });
80
- pendingOutputFiles.delete(temporary);
98
+ if (temporaryCreated) {
99
+ try {
100
+ await operations.unlink(temporary);
101
+ temporaryCreated = false;
102
+ }
103
+ catch {
104
+ // Keep the path registered for best-effort signal cleanup.
105
+ }
106
+ }
107
+ if (temporaryCreated) {
108
+ const pending = pendingOutputFiles.get(temporary);
109
+ if (pending)
110
+ pending.settled = true;
111
+ }
112
+ if (!temporaryCreated)
113
+ pendingOutputFiles.delete(temporary);
81
114
  }
82
115
  }
116
+ async function cleanupPendingOutputFiles() {
117
+ const entries = [...pendingOutputFiles].filter(([, pending]) => pending.settled);
118
+ let removed = 0;
119
+ for (const [path, pending] of entries) {
120
+ try {
121
+ await pending.remove();
122
+ pendingOutputFiles.delete(path);
123
+ removed += 1;
124
+ }
125
+ catch {
126
+ // The caller receives an explicit retained count and can disclose that
127
+ // the published output is intact while temporary cleanup remains due.
128
+ }
129
+ }
130
+ const retainedFiles = [...pendingOutputFiles.values()].filter((item) => item.settled);
131
+ return Object.freeze({
132
+ attempted: entries.length,
133
+ removed,
134
+ retained: retainedFiles.length,
135
+ retainedPublished: retainedFiles.filter((item) => item.published).length,
136
+ retainedUnpublished: retainedFiles.filter((item) => !item.published).length,
137
+ });
138
+ }
83
139
  function cleanupPendingOutputFilesSync() {
84
- for (const path of pendingOutputFiles) {
140
+ for (const path of pendingOutputFiles.keys()) {
85
141
  try {
86
142
  unlinkSync(path);
87
143
  }
@@ -94,4 +150,4 @@ function cleanupPendingOutputFilesSync() {
94
150
  }
95
151
  }
96
152
  }
97
- export { cleanupPendingOutputFilesSync, createBufferedOutput, writePrivateFile };
153
+ export { cleanupPendingOutputFiles, cleanupPendingOutputFilesSync, createBufferedOutput, writePrivateFile };
@@ -1,10 +1,10 @@
1
1
  import { buildBulkComparisonEvidence } from "../lib/bulk-comparison-evidence.mjs";
2
- import { createPageBaseline } from "../frontend/src/lib/analysis/page-baseline.js";
3
- import { comparePageBaselines } from "../frontend/src/lib/analysis/page-similarity.js";
2
+ import { createPageBaseline } from "../packages/workspace/page-baseline.mjs";
3
+ import { comparePageBaselines } from "../packages/comparison/page-similarity.mjs";
4
4
  import { CliUsageError } from "./errors.mjs";
5
5
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
6
- export const CLI_PAGE_COMPARE_SCHEMA = 'whoisleuth.cli.page-compare';
7
- export const CLI_PAGE_COMPARE_VERSION = 3;
6
+ import { CLI_PAGE_COMPARE_SCHEMA, CLI_PAGE_COMPARE_VERSION, } from "../packages/contracts/offline-comparison.mjs";
7
+ export { CLI_PAGE_COMPARE_SCHEMA, CLI_PAGE_COMPARE_VERSION };
8
8
  function record(value) {
9
9
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
10
10
  }
@@ -56,6 +56,7 @@ function buildCliPageComparison(leftText, rightText, generatedAt = new Date().to
56
56
  && ['success', 'partial'].includes(right.compact.technology.state);
57
57
  const tlsUsable = ['success', 'partial'].includes(left.compact.tls.state)
58
58
  && ['success', 'partial'].includes(right.compact.tls.state);
59
+ const tlsPartial = left.compact.tls.state === 'partial' || right.compact.tls.state === 'partial';
59
60
  const technologyPartial = left.compact.technology.truncated || right.compact.technology.truncated
60
61
  || left.compact.technology.state === 'partial' || right.compact.technology.state === 'partial';
61
62
  return {
@@ -88,12 +89,16 @@ function buildCliPageComparison(leftText, rightText, generatedAt = new Date().to
88
89
  leftSourceState: left.compact.tls.state,
89
90
  rightSourceState: right.compact.tls.state,
90
91
  issuer: {
91
- state: tlsUsable ? scalarComparison(left.compact.tls.issuerLabel, right.compact.tls.issuerLabel) : 'unavailable',
92
+ state: tlsUsable
93
+ ? tlsPartial ? 'partial' : scalarComparison(left.compact.tls.issuerLabel, right.compact.tls.issuerLabel)
94
+ : 'unavailable',
92
95
  left: left.compact.tls.issuerLabel,
93
96
  right: right.compact.tls.issuerLabel,
94
97
  },
95
98
  publicKey: {
96
- state: tlsUsable ? scalarComparison(left.compact.tls.spkiSha256, right.compact.tls.spkiSha256) : 'unavailable',
99
+ state: tlsUsable
100
+ ? tlsPartial ? 'partial' : scalarComparison(left.compact.tls.spkiSha256, right.compact.tls.spkiSha256)
101
+ : 'unavailable',
97
102
  leftSha256: left.compact.tls.spkiSha256,
98
103
  rightSha256: right.compact.tls.spkiSha256,
99
104
  },
@@ -1,3 +1,4 @@
1
+ import { terminalSafeJson } from "./formatters/json.mjs";
1
2
  export const CLI_PROGRESS_EVENT_SCHEMA = 'whoisleuth.cli.progress';
2
3
  export const CLI_PROGRESS_EVENT_VERSION = 1;
3
4
  const MAX_CLI_PROGRESS_EVENTS = 2_100;
@@ -45,7 +46,7 @@ function createCliProgressEvents(stream, options) {
45
46
  ...(typeof fields.ok === 'boolean' ? { ok: fields.ok } : {}),
46
47
  ...(exitCode !== null ? { exitCode } : {}),
47
48
  };
48
- stream.write(`${JSON.stringify(document)}\n`);
49
+ stream.write(`${terminalSafeJson(document)}\n`);
49
50
  sequence += 1;
50
51
  },
51
52
  });
package/cli/progress.mjs CHANGED
@@ -10,7 +10,7 @@ function safeProgressMessage(value) {
10
10
  }
11
11
  function createTerminalProgress(stream, options) {
12
12
  const environment = options.environment || process.env;
13
- const presentation = terminalPresentation(stream, options.color, environment);
13
+ const presentation = terminalPresentation(stream, options.color, environment, options.palette);
14
14
  const enabled = options.enabled
15
15
  && presentation.interactive
16
16
  && environment.WHOISLEUTH_NO_PROGRESS !== '1';
@@ -23,8 +23,8 @@ function createTerminalProgress(stream, options) {
23
23
  if (!enabled || !stream)
24
24
  return;
25
25
  const elapsedSeconds = Math.max(0, Math.floor((now() - startedAt) / 1000));
26
- const marker = tone(PROGRESS_FRAMES[frame % PROGRESS_FRAMES.length] || '·', 'accent', presentation.color);
27
- const elapsed = tone(`${elapsedSeconds}s`, 'dim', presentation.color);
26
+ const marker = tone(PROGRESS_FRAMES[frame % PROGRESS_FRAMES.length] || '·', 'accent', presentation.color ? presentation.palette : false);
27
+ const elapsed = tone(`${elapsedSeconds}s`, 'dim', presentation.color ? presentation.palette : false);
28
28
  stream.write(`\r\u001b[2K${marker} ${message} · ${elapsed}`);
29
29
  frame += 1;
30
30
  }
@@ -1,11 +1,11 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { canonicalArtifactJsonV2 } from "../frontend/src/lib/analysis/artifact-integrity.js";
2
+ import { canonicalArtifactJsonV2 } from "../packages/evidence/artifact-integrity.mjs";
3
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
3
4
  import { scanBoundedJson } from "./bounded-json.mjs";
4
5
  import { CliUsageError } from "./errors.mjs";
5
6
  import { buildRegistryDoctorReport } from "./registry-doctor.mjs";
6
7
  import { SAVED_LOOKUP_SCHEMA, parseSavedLookupDocument, } from "./saved-lookup.mjs";
7
8
  export const REGISTRY_COHORT_SCHEMA = 'whoisleuth.cli.registry-cohort';
8
- export const LEGACY_REGISTRY_COHORT_VERSION = 1;
9
9
  export const REGISTRY_COHORT_VERSION = 2;
10
10
  export const MAX_REGISTRY_COHORT_INPUT_BYTES = 16 * 1024 * 1024;
11
11
  export const MAX_REGISTRY_COHORT_SAMPLES = 500;
@@ -14,8 +14,6 @@ export const MAX_REGISTRY_COHORT_INPUT_POINTS = 2_000;
14
14
  export const MAX_REGISTRY_COHORT_TIMELINE_POINTS = 50;
15
15
  export const MAX_REGISTRY_COHORT_OMISSIONS = 1_000_000;
16
16
  export const MIN_REGISTRY_COHORT_SAMPLE = 5;
17
- const LEGACY_ROOT_KEYS = new Set(['schema', 'version', 'generatedAt', 'sampleCount', 'minimumCohortSample', 'cohorts', 'limitations']);
18
- const LEGACY_COHORT_KEYS = new Set(['suffix', 'profileId', 'sampleCount', 'state', 'sourceAlignment', 'publication']);
19
17
  const ROOT_KEYS = new Set(['schema', 'version', 'generatedAt', 'inputFamily', 'sampleCount', 'reportsMerged', 'minimumCohortSample', 'sampleWindow', 'cohorts', 'omissions', 'truncated', 'limitations']);
20
18
  const COHORT_KEYS = new Set(['suffix', 'profileId', 'sampleCount', 'state', 'latestState', 'sampleWindow', 'sourceAlignment', 'publication', 'timeline', 'timelineOmitted']);
21
19
  const POINT_KEYS = new Set(['reportGeneratedAt', 'sampleWindow', 'sampleCount', 'state', 'sourceAlignment', 'publication']);
@@ -29,11 +27,6 @@ const LIMITATIONS = Object.freeze([
29
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.',
30
28
  'Review fixture provenance and source health before changing a parser or registry access profile.',
31
29
  ]);
32
- const LEGACY_LIMITATIONS = Object.freeze([
33
- 'The report intentionally omits domains, queries and raw evidence. It groups saved observations only by suffix and capability profile.',
34
- `Cohorts with fewer than ${MIN_REGISTRY_COHORT_SAMPLE} observations remain insufficient sample and must not drive catalogue changes.`,
35
- 'Repeated observations from one environment are not representative by themselves. Review fixture provenance and source health before changing a parser or access profile.',
36
- ]);
37
30
  function record(value, label) {
38
31
  if (!value || typeof value !== 'object' || Array.isArray(value))
39
32
  throw new CliUsageError(`${label} must be an object.`);
@@ -56,10 +49,10 @@ function timestamp(value, label) {
56
49
  if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/u.test(value)) {
57
50
  throw new CliUsageError(`${label} is invalid.`);
58
51
  }
59
- const parsed = Date.parse(value);
60
- if (!Number.isFinite(parsed))
61
- throw new CliUsageError(`${label} is invalid.`);
62
- return new Date(parsed).toISOString();
52
+ const normalized = normalizeExplicitIsoTimestamp(value);
53
+ if (normalized)
54
+ return normalized;
55
+ throw new CliUsageError(`${label} is invalid.`);
63
56
  }
64
57
  function identifier(value, label) {
65
58
  if (typeof value !== 'string' || !/^[a-z0-9][a-z0-9_-]{0,119}$/u.test(value))
@@ -219,39 +212,6 @@ function sourceReportsFromLookups(documents, generatedAt) {
219
212
  const observed = reports.map((report) => report.generatedAt).sort();
220
213
  return [Object.freeze({ generatedAt, sampleWindow: Object.freeze({ from: observed[0], to: observed.at(-1) }), cohorts: Object.freeze(cohorts), duplicateTimelinePoints: 0 })];
221
214
  }
222
- function parseLegacyReport(value) {
223
- const root = exact(value, LEGACY_ROOT_KEYS, 'Registry cohort v1 report');
224
- if (root.schema !== REGISTRY_COHORT_SCHEMA || root.version !== LEGACY_REGISTRY_COHORT_VERSION)
225
- throw new CliUsageError('Registry cohort report version is unsupported.');
226
- const generatedAt = timestamp(root.generatedAt, 'Registry cohort v1 generatedAt');
227
- const sampleCount = integer(root.sampleCount, 1, MAX_REGISTRY_COHORT_SAMPLES, 'Registry cohort v1 sample count');
228
- if (root.minimumCohortSample !== MIN_REGISTRY_COHORT_SAMPLE)
229
- throw new CliUsageError('Registry cohort v1 minimum sample is unsupported.');
230
- boundedLimitations(root.limitations, LEGACY_LIMITATIONS, 'Registry cohort v1');
231
- if (!Array.isArray(root.cohorts) || !root.cohorts.length || root.cohorts.length > MAX_REGISTRY_COHORT_GROUPS)
232
- throw new CliUsageError('Registry cohort v1 groups are invalid.');
233
- const seen = new Set();
234
- let represented = 0;
235
- const cohorts = root.cohorts.map((candidate, index) => {
236
- const item = exact(candidate, LEGACY_COHORT_KEYS, `Registry cohort v1 group ${index + 1}`);
237
- const count = integer(item.sampleCount, 1, sampleCount, 'Registry cohort v1 group sample count');
238
- const sources = sourceAlignment(item.sourceAlignment, count, 'Registry cohort v1 source alignment');
239
- const published = publication(item.publication, count, 'Registry cohort v1 publication');
240
- const pointState = state(item.state, 'Registry cohort v1 state');
241
- if (pointState !== derivedState(count, sources, published))
242
- throw new CliUsageError('Registry cohort v1 state does not match its retained counts.');
243
- const key = `${suffix(item.suffix)}\u0000${identifier(item.profileId, 'Registry cohort v1 profile ID')}`;
244
- if (seen.has(key))
245
- throw new CliUsageError('Registry cohort v1 groups must be unique.');
246
- seen.add(key);
247
- represented += count;
248
- const point = Object.freeze({ reportGeneratedAt: generatedAt, sampleWindow: Object.freeze({ from: generatedAt, to: generatedAt }), sampleCount: count, state: pointState, sourceAlignment: sources, publication: published });
249
- return Object.freeze({ suffix: key.split('\u0000')[0], profileId: key.split('\u0000')[1], sampleWindow: point.sampleWindow, points: Object.freeze([point]), inheritedState: pointState, upstreamTimelineOmitted: 0 });
250
- });
251
- if (represented !== sampleCount)
252
- throw new CliUsageError('Registry cohort v1 sample count does not match its groups.');
253
- return Object.freeze({ generatedAt, sampleWindow: Object.freeze({ from: generatedAt, to: generatedAt }), cohorts: Object.freeze(cohorts), duplicateTimelinePoints: 0 });
254
- }
255
215
  function parseCurrentReport(value) {
256
216
  const root = exact(value, ROOT_KEYS, 'Registry cohort v2 report');
257
217
  if (root.schema !== REGISTRY_COHORT_SCHEMA || root.version !== REGISTRY_COHORT_VERSION)
@@ -406,11 +366,9 @@ export function buildRegistryCohortReport(text, generatedAt = new Date().toISOSt
406
366
  const reports = [];
407
367
  let retainedPoints = 0;
408
368
  for (const document of documents) {
409
- const report = document.version === LEGACY_REGISTRY_COHORT_VERSION
410
- ? parseLegacyReport(document)
411
- : document.version === REGISTRY_COHORT_VERSION
412
- ? parseCurrentReport(document)
413
- : (() => { throw new CliUsageError('Registry cohort report version is unsupported.'); })();
369
+ const report = document.version === REGISTRY_COHORT_VERSION
370
+ ? parseCurrentReport(document)
371
+ : (() => { throw new CliUsageError('Registry cohort report version is unsupported.'); })();
414
372
  retainedPoints += report.cohorts.reduce((total, cohort) => total + cohort.points.length, 0);
415
373
  if (retainedPoints > MAX_REGISTRY_COHORT_INPUT_POINTS) {
416
374
  throw new CliUsageError(`Retained registry cohort input contains more than ${MAX_REGISTRY_COHORT_INPUT_POINTS} timeline points.`);
@@ -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,17 +1,16 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { createHash } from 'node:crypto';
3
- import { canonicalArtifactJsonV2 } from "../frontend/src/lib/analysis/artifact-integrity.js";
4
- import { BULK_SESSION_SCHEMA, MAX_BULK_SESSIONS, MAX_BULK_SESSION_ROWS, SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS, mergeBulkSessions, normalizeBulkSession, } from "../frontend/src/lib/analysis/bulk-session-model.js";
5
- import { buildBulkComparisonCandidates } from "../frontend/src/lib/analysis/comparison-ledger-bulk.js";
6
- import { MAX_COMPARISON_LEDGER_DETAIL_ROWS, boundedComparisonLedgerLimitations, comparisonLedgerCollector, comparisonLedgerValuePresent, freshComparisonLedgerCounters, makeComparisonLedgerCandidate, normaliseComparisonLedgerRowWithCounters, stableComparisonLedgerId, stableComparisonLedgerJson, } from "../frontend/src/lib/analysis/comparison-ledger-contract.js";
3
+ import { canonicalArtifactJsonV2 } from "../packages/evidence/artifact-integrity.mjs";
4
+ import { BULK_SESSION_SCHEMA, MAX_BULK_SESSIONS, MAX_BULK_SESSION_ROWS, SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS, mergeBulkSessions, normalizeBulkSession, } from "../packages/workspace/bulk-session-model.mjs";
5
+ import { buildBulkComparisonCandidates } from "../packages/comparison/comparison-ledger-bulk.mjs";
6
+ import { MAX_COMPARISON_LEDGER_DETAIL_ROWS, boundedComparisonLedgerLimitations, comparisonLedgerCollector, comparisonLedgerValuePresent, freshComparisonLedgerCounters, makeComparisonLedgerCandidate, normaliseComparisonLedgerRowWithCounters, stableComparisonLedgerId, stableComparisonLedgerJson, } from "../packages/comparison/comparison-ledger-contract.mjs";
7
7
  import { DOMAIN_PORTFOLIO_INPUT_SCHEMA, DOMAIN_PORTFOLIO_REVIEW_SCHEMA, DOMAIN_PORTFOLIO_REVIEW_VERSION, reviewDomainPortfolio, } from "../lib/domain-portfolio-review.mjs";
8
8
  import { parseBoundedJsonObject } from "./bounded-json.mjs";
9
9
  import { CliUsageError } from "./errors.mjs";
10
10
  import { buildCliLookupDiff, formatCliLookupDiff } from "./lookup-diff.mjs";
11
11
  import { SAVED_LOOKUP_SCHEMA } from "./saved-lookup.mjs";
12
- export const CLI_COMPARISON_LEDGER_SCHEMA = 'whoisleuth.cli.comparison-ledger';
13
- export const CLI_COMPARISON_LEDGER_VERSION = 1;
14
- export const MAX_RETAINED_ARTIFACT_DIFF_BYTES = 8 * 1024 * 1024;
12
+ import { CLI_COMPARISON_LEDGER_SCHEMA, CLI_COMPARISON_LEDGER_VERSION, MAX_RETAINED_ARTIFACT_DIFF_BYTES, } from "../packages/contracts/offline-comparison.mjs";
13
+ export { CLI_COMPARISON_LEDGER_SCHEMA, CLI_COMPARISON_LEDGER_VERSION, MAX_RETAINED_ARTIFACT_DIFF_BYTES };
15
14
  const BULK_ROOT_KEYS = new Set(['schema', 'version', 'generatedAt', 'sessions', 'limitations']);
16
15
  const PORTFOLIO_ROOT_KEYS = new Set(['schema', 'version', 'generatedAt', 'portfolioLabel', 'assets', 'simulations', 'renewalQueue', 'recoveryCycles', 'unknownCounts', 'limitations']);
17
16
  const SAFE_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
@@ -63,7 +62,7 @@ function parseBulkExport(root) {
63
62
  const rawSession = record(candidate);
64
63
  const rawResults = Array.isArray(rawSession?.results) ? rawSession.results : null;
65
64
  const rawDomains = Array.isArray(rawSession?.domains) ? rawSession.domains : null;
66
- const session = normalizeBulkSession(candidate, version);
65
+ const session = normalizeBulkSession(candidate);
67
66
  if (!session || !rawResults || !rawDomains
68
67
  || rawResults.length > MAX_BULK_SESSION_ROWS
69
68
  || rawResults.length !== session.results.length