@slicedearth/whoisleuth-cli 1.32.2 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/doctor.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { resolvePublicAddresses } from "../lib/safe-fetch.mjs";
1
+ import { resolvePublicAddresses, safeFetch } from "../lib/safe-fetch.mjs";
2
2
  import { withTimeout } from "../lib/abort.mjs";
3
3
  import { whoisQuery } from "../lib/whois-transport.mjs";
4
4
  const DOCTOR_SCHEMA = 'whoisleuth.cli.doctor';
@@ -6,6 +6,7 @@ const DOCTOR_VERSION = 1;
6
6
  const MINIMUM_NODE_MAJOR = 24;
7
7
  const NETWORK_HOST = 'whois.iana.org';
8
8
  const NETWORK_QUERY = 'example.com';
9
+ const NETWORK_HTTPS_URL = 'https://data.iana.org/rdap/dns.json';
9
10
  const NETWORK_TIMEOUT_MS = 6_000;
10
11
  const MAX_DOCTOR_DETAIL_LENGTH = 180;
11
12
  function boundedDetail(value, fallback) {
@@ -57,58 +58,92 @@ async function buildDoctorReport(options) {
57
58
  id: 'network',
58
59
  label: 'Network checks',
59
60
  state: 'skipped',
60
- detail: 'Not requested. Run doctor --network to test bounded public DNS and WHOIS connectivity.',
61
+ detail: 'Not requested. Run doctor --network to test bounded public DNS, HTTPS, and WHOIS connectivity.',
61
62
  }));
62
63
  }
