feat(aphoria): add --show-claims flag to display all extracted claims

Implements the --show-claims feature requested by users who need to verify
extractors are working correctly and debug false negatives.

Changes:
- Add `claims: Option<Vec<ExtractedClaim>>` field to ScanResult
- Add `--show-claims` CLI flag to scan command
- Add `show_claims: bool` parameter to ScanArgs
- Populate claims in scanner when flag is set (sorted by file, then line)
- Display claims in all output formats:
  * Table: New "Extracted Claims" section with concept/value/file/line/confidence
  * JSON: Top-level `claims` array with full claim details
  * Markdown: "## Extracted Claims" section with table
  * SARIF: Informational-level results (level: "note") for IDE integration

User outcome:
- `aphoria scan . --show-claims` displays all claims (not just conflicts)
- Users can verify extractors detected their code patterns
- Users can debug false negatives by seeing what WAS extracted
- Builds trust through transparency

Quality:
- Zero breaking changes (opt-in flag, backward compatible)
- All tests passing (943 passed)
- Clippy clean (no warnings)
- Manual testing verified all 4 output formats

Addresses user feedback from /home/jml/Workspace/maxwell/.aphoria/.notes-for-aphoria-team

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jml 2026-02-08 00:37:43 +00:00
parent c65066fd1c
commit e73bf3c4b7
20 changed files with 268 additions and 23 deletions

View File

@ -49,6 +49,7 @@ pub async fn show_diff(config: &AphoriaConfig) -> Result<String, AphoriaError> {
sync: false, // Diff does not write observations
file_source: crate::types::FileSource::All,
benchmark: false,
show_claims: false,
};
let result = run_scan(args, config).await?;

View File

@ -88,6 +88,10 @@ pub enum Commands {
/// Run performance benchmark with timing breakdown.
#[arg(long)]
benchmark: bool,
/// Show all extracted claims in the output
#[arg(long)]
show_claims: bool,
},
/// Manage acknowledgments (mark conflicts as intentional)

View File

