///|
/// Append-only audit timeline for tracking keymap evolution in CI.
pub(all) enum TimelineEventKind {
Imported
Analyzed
Approved
Rejected
Migrated
} derive(Eq, @debug.Debug)
///|
pub(all) struct TimelineEvent {
sequence : Int
label : String
actor : String
kind : TimelineEventKind
fingerprint : String
errors : Int
warnings : Int
score : Int
note : String
} derive(Eq, @debug.Debug)
///|
pub(all) struct AuditTimeline {
project : String
events : Array[TimelineEvent]
baseline : String
} derive(Eq, @debug.Debug)
///|
pub(all) struct TimelineDelta {
previous : TimelineEvent?
current : TimelineEvent
score_delta : Int
errors_delta : Int
warnings_delta : Int
regressed : Bool
} derive(Eq, @debug.Debug)
///|
pub fn AuditTimeline::new(
project : String,
baseline? : String = "",
) -> AuditTimeline {
{ project, events: [], baseline }
}
///|
fn event_kind_name(kind : TimelineEventKind) -> String {
match kind {
Imported => "imported"
Analyzed => "analyzed"
Approved => "approved"
Rejected => "rejected"
Migrated => "migrated"
}
}
///|
/// Append an event with a monotonically increasing sequence number.
pub fn timeline_append(
timeline : AuditTimeline,
label : String,
actor : String,
kind : TimelineEventKind,
analysis : Analysis,
note? : String = "",
) -> AuditTimeline {
let sequence = timeline.events.length() + 1
let event = {
sequence,
label,
actor,
kind,
fingerprint: analysis.fingerprint,
errors: analysis.error_count,
warnings: analysis.warning_count,
score: analysis.score,
note,
}
{ ..timeline, events: timeline.events + [event] }
}
///|
/// Record an analysis event and choose Approved/Rejected from the result.
pub fn timeline_record_gate(
timeline : AuditTimeline,
label : String,
actor : String,
analysis : Analysis,
note? : String = "",
) -> AuditTimeline {
let kind = if analysis.ok() { Approved } else { Rejected }
timeline_append(timeline, label, actor, kind, analysis, note~)
}
///|
pub fn timeline_latest(timeline : AuditTimeline) -> TimelineEvent? {
if timeline.events.length() == 0 {
None
} else {
Some(timeline.events[timeline.events.length() - 1])
}
}
///|
/// Compare the latest event with the immediately preceding one.
pub fn timeline_delta(timeline : AuditTimeline) -> TimelineDelta? {
if timeline.events.length() == 0 {
None
} else {
let current = timeline.events[timeline.events.length() - 1]
let previous = if timeline.events.length() > 1 {
Some(timeline.events[timeline.events.length() - 2])
} else {
None
}
let score_delta = match previous {
Some(item) => current.score - item.score
None => 0
}
let errors_delta = match previous {
Some(item) => current.errors - item.errors
None => current.errors
}
let warnings_delta = match previous {
Some(item) => current.warnings - item.warnings
None => current.warnings
}
Some({
previous,
current,
score_delta,
errors_delta,
warnings_delta,
regressed: errors_delta > 0 || score_delta < 0,
})
}
}
///|
/// Return all regressions after the baseline event.
pub fn timeline_regressions(timeline : AuditTimeline) -> Array[TimelineDelta] {
let result : Array[TimelineDelta] = []
for i in 1.. 0 || score_delta < 0 {
result.push({
previous: Some(previous),
current,
score_delta,
errors_delta,
warnings_delta,
regressed: true,
})
}
}
result
}
///|
pub fn timeline_to_json(timeline : AuditTimeline) -> String {
let events : Array[String] = []
for event in timeline.events {
events.push(
"{\"sequence\":" +
event.sequence.to_string() +
",\"label\":" +
json_string(event.label) +
",\"actor\":" +
json_string(event.actor) +
",\"kind\":" +
json_string(event_kind_name(event.kind)) +
",\"fingerprint\":" +
json_string(event.fingerprint) +
",\"errors\":" +
event.errors.to_string() +
",\"warnings\":" +
event.warnings.to_string() +
",\"score\":" +
event.score.to_string() +
",\"note\":" +
json_string(event.note) +
"}",
)
}
"{\"project\":" +
json_string(timeline.project) +
",\"baseline\":" +
json_string(timeline.baseline) +
",\"events\":[" +
events.join(",") +
"]}"
}
///|
pub fn timeline_to_markdown(timeline : AuditTimeline) -> String {
let lines : Array[String] = [
"## Audit timeline: " + timeline.project,
"",
"| # | Label | Actor | Kind | Errors | Warnings | Score | Fingerprint |",
"| ---: | --- | --- | --- | ---: | ---: | ---: | --- |",
]
for event in timeline.events {
lines.push(
"| " +
event.sequence.to_string() +
" | " +
event.label +
" | " +
event.actor +
" | " +
event_kind_name(event.kind) +
" | " +
event.errors.to_string() +
" | " +
event.warnings.to_string() +
" | " +
event.score.to_string() +
" | `" +
event.fingerprint +
"` |",
)
}
lines.push("")
lines.push(
"Regressions detected: " +
timeline_regressions(timeline).length().to_string(),
)
lines.join("\n")
}
///|
/// Return a stable summary useful for release notes.
pub fn timeline_summary(timeline : AuditTimeline) -> String {
let latest = match timeline_latest(timeline) {
Some(event) =>
"latest=" +
event.label +
" score=" +
event.score.to_string() +
" errors=" +
event.errors.to_string()
None => "latest=none"
}
timeline.project +
": events=" +
timeline.events.length().to_string() +
", " +
latest +
", regressions=" +
timeline_regressions(timeline).length().to_string()
}