63
64
  else {
64
65
  const resolve = options.resolveAddresses || resolvePublicAddresses;
66
+ const fetchHttps = options.fetchHttps || safeFetch;
65
67
  const query = options.queryWhois || whoisQuery;
66
68
  const networkTimeoutMs = Number.isSafeInteger(options.networkTimeoutMs)
67
69
  && Number(options.networkTimeoutMs) >= 1
68
70
  && Number(options.networkTimeoutMs) <= NETWORK_TIMEOUT_MS
69
71
  ? Number(options.networkTimeoutMs)
70
72
  : NETWORK_TIMEOUT_MS;
71
- try {
72
- const addresses = await withTimeout(() => resolve(NETWORK_HOST), networkTimeoutMs, `Public DNS check timed out after ${networkTimeoutMs} ms.`);
73
- checks.push(Object.freeze({
74
- id: 'public_dns',
75
- label: 'Public DNS',
76
- state: addresses.length > 0 ? 'pass' : 'partial',
77
- detail: addresses.length > 0
78
- ? `${addresses.length} public address${addresses.length === 1 ? '' : 'es'} validated; addresses were not retained.`
79
- : 'The fixed diagnostic host returned no validated public addresses.',
80
- }));
81
- }
82
- catch (error) {
83
- checks.push(Object.freeze({
84
- id: 'public_dns',
85
- label: 'Public DNS',
86
- state: 'partial',
87
- detail: boundedDetail(error && typeof error === 'object' && 'message' in error ? error.message : error, 'Public DNS check failed.'),
88
- }));
89
- }
90
- try {
91
- const response = await query(NETWORK_HOST, NETWORK_QUERY, {
92
- timeoutMs: Math.max(1, networkTimeoutMs - 1_000),
93
- totalDeadlineMs: networkTimeoutMs,
94
- });
95
- checks.push(Object.freeze({
96
- id: 'whois_transport',
97
- label: 'WHOIS transport',
98
- state: response.trim() ? 'pass' : 'partial',
99
- detail: response.trim()
100
- ? 'A bounded port 43 response was received; response content was not retained.'
101
- : 'The fixed diagnostic query returned an empty response.',
102
- }));
103
- }
104
- catch (error) {
105
- checks.push(Object.freeze({
106
- id: 'whois_transport',
107
- label: 'WHOIS transport',
108
- state: 'partial',
109
- detail: boundedDetail(error && typeof error === 'object' && 'message' in error ? error.message : error, 'WHOIS transport check failed.'),
110
- }));
111
- }
73
+ const networkChecks = await Promise.all([
74
+ (async () => {
75
+ try {
76
+ const addresses = await withTimeout(() => resolve(NETWORK_HOST), networkTimeoutMs, `Public DNS check timed out after ${networkTimeoutMs} ms.`);
77
+ return Object.freeze({
78
+ id: 'public_dns',
79
+ label: 'Public DNS',
80
+ state: addresses.length > 0 ? 'pass' : 'partial',
81
+ detail: addresses.length > 0
82
+ ? `${addresses.length} public address${addresses.length === 1 ? '' : 'es'} validated; addresses were not retained.`
83
+ : 'The fixed diagnostic host returned no validated public addresses.',
84
+ });
85
+ }
86
+ catch (error) {
87
+ return Object.freeze({
88
+ id: 'public_dns',
89
+ label: 'Public DNS',
90
+ state: 'partial',
91
+ detail: boundedDetail(error && typeof error === 'object' && 'message' in error ? error.message : error, 'Public DNS check failed.'),
92
+ });
93
+ }
94
+ })(),
95
+ (async () => {
96
+ try {
97
+ const response = await withTimeout(() => fetchHttps(NETWORK_HTTPS_URL, {
98
+ headers: { accept: 'application/json' },
99
+ signal: AbortSignal.timeout(networkTimeoutMs),
100
+ }), networkTimeoutMs, `HTTPS check timed out after ${networkTimeoutMs} ms.`);
101
+ const status = response.status;
102
+ await response.body?.cancel().catch(() => { });
103
+ return Object.freeze({
104
+ id: 'https_transport',
105
+ label: 'HTTPS transport',
106
+ state: response.ok ? 'pass' : 'partial',
107
+ detail: response.ok
108
+ ? `The fixed IANA RDAP bootstrap endpoint returned HTTP ${status}; response content was not retained.`
109
+ : `The fixed IANA RDAP bootstrap endpoint returned HTTP ${status}.`,
110
+ });
111
+ }
112
+ catch (error) {
113
+ return Object.freeze({
114
+ id: 'https_transport',
115
+ label: 'HTTPS transport',
116
+ state: 'partial',
117
+ detail: boundedDetail(error && typeof error === 'object' && 'message' in error ? error.message : error, 'HTTPS transport check failed.'),
118
+ });
119
+ }
120
+ })(),
121
+ (async () => {
122
+ try {
123
+ const response = await withTimeout(() => query(NETWORK_HOST, NETWORK_QUERY, {
124
+ timeoutMs: Math.max(1, networkTimeoutMs - 1_000),
125
+ totalDeadlineMs: networkTimeoutMs,
126
+ }), networkTimeoutMs, `WHOIS check timed out after ${networkTimeoutMs} ms.`);
127
+ return Object.freeze({
128
+ id: 'whois_transport',
129
+ label: 'WHOIS transport',
130
+ state: response.trim() ? 'pass' : 'partial',
131
+ detail: response.trim()
132
+ ? 'A bounded port 43 response was received; response content was not retained.'
133
+ : 'The fixed diagnostic query returned an empty response.',
134
+ });
135
+ }
136
+ catch (error) {
137
+ return Object.freeze({
138
+ id: 'whois_transport',
139
+ label: 'WHOIS transport',
140
+ state: 'partial',
141
+ detail: boundedDetail(error && typeof error === 'object' && 'message' in error ? error.message : error, 'WHOIS transport check failed.'),
142
+ });
143
+ }
144
+ })(),
145
+ ]);
146
+ checks.push(...networkChecks);
112
147
  }
113
148
  const state = checks.some((check) => check.state === 'partial') ? 'partial' : 'pass';