@ -64,12 +64,22 @@ pub async fn handle_command(command: Commands, config: &AphoriaConfig) -> ExitCo
staged,
community_preview,
benchmark,
show_claims,
} => {
if community_preview {
scan::handle_community_preview(path, config).await
} else {
scan::handle_scan(
path, format, exit_code, strict, persist, debug, sync, staged, benchmark,
path,
format,
exit_code,
strict,
persist,
debug,
sync,
staged,
benchmark,
show_claims,
config,
)
.await

View File

@ -15,6 +15,7 @@ pub async fn handle_scan(
sync: bool,
staged: bool,
benchmark: bool,
show_claims: bool,
config: &AphoriaConfig,
) -> ExitCode {
// Validate: --sync requires --persist
@ -36,6 +37,7 @@ pub async fn handle_scan(
sync,
file_source,
benchmark,
show_claims,
};
// Apply stricter thresholds if requested
@ -99,6 +101,7 @@ pub async fn handle_community_preview(
sync: false,
file_source: FileSource::All,
benchmark: false,
show_claims: false,
};
let claims = match extract_claims(&args, config).await {

View File

@ -18,10 +18,11 @@ use crate::types::Language;
///
/// Used to classify the type of value extracted from code patterns,
/// enabling proper placeholder generation during normalization.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ValueType {
/// Text/string value (e.g., "TLSv1.2", "admin")
#[default]
Text,
/// Numeric value (e.g., 4096, 30)
Number,
@ -29,12 +30,6 @@ pub enum ValueType {
Boolean,
}
impl Default for ValueType {
fn default() -> Self {
Self::Text
}
}
impl std::fmt::Display for ValueType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {

View File

@ -11,12 +11,13 @@ use uuid::Uuid;
///
/// Patterns move through this state machine as they age, become obsolete,
/// or are replaced by better alternatives.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum KnowledgeStatus {
/// Pattern is active and should trigger matches.
///
/// This is the default state for all patterns.
#[default]
Active,
/// Pattern is deprecated but still active.
@ -65,12 +66,6 @@ pub enum KnowledgeStatus {
},
}
impl Default for KnowledgeStatus {
fn default() -> Self {
Self::Active
}
}
impl KnowledgeStatus {
/// Check if this status is active (pattern should match during scans).
pub fn is_active(&self) -> bool {

View File

@ -153,6 +153,26 @@ impl ReportFormatter for JsonReport {
"deprecated_usages": deprecated_json,
});
// Add claims if present
if let Some(claims) = &result.claims {
let claims_json: Vec<serde_json::Value> = claims
.iter()
.map(|claim| {
serde_json::json!({
"concept_path": claim.concept_path,
"predicate": claim.predicate,
"value": object_value_to_json(&claim.value),
"file": claim.file,
"line": claim.line,
"matched_text": claim.matched_text,
"confidence": claim.confidence,
"description": claim.description,
})
})
.collect();
report["claims"] = serde_json::json!(claims_json);
}
// Add timing if benchmark mode was enabled
if let Some(timing) = &result.timing {
let mut timing_json = serde_json::json!({
@ -215,6 +235,7 @@ mod tests {
debug: false,
observations_recorded: 0,
timing: None,
claims: None,
deprecated_usages: vec![],
};

View File

@ -32,6 +32,31 @@ impl ReportFormatter for MarkdownReport {
if result.conflicts.is_empty() && result.drifts.is_empty() {
out.push_str("No conflicts or drifts found.\n");
// Still show claims if requested, even with no conflicts
if let Some(claims) = &result.claims {
out.push_str("\n## Extracted Claims\n\n");
if claims.is_empty() {
out.push_str("No claims extracted.\n\n");
} else {
out.push_str("| Concept | Value | File | Line | Confidence |\n");
out.push_str("|---------|-------|------|------|------------|\n");
for claim in claims {
out.push_str(&format!(
"| `{}` | `{}` | `{}` | {} | {:.0}% |\n",
extract_leaf_concept(&claim.concept_path),
object_value_display(&claim.value),
claim.file,
claim.line,
claim.confidence * 100.0,
));
}
out.push('\n');
}
}
return out;
}
@ -233,6 +258,30 @@ impl ReportFormatter for MarkdownReport {
}
}
// Extracted Claims section
if let Some(claims) = &result.claims {
out.push_str("## Extracted Claims\n\n");
if claims.is_empty() {
out.push_str("No claims extracted.\n\n");
} else {
out.push_str("| Concept | Value | File | Line | Confidence |\n");
out.push_str("|---------|-------|------|------|------------|\n");
for claim in claims {
out.push_str(&format!(
"| `{}` | `{}` | `{}` | {} | {:.0}% |\n",
extract_leaf_concept(&claim.concept_path),
object_value_display(&claim.value),
claim.file,
claim.line,
claim.confidence * 100.0,
));
}
out.push('\n');
}
}
out
}
}
@ -280,6 +329,7 @@ mod tests {
debug: false,
observations_recorded: 0,
timing: None,
claims: None,
deprecated_usages: vec![],
};

View File

@ -297,10 +297,69 @@ impl ReportFormatter for SarifReport {
})
.collect();
// Add claims if present (as informational-level results)
let claims_results: Vec<serde_json::Value> = if let Some(claims) = &result.claims {
// Add a single rule for all claims
if !claims.is_empty() && !rule_indices.contains_key("aphoria/claim") {
let idx = rules.len();
rule_indices.insert("aphoria/claim".to_string(), idx);
rules.push(serde_json::json!({
"id": "aphoria/claim",
"shortDescription": {
"text": "Extracted claim (no conflict detected)",
},
"defaultConfiguration": {
"level": "note",
},
"helpUri": "https://github.com/orchard9/aphoria/docs/claims",
}));
}
claims
.iter()
.map(|claim| {
let rule_index = rule_indices.get("aphoria/claim").copied().unwrap_or(0);
let message = format!(
"{}\n{} = {}",
claim.description,
claim.predicate,
object_value_display(&claim.value)
);
serde_json::json!({
"ruleId": "aphoria/claim",
"ruleIndex": rule_index,
"level": "note",
"message": {
"text": message,
},
"locations": [{
"physicalLocation": {
"artifactLocation": {
"uri": claim.file,
"uriBaseId": "%SRCROOT%",
},
"region": {
"startLine": claim.line,
}
}
}],
"properties": {
"concept_path": claim.concept_path,
"confidence": claim.confidence,
}
})
})
.collect()
} else {
Vec::new()
};
// Combine all results
let mut all_results = results;
all_results.extend(drift_results);
all_results.extend(deprecated_results);
all_results.extend(claims_results);
let sarif = serde_json::json!({
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
@ -393,6 +452,7 @@ mod tests {
debug: false,
observations_recorded: 0,
timing: None,
claims: None,
deprecated_usages: vec![],
};

View File

@ -32,6 +32,40 @@ impl ReportFormatter for TableReport {
if result.conflicts.is_empty() && result.drifts.is_empty() {
output.push_str("No conflicts or drifts found.\n");
// Still show claims if requested, even with no conflicts
if let Some(claims) = &result.claims {
if claims.is_empty() {
output.push_str("\nExtracted Claims:\n\n");
output.push_str(" No claims extracted.\n");
} else {
output.push_str("\nExtracted Claims:\n\n");
let mut claims_table = Table::new();
claims_table.set_content_arrangement(ContentArrangement::Dynamic);
claims_table.set_header(vec![
Cell::new("Concept"),
Cell::new("Value"),
Cell::new("File"),
Cell::new("Line").set_alignment(CellAlignment::Right),
Cell::new("Confidence").set_alignment(CellAlignment::Right),
]);
for claim in claims {
claims_table.add_row(vec![
Cell::new(extract_leaf_concept(&claim.concept_path)),
Cell::new(object_value_display(&claim.value)),
Cell::new(&claim.file),
Cell::new(claim.line.to_string()).set_alignment(CellAlignment::Right),
Cell::new(format!("{:.0}%", claim.confidence * 100.0))
.set_alignment(CellAlignment::Right),
]);
}
output.push_str(&claims_table.to_string());
output.push('\n');
}
}
return output;
}
@ -220,6 +254,39 @@ impl ReportFormatter for TableReport {
}
}
// Extracted Claims section
if let Some(claims) = &result.claims {
if claims.is_empty() {
output.push_str("\nExtracted Claims:\n\n");
output.push_str(" No claims extracted.\n\n");
} else {
output.push_str("\nExtracted Claims:\n\n");
let mut claims_table = Table::new();
claims_table.set_content_arrangement(ContentArrangement::Dynamic);
claims_table.set_header(vec![
Cell::new("Concept"),
Cell::new("Value"),
Cell::new("File"),
Cell::new("Line").set_alignment(CellAlignment::Right),
Cell::new("Confidence").set_alignment(CellAlignment::Right),
]);
for claim in claims {
claims_table.add_row(vec![
Cell::new(extract_leaf_concept(&claim.concept_path)),
Cell::new(object_value_display(&claim.value)),
Cell::new(&claim.file),
Cell::new(claim.line.to_string()).set_alignment(CellAlignment::Right),
Cell::new(format!("{:.0}%", claim.confidence * 100.0))
.set_alignment(CellAlignment::Right),
]);
}
output.push_str(&claims_table.to_string());
output.push('\n');
}
}
// Footer summary
let block_count = result.count_by_verdict(Verdict::Block);
let flag_count = result.count_by_verdict(Verdict::Flag);
@ -335,6 +402,7 @@ mod tests {
debug: false,
observations_recorded: 0,
timing: None,
claims: None,
deprecated_usages: vec![],
}
}

View File

@ -87,7 +87,16 @@ pub async fn run_scan(args: ScanArgs, config: &AphoriaConfig) -> Result<ScanResu
None
};
// 6. Build result
// 6. Populate claims if requested (clone and sort by file, then line)
let claims = if args.show_claims {
let mut sorted = all_claims.to_vec();
sorted.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
Some(sorted)
} else {
None
};
// 7. Build result
let project_name =
project_root.file_name().and_then(|s| s.to_str()).unwrap_or("unknown").to_string();
@ -102,6 +111,7 @@ pub async fn run_scan(args: ScanArgs, config: &AphoriaConfig) -> Result<ScanResu
debug: args.debug,
observations_recorded: result.observations_recorded,
timing,
claims,
deprecated_usages: vec![], // TODO: Populate from lifecycle store during scan
})
}

View File

@ -45,6 +45,7 @@ async fn test_conflict_detection_tls_disabled() {
sync: false,
file_source: FileSource::All,
benchmark: false,
show_claims: false,
};
let mut config = AphoriaConfig::default();
@ -112,6 +113,7 @@ async fn test_conflict_detection_jwt_audience_disabled() {
sync: false,
file_source: FileSource::All,
benchmark: false,
show_claims: false,
};
let mut config = AphoriaConfig::default();
@ -181,6 +183,7 @@ async fn test_no_conflicts_when_compliant() {
sync: false,
file_source: FileSource::All,
benchmark: false,
show_claims: false,
};
let mut config = AphoriaConfig::default();

View File

@ -68,6 +68,7 @@ fn test_scan_result_has_drifts() {
observations_recorded: 0,
timing: None,
deprecated_usages: vec![],
claims: None,
};
assert!(result.has_drifts());
@ -101,6 +102,7 @@ fn test_drift_json_output_format() {
observations_recorded: 0,
timing: None,
deprecated_usages: vec![],
claims: None,
};
let formatter = JsonReport;
@ -136,6 +138,7 @@ fn test_drift_sarif_output_format() {
observations_recorded: 0,
timing: None,
deprecated_usages: vec![],
claims: None,
};
let formatter = SarifReport;
@ -173,6 +176,7 @@ fn test_drift_table_output_format() {
observations_recorded: 0,
timing: None,
deprecated_usages: vec![],
claims: None,
};
let formatter = TableReport;

View File

@ -128,6 +128,7 @@ version = "0.1.0"
sync: false,
file_source: FileSource::All,
benchmark: false,
show_claims: false,
};
let result = run_scan(args, &config_b).await.expect("scan should succeed");

View File

@ -39,6 +39,7 @@ async fn test_scan_returns_result() {
sync: false,
file_source: FileSource::All,
benchmark: false,
show_claims: false,
};
let mut config = AphoriaConfig::default();

View File

@ -32,6 +32,7 @@ version = "0.1.0"
sync: false,
file_source: FileSource::All,
benchmark: false,
show_claims: false,
};
let mut config = AphoriaConfig::default();
@ -87,6 +88,7 @@ version = "0.1.0"
sync: false,
file_source: FileSource::All,
benchmark: false,
show_claims: false,
};
let mut config = AphoriaConfig::default();
@ -151,6 +153,7 @@ version = "0.1.0"
sync: false,
file_source: FileSource::All,
benchmark: false,
show_claims: false,
};
let ephemeral_result = run_scan(ephemeral_args, &config).await.expect("ephemeral scan");
@ -165,6 +168,7 @@ version = "0.1.0"
sync: false,
file_source: FileSource::All,
benchmark: false,
show_claims: false,
};
let persistent_result = run_scan(persistent_args, &config).await.expect("persistent scan");
@ -241,6 +245,7 @@ version = "0.1.0"
sync: true, // Enable observation write-back
file_source: FileSource::All,
benchmark: false,
show_claims: false,
};
let result = run_scan(args, &config).await.expect("scan should succeed");
@ -291,6 +296,7 @@ version = "0.1.0"
sync: false, // Disabled
file_source: FileSource::All,
benchmark: false,
show_claims: false,
};
let result = run_scan(args, &config).await.expect("scan should succeed");
@ -335,6 +341,7 @@ version = "0.1.0"
sync: false, // Would be ignored anyway in ephemeral mode
file_source: FileSource::All,
benchmark: false,
show_claims: false,
};
let result = run_scan(args, &config).await.expect("scan should succeed");
@ -388,6 +395,7 @@ version = "0.1.0"
sync: true, // Record observations
file_source: FileSource::All,
benchmark: false,
show_claims: false,
};
let result1 = run_scan(args1, &config).await.expect("first scan should succeed");
@ -415,6 +423,7 @@ version = "0.1.0"
sync: false, // Don't need to sync on drift detection
file_source: FileSource::All,
benchmark: false,
show_claims: false,
};
let result2 = run_scan(args2, &config).await.expect("second scan should succeed");
@ -470,6 +479,7 @@ version = "0.1.0"
sync: false,
file_source: FileSource::All,
benchmark: false,
show_claims: false,
};
let result = run_scan(args, &config).await.expect("scan should succeed");

