Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions artifacts/requirements.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2180,6 +2180,25 @@ artifacts:
status: implemented
tags: [codegen, wit, cli, v0100]

- id: REQ-CODEGEN-SUMMARY
type: requirement
title: codegen prints a per-category summary and a hint when WIT was requested but none emitted
description: >
`spar codegen` shall print a one-line summary breaking the
generated files down by category (`.wit`, `.rs`, workspace, config,
other) together with the requested `--format`, so an operator can
tell at a glance both what got produced and whether the requested
format was honoured. When `--format wit` or `--format both` is
passed (`both` is the default) and zero `.wit` files are emitted,
a diagnostic hint shall follow the summary explaining that WIT
interfaces are generated per AADL `process` subcomponent and
pointing to `--format rust` as the alternative for models with
threads only. This addresses the silent failure mode that
surfaces as "the other files but no wit" when an AADL model has
threads but no processes.
status: implemented
tags: [codegen, wit, cli, diagnostics, v0110]

# Research findings tracked separately in research/findings.yaml

# ── Mermaid M3 (classDiagram + requirementDiagram) ─────────────────────
Expand Down
27 changes: 27 additions & 0 deletions artifacts/verification.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2896,6 +2896,33 @@ artifacts:
- type: satisfies
target: REQ-CODEGEN-WIT-STRICT

- id: TEST-CODEGEN-SUMMARY-HINT
type: feature
title: codegen per-category summary line + empty-WIT hint smoke
description: >
Smoke runs of `spar codegen` confirm the new diagnostic:
threads-only model + default `--format both` emits the summary
`wrote N files (... .rs, ... workspace) (format: both)` followed
by the three-line hint pointing at `process` subcomponents and
`--format rust`; process + `--format wit` emits the summary with
`.wit` count and no hint; threads + `--format rust` emits the
summary with no hint (user did not request WIT). Helpers
`summarise_codegen_output` and `maybe_print_empty_wit_hint` in
crates/spar-cli/src/main.rs are pure functions of the generated
file list + format, ready for promotion to golden tests if the
stdout shape is later contracted.
fields:
method: smoke-test
steps:
- run: cargo run -q -p spar -- codegen --root Threads::Sys.impl --output /tmp/threads-out /tmp/threads.aadl
- run: cargo run -q -p spar -- codegen --root Proc::Sys.impl --format wit --output /tmp/proc-out /tmp/proc.aadl
- run: cargo run -q -p spar -- codegen --root Threads::Sys.impl --format rust --output /tmp/threads-rust-out /tmp/threads.aadl
status: passing
tags: [codegen, wit, cli, diagnostics, v0110]
links:
- type: satisfies
target: REQ-CODEGEN-SUMMARY

# ── Trace-topology fixtures (v0.11.0) ────────────────────────────────────

- id: TEST-TRACE-FIXTURES
Expand Down
111 changes: 107 additions & 4 deletions crates/spar-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2073,12 +2073,15 @@ fn cmd_codegen(args: &[String]) {
let result = spar_codegen::generate(&inst, &config);

if dry_run {
eprintln!("Dry run: {} files would be generated", result.files.len());
eprintln!(
"Dry run: {}",
summarise_codegen_output(&result.files, output_format)
);
for file in &result.files {
println!("{}/{}", output_dir, file.path);
}
maybe_print_empty_wit_hint(&result.files, output_format);
} else {
let mut count = 0;
for file in &result.files {
// Validate that the generated file path does not escape the
// output directory via path traversal (e.g., "../" components).
Expand All @@ -2098,10 +2101,110 @@ fn cmd_codegen(args: &[String]) {
eprintln!("Cannot write {full_path}: {e}");
process::exit(1);
});
count += 1;
}
eprintln!("Generated {count} files in {output_dir}/");
eprintln!(
"codegen: {} to {output_dir}/",
summarise_codegen_output(&result.files, output_format)
);
maybe_print_empty_wit_hint(&result.files, output_format);
}
}

