///|
/// Options for producing stable manifest text suitable for reviews and snapshots.
/// Normalization is deliberately opt-in: it returns a new specification and never
/// changes the caller's original data.
pub(all) struct NormalizationOptions {
sort_records : Bool
sort_list_values : Bool
deduplicate_list_values : Bool
compact_whitespace : Bool
drop_empty_values : Bool
} derive(Eq, Debug)
///|
pub(all) struct NormalizationChange {
category : String
subject : String
before : String
after : String
} derive(Eq, Debug)
///|
pub(all) struct NormalizationResult {
spec : BoundarySpec
changes : Array[NormalizationChange]
source_issues : Array[TraceIssue]
} derive(Eq, Debug)
///|
pub fn default_normalization_options() -> NormalizationOptions {
{
sort_records: true,
sort_list_values: true,
deduplicate_list_values: true,
compact_whitespace: true,
drop_empty_values: true,
}
}
///|
pub fn preserve_order_normalization_options() -> NormalizationOptions {
{
sort_records: false,
sort_list_values: false,
deduplicate_list_values: true,
compact_whitespace: true,
drop_empty_values: true,
}
}
///|
/// Parse, normalize, and describe the changes made to a manifest. Invalid source
/// records are retained as source issues; callers should only overwrite files
/// when `result.is_lossless()` is true.
pub fn normalize_manifest(raw : StringView) -> NormalizationResult {
normalize_manifest_with_options(raw, default_normalization_options())
}
///|
pub fn normalize_manifest_with_options(
raw : StringView,
options : NormalizationOptions,
) -> NormalizationResult {
let source = parse_manifest(raw)
normalize_spec(source, options)
}
///|
pub fn normalize_spec(
source : BoundarySpec,
options : NormalizationOptions,
) -> NormalizationResult {
let spec = {
project: normalize_text(source.project, options),
purpose: normalize_text(source.purpose, options),
scopes: normalize_strings(source.scopes, options),
out_of_scope: normalize_strings(source.out_of_scope, options),
capabilities: normalize_capabilities(source.capabilities, options),
requirements: normalize_requirements(source.requirements, options),
evidence: normalize_evidence(source.evidence, options),
issues: source.issues,
}
{
spec,
changes: collect_normalization_changes(source, spec),
source_issues: source.issues,
}
}
///|
pub fn NormalizationResult::is_lossless(self : NormalizationResult) -> Bool {
self.source_issues.is_empty()
}
///|
pub fn NormalizationResult::changed(self : NormalizationResult) -> Bool {
!self.changes.is_empty()
}
///|
pub fn NormalizationResult::to_manifest(self : NormalizationResult) -> String {
spec_to_manifest(self.spec)
}
///|
pub fn NormalizationResult::to_markdown(self : NormalizationResult) -> String {
let lines : Array[String] = []
lines.push("# CapsuleTrace Normalization")
lines.push("")
lines.push("- Project: " + printable(self.spec.project))
lines.push("- Lossless: " + bool_label(self.is_lossless()))
lines.push("- Changes: " + self.changes.length().to_string())
lines.push("- Parse issues: " + self.source_issues.length().to_string())
lines.push("")
lines.push("## Changes")
lines.push("")
lines.push("| Category | Subject | Before | After |")
lines.push("| --- | --- | --- | --- |")
if self.changes.is_empty() {
lines.push("| info | all | already normalized | already normalized |")
} else {
for change in self.changes {
lines.push(
"| " +
markdown_cell(change.category) +
" | " +
markdown_cell(change.subject) +
" | " +
markdown_cell(change.before) +
" | " +
markdown_cell(change.after) +
" |",
)
}
}
if !self.source_issues.is_empty() {
lines.push("")
lines.push("## Source Issues")
lines.push("")
for issue in self.source_issues {
lines.push(
"- line " +
issue.line.to_string() +
": " +
issue.kind.label() +
" - " +
issue.message,
)
}
}
lines.join("\n")
}
///|
/// Render valid manifest records in canonical record order. This is useful for
/// snapshot tests and generated review artifacts, not as a replacement for a
/// parser error message.
pub fn spec_to_manifest(spec : BoundarySpec) -> String {
let lines : Array[String] = []
if !spec.project.trim().is_empty() {
lines.push("project: " + spec.project)
}
if !spec.purpose.trim().is_empty() {
lines.push("purpose: " + spec.purpose)
}
for scope in spec.scopes {
lines.push("scope: " + scope)
}
for non_goal in spec.out_of_scope {
lines.push("out-of-scope: " + non_goal)
}
for capability in spec.capabilities {
lines.push(capability_to_manifest_line(capability))
}
for requirement in spec.requirements {
lines.push(requirement_to_manifest_line(requirement))
}
for item in spec.evidence {
lines.push(evidence_to_manifest_line(item))
}
lines.join("\n")
}
///|
pub fn capability_to_manifest_line(capability : Capability) -> String {
"capability: " +
capability.id +
" | " +
capability.title +
" | " +
capability.inputs.join(", ") +
" | " +
capability.outputs.join(", ") +
" | " +
capability.constraints.join(", ")
}
///|
pub fn requirement_to_manifest_line(requirement : Requirement) -> String {
"requirement: " +
requirement.id +
" | " +
requirement.level.label() +
" | " +
requirement.text +
" | " +
requirement.capabilities.join(", ")
}
///|
pub fn evidence_to_manifest_line(item : Evidence) -> String {
"evidence: " +
item.id +
" | " +
item.kind.label() +
" | " +
item.target +
" | " +
item.status.label() +
" | " +
item.requirements.join(", ") +
" | " +
item.note
}
///|
fn normalize_capabilities(
items : Array[Capability],
options : NormalizationOptions,
) -> Array[Capability] {
let result : Array[Capability] = []
for item in items {
result.push({
id: normalize_text(item.id, options),
title: normalize_text(item.title, options),
inputs: normalize_strings(item.inputs, options),
outputs: normalize_strings(item.outputs, options),
constraints: normalize_strings(item.constraints, options),
})
}
if options.sort_records {
sort_capabilities_by_id(result)
} else {
result
}
}
///|
fn normalize_requirements(
items : Array[Requirement],
options : NormalizationOptions,
) -> Array[Requirement] {
let result : Array[Requirement] = []
for item in items {
result.push({
id: normalize_text(item.id, options),
level: item.level,
text: normalize_text(item.text, options),
capabilities: normalize_strings(item.capabilities, options),
})
}
if options.sort_records {
sort_requirements_by_id(result)
} else {
result
}
}
///|
fn normalize_evidence(
items : Array[Evidence],
options : NormalizationOptions,
) -> Array[Evidence] {
let result : Array[Evidence] = []
for item in items {
result.push({
id: normalize_text(item.id, options),
kind: item.kind,
target: normalize_text(item.target, options),
status: item.status,
requirements: normalize_strings(item.requirements, options),
note: normalize_text(item.note, options),
})
}
if options.sort_records {
sort_evidence_by_id(result)
} else {
result
}
}
///|
fn normalize_strings(
values : Array[String],
options : NormalizationOptions,
) -> Array[String] {
let result : Array[String] = []
for value in values {
let normalized = normalize_text(value, options)
let may_push = !options.drop_empty_values || !normalized.is_empty()
let is_new = !options.deduplicate_list_values ||
!result.contains(normalized)
if may_push && is_new {
result.push(normalized)
}
}
if options.sort_list_values {
sort_strings_ascending(result)
} else {
result
}
}
///|
/// The manifests involved here are intentionally small. Insertion sorting avoids
/// target-specific ordering surprises while keeping canonical output stable.
pub fn sort_strings_ascending(values : Array[String]) -> Array[String] {
let mut sorted : Array[String] = []
for value in values {
let next : Array[String] = []
let mut inserted = false
for existing in sorted {
if !inserted && value.lexical_compare(existing) < 0 {
next.push(value)
inserted = true
}
next.push(existing)
}
if !inserted {
next.push(value)
}
sorted = next
}
sorted
}
///|
fn sort_capabilities_by_id(values : Array[Capability]) -> Array[Capability] {
let mut sorted : Array[Capability] = []
for value in values {
let next : Array[Capability] = []
let mut inserted = false
for existing in sorted {
if !inserted && value.id.lexical_compare(existing.id) < 0 {
next.push(value)
inserted = true
}
next.push(existing)
}
if !inserted {
next.push(value)
}
sorted = next
}
sorted
}
///|
fn sort_requirements_by_id(values : Array[Requirement]) -> Array[Requirement] {
let mut sorted : Array[Requirement] = []
for value in values {
let next : Array[Requirement] = []
let mut inserted = false
for existing in sorted {
if !inserted && value.id.lexical_compare(existing.id) < 0 {
next.push(value)
inserted = true
}
next.push(existing)
}
if !inserted {
next.push(value)
}
sorted = next
}
sorted
}
///|
fn sort_evidence_by_id(values : Array[Evidence]) -> Array[Evidence] {
let mut sorted : Array[Evidence] = []
for value in values {
let next : Array[Evidence] = []
let mut inserted = false
for existing in sorted {
if !inserted && value.id.lexical_compare(existing.id) < 0 {
next.push(value)
inserted = true
}
next.push(existing)
}
if !inserted {
next.push(value)
}
sorted = next
}
sorted
}
///|
fn normalize_text(value : String, options : NormalizationOptions) -> String {
let trimmed = value.trim().to_owned()
if options.compact_whitespace {
compact_whitespace(trimmed)
} else {
trimmed
}
}
///|
fn compact_whitespace(value : String) -> String {
let mut compacted = value.replace_all(old="\t", new=" ")
while compacted.contains(" ") {
compacted = compacted.replace_all(old=" ", new=" ")
}
compacted
}
///|
fn collect_normalization_changes(
before : BoundarySpec,
after : BoundarySpec,
) -> Array[NormalizationChange] {
let changes : Array[NormalizationChange] = []
add_normalization_change(
changes,
"summary",
"project",
before.project,
after.project,
)
add_normalization_change(
changes,
"summary",
"purpose",
before.purpose,
after.purpose,
)
add_normalization_change(
changes,
"summary",
"scope",
before.scopes.join(" | "),
after.scopes.join(" | "),
)
add_normalization_change(
changes,
"summary",
"out-of-scope",
before.out_of_scope.join(" | "),
after.out_of_scope.join(" | "),
)
for item in before.capabilities {
match after.capability(item.id) {
Some(normalized) =>
add_normalization_change(
changes,
"capability",
item.id,
capability_to_manifest_line(item),
capability_to_manifest_line(normalized),
)
None => ()
}
}
for item in before.requirements {
match after.requirement(item.id) {
Some(normalized) =>
add_normalization_change(
changes,
"requirement",
item.id,
requirement_to_manifest_line(item),
requirement_to_manifest_line(normalized),
)
None => ()
}
}
for item in before.evidence {
match after.evidence_item(item.id) {
Some(normalized) =>
add_normalization_change(
changes,
"evidence",
item.id,
evidence_to_manifest_line(item),
evidence_to_manifest_line(normalized),
)
None => ()
}
}
changes
}
///|
fn add_normalization_change(
changes : Array[NormalizationChange],
category : String,
subject : String,
before : String,
after : String,
) -> Unit {
if before != after {
changes.push({ category, subject, before, after })
}
}