158 lines
5.2 KiB
TypeScript
158 lines
5.2 KiB
TypeScript
/**
|
|
* Where a finding came from.
|
|
*
|
|
* The rule this type exists to enforce: a finding names the artefact that
|
|
* produced it. SideDoor already did this — it prints the service, the port
|
|
* binding and the compose file — and it is noticeably the most useful of these
|
|
* outputs to read. A result that is asserted without a source leaves the
|
|
* reader to take it on trust, and an unverified claim should count as absent.
|
|
*/
|
|
|
|
/** A location in source or configuration. */
|
|
export interface FileEvidence {
|
|
kind: 'file'
|
|
path: string
|
|
/** 1-indexed. Omitted when the finding is about the file as a whole. */
|
|
line?: number
|
|
/** The matched text. Keep it short — one line, trimmed. */
|
|
excerpt?: string
|
|
}
|
|
|
|
/** A row in a database. */
|
|
export interface RecordEvidence {
|
|
kind: 'record'
|
|
table: string
|
|
id: string
|
|
/** The column that carried the value the finding turns on. */
|
|
field?: string
|
|
value?: string
|
|
}
|
|
|
|
/** Output of a command that was actually run. */
|
|
export interface CommandEvidence {
|
|
kind: 'command'
|
|
command: string
|
|
exitCode?: number
|
|
excerpt?: string
|
|
}
|
|
|
|
/**
|
|
* A number that was measured, together with how much data stood behind it.
|
|
*
|
|
* `samples` is not decoration. An availability of 100% over zero samples and
|
|
* an availability of 100% over 8,640 samples are different claims, and only
|
|
* one of them is evidence.
|
|
*/
|
|
export interface MeasurementEvidence {
|
|
kind: 'measurement'
|
|
metric: string
|
|
/** Null when it could not be computed. Never substitute a flattering default. */
|
|
value: number | null
|
|
unit?: string
|
|
samples: number
|
|
/** e.g. "2026-07-01 → 2026-07-31". */
|
|
window?: string
|
|
}
|
|
|
|
/** A written source: a policy, a standard, a control catalogue. */
|
|
export interface DocumentEvidence {
|
|
kind: 'document'
|
|
title: string
|
|
section?: string
|
|
url?: string
|
|
}
|
|
|
|
export type EvidenceSource =
|
|
| FileEvidence
|
|
| RecordEvidence
|
|
| CommandEvidence
|
|
| MeasurementEvidence
|
|
| DocumentEvidence
|
|
|
|
export interface Evidence {
|
|
source: EvidenceSource
|
|
/** ISO 8601. When the observation was made, if the caller tracks it. */
|
|
observedAt?: string
|
|
/** One line on why this artefact supports the finding. */
|
|
note?: string
|
|
}
|
|
|
|
/**
|
|
* A short, copy-pasteable citation — the thing a reader scans for.
|
|
*
|
|
* Formats follow the conventions each kind is already read in: `path:line`
|
|
* for source (clickable in most terminals), `table#id` for rows.
|
|
*/
|
|
export function cite(e: Evidence): string {
|
|
const s = e.source
|
|
switch (s.kind) {
|
|
case 'file':
|
|
return s.line ? `${s.path}:${s.line}` : s.path
|
|
case 'record':
|
|
return s.field ? `${s.table}#${s.id} (${s.field})` : `${s.table}#${s.id}`
|
|
case 'command':
|
|
return s.exitCode === undefined ? `$ ${s.command}` : `$ ${s.command} → exit ${s.exitCode}`
|
|
case 'measurement': {
|
|
const v = s.value === null ? 'not computable' : `${s.value}${s.unit ?? ''}`
|
|
const n = `${s.samples} sample${s.samples === 1 ? '' : 's'}`
|
|
return s.window ? `${s.metric} = ${v} (${n}, ${s.window})` : `${s.metric} = ${v} (${n})`
|
|
}
|
|
case 'document':
|
|
return s.section ? `${s.title} § ${s.section}` : s.title
|
|
}
|
|
}
|
|
|
|
/** Longer form: the citation plus whatever context was attached to it. */
|
|
export function citeVerbose(e: Evidence): string[] {
|
|
const out = [cite(e)]
|
|
const s = e.source
|
|
if ((s.kind === 'file' || s.kind === 'command') && s.excerpt) out.push(` ${s.excerpt.trim()}`)
|
|
if (s.kind === 'record' && s.value !== undefined) out.push(` value: ${s.value}`)
|
|
if (e.note) out.push(` ${e.note}`)
|
|
return out
|
|
}
|
|
|
|
/** Convenience constructors, so call sites stay one line. */
|
|
export const evidence = {
|
|
file(path: string, line?: number, excerpt?: string, note?: string): Evidence {
|
|
return { source: { kind: 'file', path, line, excerpt }, note }
|
|
},
|
|
record(table: string, id: string, field?: string, value?: string, note?: string): Evidence {
|
|
return { source: { kind: 'record', table, id, field, value }, note }
|
|
},
|
|
command(command: string, exitCode?: number, excerpt?: string, note?: string): Evidence {
|
|
return { source: { kind: 'command', command, exitCode, excerpt }, note }
|
|
},
|
|
measurement(
|
|
metric: string,
|
|
value: number | null,
|
|
samples: number,
|
|
opts: { unit?: string, window?: string, note?: string } = {},
|
|
): Evidence {
|
|
return {
|
|
source: { kind: 'measurement', metric, value, samples, unit: opts.unit, window: opts.window },
|
|
note: opts.note,
|
|
}
|
|
},
|
|
document(title: string, section?: string, url?: string, note?: string): Evidence {
|
|
return { source: { kind: 'document', title, section, url }, note }
|
|
},
|
|
}
|
|
|
|
/**
|
|
* Read a measurement out loud, including the case where there is nothing to
|
|
* read.
|
|
*
|
|
* `uptime null, met=false` was correct and unreadable. A reader should not
|
|
* have to know that zero samples is not 100% in order to understand the line.
|
|
*/
|
|
export function describeMeasurement(s: MeasurementEvidence): string {
|
|
if (s.samples === 0) {
|
|
return `No samples for ${s.metric}${s.window ? ` in ${s.window}` : ''}; it cannot be computed.`
|
|
}
|
|
if (s.value === null) {
|
|
return `${s.metric} could not be computed from the ${s.samples} available sample${s.samples === 1 ? '' : 's'}.`
|
|
}
|
|
return `${s.metric} is ${s.value}${s.unit ?? ''} over ${s.samples} sample${s.samples === 1 ? '' : 's'}${s.window ? ` (${s.window})` : ''}.`
|
|
}
|