/// Categorise a generated file by extension / well-known name.
///
/// `(extension_tag, count_label)` — the tag is used for the empty-WIT
/// hint, the label is what shows up in the summary line.
fn codegen_file_categories(files: &[spar_codegen::GeneratedFile]) -> [(u32, &'static str); 5] {
let mut wit = 0u32;
let mut rust = 0u32;
let mut workspace = 0u32;
let mut config = 0u32;
let mut other = 0u32;
for file in files {
let path = std::path::Path::new(&file.path);
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
match (ext, name) {
("wit", _) => wit += 1,
("rs", _) => rust += 1,
(_, "Cargo.toml" | "Cargo.lock" | "BUILD.bazel" | "WORKSPACE") => workspace += 1,
("toml", _) => config += 1,
_ => other += 1,
}
}
[
(wit, ".wit"),
(rust, ".rs"),
(workspace, "workspace"),
(config, "config"),
(other, "other"),
]
}

/// One-line summary like
/// `wrote 7 files (1 .wit, 4 .rs, 2 workspace) (format: both)`.
///
/// The format label answers "did spar honour my `--format`?" at a
/// glance, and the per-category counts answer "what got produced?" —
/// the silent default-`both` failure mode (Rust + workspace files but
/// zero `.wit` when the model has no `process` subcomponents) is
/// immediately visible from this line alone.
fn summarise_codegen_output(
files: &[spar_codegen::GeneratedFile],
format: spar_codegen::OutputFormat,
) -> String {
let cats = codegen_file_categories(files);
let format_label = match format {
spar_codegen::OutputFormat::Rust => "rust",
spar_codegen::OutputFormat::Wit => "wit",
spar_codegen::OutputFormat::Both => "both",
};
let mut parts: Vec<String> = Vec::new();
for (n, label) in cats {
if n > 0 {
parts.push(format!("{n} {label}"));
}
}
let body = if parts.is_empty() {
"empty".to_string()
} else {
parts.join(", ")
};
let total = files.len();
let noun = if total == 1 { "file" } else { "files" };
format!("wrote {total} {noun} ({body}) (format: {format_label})")
}

/// Explain the silent failure mode: when WIT was requested (`--format wit`
/// or the default `--format both`) but zero `.wit` files were emitted,
/// the AADL model has no `process` subcomponents to generate interfaces
/// for. This is the most common confusing case — agents see "the other
/// files but no wit" and don't know why — so the hint is worth a few
/// lines of stdout.
fn maybe_print_empty_wit_hint(
files: &[spar_codegen::GeneratedFile],
format: spar_codegen::OutputFormat,
) {
let asked_for_wit = matches!(
format,
spar_codegen::OutputFormat::Wit | spar_codegen::OutputFormat::Both
);
if !asked_for_wit {
return;
}
let wit_count = codegen_file_categories(files)[0].0;
if wit_count > 0 {
return;
}
eprintln!(
"hint: --format {} emits one .wit interface per AADL `process` subcomponent;",
match format {
spar_codegen::OutputFormat::Wit => "wit",
_ => "both",
}
);
eprintln!(" this model has 0 processes — add `process` declarations to the system");
eprintln!(" implementation, or pass --format rust for thread skeletons only.");
}

/// Check that a generated file path is safe to write under the output directory.
Expand Down
7 changes: 5 additions & 2 deletions crates/spar-cli/tests/roundtrip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,12 @@ fn roundtrip_aadl_codegen_produces_all_artifacts() {
);

let stderr = String::from_utf8_lossy(&codegen.stderr);
// Codegen prints a per-category summary of the shape
// codegen: wrote N files (… .wit, … .rs, … workspace) (format: both) to <dir>/
// — see crates/spar-cli/src/main.rs `summarise_codegen_output`.
assert!(
stderr.contains("Generated"),
"should report generation: {stderr}"
stderr.contains("codegen: wrote") && stderr.contains("(format:"),
"should report generation summary: {stderr}"
);

// Verify key files were generated
Expand Down
Loading