View File

@ -238,6 +238,7 @@ async fn test_staged_with_persist_and_sync() {
sync: false,
file_source: FileSource::Staged,
benchmark: false,
show_claims: false,
};
let result = run_scan(args, &config).await.expect("scan should succeed");

View File

@ -60,6 +60,11 @@ pub struct ScanArgs {
/// When enabled, timing measurements are captured for each scan phase
/// and included in the output.
pub benchmark: bool,
/// Show all extracted claims in the output.
/// When enabled, all claims (not just conflicts) are included in the
/// scan result, sorted by file path and line number.
pub show_claims: bool,
}
/// Arguments for the acknowledge command.

View File

@ -44,6 +44,12 @@ pub struct ScanResult {
/// Benchmark timing breakdown (only populated when --benchmark is set).
pub timing: Option<ScanTiming>,
/// Extracted claims (only populated when --show-claims is enabled).
///
/// When present, contains all claims extracted during the scan, sorted by
/// file path and line number for easy verification and debugging.
pub claims: Option<Vec<ExtractedClaim>>,
/// Deprecated pattern usages detected.
///
/// Populated when deprecated patterns are matched during scan.
@ -87,6 +93,7 @@ impl ScanResult {
debug: false,
observations_recorded: 0,
timing: None,
claims: None,
deprecated_usages: vec![],
}
}
@ -428,6 +435,7 @@ mod tests {
debug: false,
observations_recorded: 0,
timing: None,
claims: None,
deprecated_usages: vec![],
};

View File

@ -10,10 +10,11 @@ use rkyv::{Archive, Deserialize, Serialize};
/// - **Closed**: Normal operation, requests are allowed.
/// - **Open**: Circuit has tripped, requests are blocked.
/// - **HalfOpen**: Testing after timeout, one request allowed.
#[derive(Archive, Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Archive, Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[archive(check_bytes)]
pub enum CircuitState {
/// Normal operation - requests allowed.
#[default]
Closed,
/// Circuit tripped - requests blocked until timeout.
@ -34,12 +35,6 @@ impl CircuitState {
}
}
impl Default for CircuitState {
fn default() -> Self {
Self::Closed
}
}
/// Types of failures that trip the circuit breaker.
///
/// Each failure type counts toward the threshold. The type is recorded