114
149
  return Object.freeze({
@@ -28,7 +28,7 @@ function renderRegistrarComparison(comparison) {
28
28
  }
29
29
  function formatLookupEvidenceHtml(document) {
30
30
  const report = buildLookupEvidenceReport(document);
31
- const title = `Lookup evidence report — ${report.title}`;
31
+ const title = `Lookup evidence report: ${report.title}`;
32
32
  return `<!doctype html>
33
33
  <html lang="en">
34
34
  <head>
@@ -284,12 +284,12 @@ function formatTerminalRegistrySupport(document) {
284
284
  function formatTerminalBulk(items, metadata) {
285
285
  const lines = items.map((item) => {
286
286
  if (!item.ok)
287
- return `! ${safeTerminalValue(item.query)} — ${safeTerminalValue(item.error, 'Lookup failed')}`;
287
+ return `! ${safeTerminalValue(item.query)}: ${safeTerminalValue(item.error, 'Lookup failed')}`;
288
288
  const result = terminalRecord(item.result);
289
289
  const availability = terminalRecord(result.availability);
290
290
  const state = titleCase(availability.state);
291
291
  const confidence = titleCase(availability.confidence);
292
- return `✓ ${safeTerminalValue(item.query)} — ${state} (${confidence} confidence)`;
292
+ return `✓ ${safeTerminalValue(item.query)}: ${state} (${confidence} confidence)`;
293
293
  });
294
294
  const succeeded = items.filter((item) => item.ok).length;
295
295
  lines.push('');
@@ -367,7 +367,7 @@ function formatTerminalDiscover(document, mutationLabels = {}) {
367
367
  const unicodeDetail = unicodeDomain && unicodeDomain !== candidateDomain
368
368
  ? ` [Unicode: ${safeTerminalValue(unicodeDomain)}]`
369
369
  : '';
370
- lines.push(`${safeTerminalValue(candidateDomain)}${unicodeDetail} — ${labels.join(', ') || 'Generated variant'}`);
370
+ lines.push(`${safeTerminalValue(candidateDomain)}${unicodeDetail}: ${labels.join(', ') || 'Generated variant'}`);
371
371
  }
372
372
  if (!visible.length)
373
373
  lines.push('No candidates were generated.');
@@ -396,7 +396,7 @@ function formatTerminalPosture(document) {
396
396
  ];
397
397
  for (const value of checks) {
398
398
  const item = terminalRecord(value);
399
- lines.push(`[${safeTerminalValue(item.status, 'info').toUpperCase()}] ${safeTerminalValue(item.label)} — ${safeTerminalValue(item.summary)}`);
399
+ lines.push(`[${safeTerminalValue(item.status, 'info').toUpperCase()}] ${safeTerminalValue(item.label)}: ${safeTerminalValue(item.summary)}`);
400
400
  if (item.detail)
401
401
  lines.push(` Detail ${safeTerminalValue(item.detail)}`);
402
402
  if (item.remediation)
@@ -444,7 +444,7 @@ function formatTerminalHttp(document) {
444
444
  for (const value of attempts) {
445
445
  const attempt = terminalRecord(value);
446
446
  const outcome = attempt.httpStatus ? `HTTP ${attempt.httpStatus}` : attempt.error || attempt.outcome;
447
- lines.push(`Attempt ${safeTerminalValue(attempt.url)} — ${safeTerminalValue(outcome)}`);
447
+ lines.push(`Attempt ${safeTerminalValue(attempt.url)}: ${safeTerminalValue(outcome)}`);
448
448
  }
449
449
  for (const limitation of limitations)
450
450
  lines.push(`Limitation ${safeTerminalValue(limitation)}`);
@@ -535,7 +535,7 @@ function formatTerminalTls(document) {
535
535
  lines.push(`Error ${safeTerminalValue(diagnostics.error)}`);
536
536
  for (const value of findings) {
537
537
  const finding = terminalRecord(value);
538
- lines.push(`Finding ${safeTerminalValue(finding.label)} — ${safeTerminalValue(finding.detail)}`);
538
+ lines.push(`Finding ${safeTerminalValue(finding.label)}: ${safeTerminalValue(finding.detail)}`);
539
539
  }
540
540
  for (const limitation of limitations)
541
541
  lines.push(`Limitation ${safeTerminalValue(limitation)}`);
@@ -8,6 +8,7 @@ import { formatLookupEvidenceHtml } from "./formatters/html.mjs";
8
8
  import { buildCliLookupDocument, formatJsonDocument } from "./formatters/json.mjs";
9
9
  import { formatLookupEvidenceMarkdown } from "./formatters/markdown.mjs";
10
10
  import { formatTerminalLookup } from "./formatters/terminal.mjs";
11
+ import { buildCliLookupPlan, formatCliLookupPlan } from "./lookup-plan.mjs";
11
12
  import { createCliProgressEvents } from "./progress-events.mjs";
12
13
  import { lookupStrictExitFindings } from "./strict-exit.mjs";
13
14
  async function runLookupCommand(args, dependencies, context) {
@@ -33,6 +34,13 @@ async function runLookupCommand(args, dependencies, context) {
33
34
  if ((args.output === 'markdown' || args.output === 'html') && classified.type !== 'domain') {
34
35
  throw new CliUsageError('Markdown and HTML reports support domain lookups only.');
35
36
  }
37
+ if (args.plan) {
38
+ const plan = buildCliLookupPlan(query, classified, args.deep);
39
+ context.writeStdout(args.output === 'json'
40
+ ? formatJsonDocument(plan)
41
+ : context.terminal(formatCliLookupPlan(plan), args.color));
42
+ return EXIT_CODES.SUCCESS;
43
+ }
36
44
  const indicator = context.beginProgress(args.deep
37
45
  ? 'Collecting deep Lookup evidence'
38
46
  : 'Collecting registration evidence');
@@ -119,13 +119,20 @@ function lookupComparisonInput(document) {
119
119
  sourceCoverage: sourceCoverage(document, availability),
120
120
  };
121
121
  }
122
- function buildCliLookupDiff(leftText, rightText, generatedAt = new Date().toISOString()) {
122
+ function buildCliLookupDiff(leftText, rightText, generatedAt = new Date().toISOString(), options = {}) {
123
123
  const left = parseSavedLookupDocument(leftText, { label: 'Left lookup input' });
124
124
  const right = parseSavedLookupDocument(rightText, { label: 'Right lookup input' });
125
- if (left.registrableDomain === right.registrableDomain) {
125
+ const domainMode = options.domainMode ?? 'different';
126
+ if (domainMode === 'different' && left.registrableDomain === right.registrableDomain) {
126
127
  throw new CliUsageError('Lookup diff requires documents for two different domains.');
127
128
  }
128
- const comparison = buildBulkDomainComparison(lookupComparisonInput(left), lookupComparisonInput(right), null);
129
+ if (domainMode === 'same' && left.registrableDomain !== right.registrableDomain) {
130
+ throw new CliUsageError('Lookup history requires observations for the same domain.');
131
+ }
132
+ const comparison = buildBulkDomainComparison(lookupComparisonInput(left), lookupComparisonInput(right), domainMode === 'same' ? right.generatedAt : null, {
133
+ allowSameDomain: domainMode === 'same',
134
+ ...(domainMode === 'same' ? { now: Date.parse(right.generatedAt) } : {}),
135
+ });
129
136
  if (!comparison)
130
137
  throw new CliUsageError('Lookup documents could not be compared.');
131
138
  return {
@@ -136,9 +143,13 @@ function buildCliLookupDiff(leftText, rightText, generatedAt = new Date().toISOS
136
143
  right: { domain: right.registrableDomain, generatedAt: right.generatedAt, mode: right.mode },
137
144
  comparison,
138
145
  limitations: [
139
- 'This command compares bounded observations already present in two saved Lookup documents and makes no network request.',
146
+ domainMode === 'same'
147
+ ? 'This comparison uses bounded observations already present in two saved Lookup documents for the same domain and makes no network request.'
148
+ : 'This command compares bounded observations already present in two saved Lookup documents and makes no network request.',
140
149
  'A missing value remains distinct from unavailable collection and from an observed difference.',
141
- 'Shared infrastructure or matching values do not establish common ownership, control, intent, safety, or maliciousness.',
150
+ domainMode === 'same'
151
+ ? 'An observed difference can reflect a domain change or changed collection conditions and does not by itself establish current state, intent, safety, or maliciousness.'
152
+ : 'Shared infrastructure or matching values do not establish common ownership, control, intent, safety, or maliciousness.',
142
153
  ],
143
154
  };
144
155
  }
@@ -0,0 +1,99 @@
1
+ export const CLI_LOOKUP_PLAN_SCHEMA = 'whoisleuth.cli.lookup-plan';
2
+ export const CLI_LOOKUP_PLAN_VERSION = 1;
3
+ const RDAP = Object.freeze({
4
+ source: 'rdap',
5
+ purpose: 'Collect authoritative registration or allocation evidence where supported.',
6
+ disclosure: 'The normalized target is sent to the applicable RDAP bootstrap and service endpoints.',
7
+ conditional: false,
8
+ });
9
+ const WHOIS = Object.freeze({
10
+ source: 'whois',
11
+ purpose: 'Collect separately attributed registry and referral publications.',
12
+ disclosure: 'The normalized target is sent over bounded TCP connections to applicable WHOIS services.',
13
+ conditional: false,
14
+ });
15
+ const FAST_DOMAIN_EVIDENCE = Object.freeze({
16
+ source: 'domain_evidence',
17
+ purpose: 'Derive a registration state from RDAP, with bounded DNS delegation fallback when needed.',
18
+ disclosure: 'DNS resolvers may receive the registrable domain when RDAP does not provide a usable record.',
19
+ conditional: true,
20
+ });
21
+ const DEEP_DOMAIN_EVIDENCE = Object.freeze({
22
+ source: 'domain_evidence',
23
+ purpose: 'Collect bounded DNS, HTTP, TLS, page-identity, technology, and security-posture evidence.',
24
+ disclosure: 'DNS resolvers and the target website infrastructure receive the hostname through bounded probes.',
25
+ conditional: false,
26
+ });
27
+ const REGISTRAR_RDAP = Object.freeze({
28
+ source: 'registrar_rdap',
29
+ purpose: 'Collect a separately attributed registrar RDAP publication when registry evidence advertises one.',
30
+ disclosure: 'The registrable domain is sent to the advertised registrar RDAP service.',
31
+ conditional: true,
32
+ });
33
+ const NETWORK_CONTEXT = Object.freeze({
34
+ source: 'network_context',
35
+ purpose: 'Add allocation and routing context for public addresses observed during domain collection.',
36
+ disclosure: 'Observed public addresses may be sent to applicable RDAP services.',
37
+ conditional: true,
38
+ });
39
+ const REVERSE_DNS = Object.freeze({
40
+ source: 'reverse_dns',
41
+ purpose: 'Collect operator-published reverse-DNS context for a public IP address.',
42
+ disclosure: 'A DNS resolver receives the reverse lookup for the normalized IP address.',
43
+ conditional: false,
44
+ });
45
+ function plannedSources(classified, deep) {
46
+ if (!deep) {
47
+ return Object.freeze(classified.type === 'domain' ? [RDAP, FAST_DOMAIN_EVIDENCE] : [RDAP]);
48
+ }
49
+ if (classified.type === 'domain') {
50
+ return Object.freeze([RDAP, WHOIS, DEEP_DOMAIN_EVIDENCE, REGISTRAR_RDAP, NETWORK_CONTEXT]);
51
+ }
52
+ if (classified.type === 'ipv4' || classified.type === 'ipv6') {
53
+ return Object.freeze([RDAP, WHOIS, REVERSE_DNS]);
54
+ }
55
+ return Object.freeze([RDAP, WHOIS]);
56
+ }
57
+ function buildCliLookupPlan(query, classified, deep) {
58
+ return Object.freeze({
59
+ schema: CLI_LOOKUP_PLAN_SCHEMA,
60
+ version: CLI_LOOKUP_PLAN_VERSION,
61
+ mode: deep ? 'deep' : 'fast',
62
+ target: Object.freeze({
63
+ query,
64
+ type: classified.type,
65
+ normalized: classified.value,
66
+ ...(classified.inputHostname ? { inputHostname: classified.inputHostname } : {}),
67
+ ...(classified.registrableDomain ? { registrableDomain: classified.registrableDomain } : {}),
68
+ }),
69
+ planning: Object.freeze({
70
+ networkRequestsMade: false,
71
+ collectionRequiresNetwork: true,
72
+ sources: plannedSources(classified, deep),
73
+ }),
74
+ limitations: Object.freeze([
75
+ 'This is a local preflight. It does not test source availability, feature configuration, cache state, redirects, referrals, or the exact number of requests a completed lookup may require.',
76
+ 'Conditional sources may be skipped when prerequisite evidence is absent, unsupported, disabled, or unavailable.',
77
+ ]),
78
+ });
79
+ }
80
+ function formatCliLookupPlan(plan) {
81
+ const lines = [
82
+ 'WHOISleuth lookup preflight',
83
+ `Target: ${plan.target.normalized}`,
84
+ `Type: ${plan.target.type}`,
85
+ `Mode: ${plan.mode}`,
86
+ 'Network requests made: no',
87
+ '',
88
+ 'Planned collection:',
89
+ ];
90
+ for (const source of plan.planning.sources) {
91
+ lines.push(` ${source.source}${source.conditional ? ' (conditional)' : ''}`);
92
+ lines.push(` ${source.purpose}`);
93
+ lines.push(` Disclosure: ${source.disclosure}`);
94
+ }
95
+ for (const limitation of plan.limitations)
96
+ lines.push('', `Limitation: ${limitation}`);
97
+ return `${lines.join('\n')}\n`;
98
+ }
99
+ export { buildCliLookupPlan, formatCliLookupPlan };
@@ -0,0 +1,123 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { CliUsageError } from "./errors.mjs";
3
+ import { buildCliLookupDiff } from "./lookup-diff.mjs";
4
+ import { parseSavedLookupDocument } from "./saved-lookup.mjs";
5
+ export const CLI_LOOKUP_TIMELINE_SCHEMA = 'whoisleuth.cli.lookup-timeline';
6
+ export const CLI_LOOKUP_TIMELINE_VERSION = 1;
7
+ export const MAX_LOOKUP_TIMELINE_OBSERVATIONS = 20;
8
+ export const MAX_LOOKUP_TIMELINE_INPUT_BYTES = 32 * 1024 * 1024;
9
+ function buildCliLookupTimeline(inputs, generatedAt = new Date().toISOString()) {
10
+ if (inputs.length < 2 || inputs.length > MAX_LOOKUP_TIMELINE_OBSERVATIONS) {
11
+ throw new CliUsageError(`Lookup timeline requires from 2 to ${MAX_LOOKUP_TIMELINE_OBSERVATIONS} saved Lookup documents.`);
12
+ }
13
+ const totalBytes = inputs.reduce((total, input) => total + Buffer.byteLength(input, 'utf8'), 0);
14
+ if (totalBytes > MAX_LOOKUP_TIMELINE_INPUT_BYTES) {
15
+ throw new CliUsageError(`Lookup timeline input is limited to ${MAX_LOOKUP_TIMELINE_INPUT_BYTES} bytes in total.`);
16
+ }
17
+ const normalized = inputs.map((input, index) => ({
18
+ input,
19
+ document: parseSavedLookupDocument(input, { label: `Lookup timeline input ${index + 1}` }),
20
+ inputIndex: index,
21
+ }));
22
+ const domain = normalized[0]?.document.registrableDomain;
23
+ if (!domain || normalized.some((item) => item.document.registrableDomain !== domain)) {
24
+ throw new CliUsageError('Lookup timeline requires observations for exactly one domain.');
25
+ }
26
+ normalized.sort((left, right) => (Date.parse(left.document.generatedAt) - Date.parse(right.document.generatedAt)
27
+ || left.inputIndex - right.inputIndex));
28
+ for (let index = 1; index < normalized.length; index++) {
29
+ if (normalized[index - 1]?.document.generatedAt === normalized[index]?.document.generatedAt) {
30
+ throw new CliUsageError('Lookup timeline observation times must be unique.');
31
+ }
32
+ }
33
+ const observations = normalized.map((item, index) => ({
34
+ index,
35
+ generatedAt: item.document.generatedAt,
36
+ mode: item.document.mode,
37
+ }));
38
+ const transitions = normalized.slice(1).map((current, index) => {
39
+ const previous = normalized[index];
40
+ if (!previous)
41
+ throw new TypeError('Lookup timeline ordering failed.');
42
+ const diff = buildCliLookupDiff(previous.input, current.input, generatedAt, { domainMode: 'same' });
43
+ return {
44
+ index,
45
+ fromObservation: index,
46
+ toObservation: index + 1,
47
+ comparison: diff.comparison,
48
+ };
49
+ });
50
+ const fields = {
51
+ conflicting: 0,
52
+ different: 0,
53
+ equal: 0,
54
+ missing: 0,
55
+ notRecorded: 0,
56
+ unavailable: 0,
57
+ };
58
+ let transitionsWithObservedChanges = 0;
59
+ for (const transition of transitions) {
60
+ const counts = transition.comparison.counts;
61
+ fields.conflicting += counts.conflicting;
62
+ fields.different += counts.different;
63
+ fields.equal += counts.equal;
64
+ fields.missing += counts.missing;
65
+ fields.notRecorded += counts.not_recorded;
66
+ fields.unavailable += counts.unavailable;
67
+ if (counts.conflicting + counts.different > 0)
68
+ transitionsWithObservedChanges += 1;
69
+ }
70
+ return {
71
+ schema: CLI_LOOKUP_TIMELINE_SCHEMA,
72
+ version: CLI_LOOKUP_TIMELINE_VERSION,
73
+ generatedAt,
74
+ domain,
75
+ observations,
76
+ transitions,
77
+ summary: {
78
+ observationCount: observations.length,
79
+ transitionCount: transitions.length,
80
+ transitionsWithObservedChanges,
81
+ fields,
82
+ },
83
+ privacy: {
84
+ filenamesRetained: 0,
85
+ rawRegistryPayloadsCopied: 0,
86
+ },
87
+ limitations: [
88
+ 'The timeline compares only bounded normalized fields already present in the selected saved Lookup documents and makes no network request.',
89
+ 'Input filenames and raw registry payloads are not copied into the timeline output.',
90
+ 'Missing, unavailable, and not-recorded evidence remain separate from an observed difference.',
91
+ 'A difference can reflect a domain change or changed collection conditions and does not by itself establish current state, ownership, intent, safety, or maliciousness.',
92
+ ],
93
+ };
94
+ }
95
+ function formatCliLookupTimeline(document) {
96
+ const output = [
97
+ 'Same-domain observation timeline',
98
+ `Domain ${document.domain}`,
99
+ `Observations ${document.summary.observationCount}`,
100
+ `First observed ${document.observations[0]?.generatedAt || 'Unavailable'}`,
101
+ `Last observed ${document.observations.at(-1)?.generatedAt || 'Unavailable'}`,
102
+ `Changed transitions ${document.summary.transitionsWithObservedChanges}`,
103
+ ];
104
+ for (const transition of document.transitions) {
105
+ const from = document.observations[transition.fromObservation];
106
+ const to = document.observations[transition.toObservation];
107
+ const counts = transition.comparison.counts;
108
+ const changedRows = transition.comparison.rows.filter((row) => (row.state === 'conflicting' || row.state === 'different' || row.state === 'missing'));
109
+ output.push('', `${from?.generatedAt || 'Unavailable'} to ${to?.generatedAt || 'Unavailable'}`, ` Observed changes ${counts.conflicting + counts.different}`, ` One-sided fields ${counts.missing}`, ` Unavailable ${counts.unavailable + counts.not_recorded}`);
110
+ if (!changedRows.length)
111
+ output.push(' No observed field changes in comparable evidence.');
112
+ for (const row of changedRows.slice(0, 10)) {
113
+ output.push(` ${row.label} [${row.state.replaceAll('_', ' ')}]: ${row.left} -> ${row.right}`);
114
+ }
115
+ if (changedRows.length > 10)
116
+ output.push(` ${changedRows.length - 10} additional changed fields omitted; use --json for the complete bounded comparison.`);
117
+ }
118
+ output.push('', 'Limitations:');
119
+ for (const limitation of document.limitations)
120
+ output.push(` - ${limitation}`);
121
+ return `${output.join('\n')}\n`;
122
+ }
123
+ export { buildCliLookupTimeline, formatCliLookupTimeline };
package/cli/manual.mjs CHANGED
@@ -7,7 +7,8 @@ function roffText(value) {
7
7
  function buildCliManual(options) {
8
8
  const commands = options.commands.map((command) => {
9
9
  const detail = options.details[command];
10
- return `.SS ${roffText(command)}\n${roffText(detail.description)}\n.PP\n.B ${roffText(options.usage[command])}\n.PP\nExample: ${roffText(detail.example)}\n.PP\nBoundary: ${roffText(detail.boundary)}`;
10
+ const collection = options.collections[command];
11
+ return `.SS ${roffText(command)}\n${roffText(detail.description)}\n.PP\n.B ${roffText(options.usage[command])}\n.PP\nExample: ${roffText(detail.example)}\n.PP\nCollection: ${roffText(collection.mode)}. ${roffText(collection.scope)}\n.PP\nBoundary: ${roffText(detail.boundary)}`;
11
12
  }).join('\n');
12
13
  return `.TH WHOISLEUTH 1 "" "WHOISleuth ${roffText(options.version)}" "User Commands"
13
14
  .SH NAME