1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use harn_vm::flow::{IntentClusterer, ObservedAtom, SqliteFlowStore, TextOp, VcsBackend};
7use serde::ser::SerializeStruct;
8use serde::Serialize;
9use serde_json::json;
10use time::format_description::well_known::Rfc3339;
11use time::{Date, Duration, OffsetDateTime, Time};
12
13use crate::cli::{
14 FlowArchivistCommand, FlowArgs, FlowCommand, FlowReplayAuditArgs, FlowShipCommand,
15};
16
17const SHIP_CAPTAIN_EVAL_PACKS: [&str; 4] = [
18 "slice_quality",
19 "false_ship_rate",
20 "coverage_fidelity",
21 "latency_pr_to_merge",
22];
23
24pub(crate) fn run_flow(args: &FlowArgs) -> Result<i32, String> {
25 match &args.command {
26 FlowCommand::ReplayAudit(replay) => run_replay_audit(replay),
27 FlowCommand::Ship(ship) => match &ship.command {
28 FlowShipCommand::Watch(watch) => run_ship_watch(watch),
29 },
30 FlowCommand::Archivist(archivist) => match &archivist.command {
31 FlowArchivistCommand::Scan(scan) => run_archivist_scan(scan),
32 },
33 }
34}
35
36pub(crate) fn run_replay_audit(args: &FlowReplayAuditArgs) -> Result<i32, String> {
37 let since = args.since.as_deref().map(parse_since).transpose()?;
38 if !args.store.is_file() {
39 return Err(format!(
40 "Flow store {} does not exist",
41 args.store.display()
42 ));
43 }
44 let store = SqliteFlowStore::open(&args.store, "replay-audit").map_err(|error| {
45 format!(
46 "failed to open Flow store {}: {error}",
47 args.store.display()
48 )
49 })?;
50
51 let chains = current_predicate_chains(&args.predicate_root, &args.touched_dirs);
52 let diagnostics = discovery_diagnostics(&chains);
53 if has_discovery_error(&diagnostics) {
54 return Err(render_discovery_diagnostics(&diagnostics));
55 }
56 if !args.json {
57 print_discovery_warnings(&diagnostics);
58 }
59
60 let current_predicates = harn_vm::flow::resolve_predicates_for_touched_directories(&chains);
61 let stored = store
62 .shipped_derived_slices_since(since)
63 .map_err(|error| format!("failed to list shipped slices: {error}"))?;
64 let created_at_by_slice = stored
65 .iter()
66 .map(|stored| (stored.slice.id, stored.created_at.clone()))
67 .collect::<std::collections::BTreeMap<_, _>>();
68 let report = harn_vm::flow::replay_audit_report(
69 stored.into_iter().map(|stored| stored.slice),
70 ¤t_predicates,
71 );
72
73 if args.json {
74 let json = serde_json::to_string_pretty(&report)
75 .map_err(|error| format!("failed to encode replay-audit report: {error}"))?;
76 println!("{json}");
77 } else {
78 print_human_report(
79 args.since.as_deref().unwrap_or("beginning"),
80 &report,
81 &created_at_by_slice,
82 );
83 }
84
85 Ok(i32::from(args.fail_on_drift && report.has_drift()))
86}
87
88#[derive(Debug, Clone)]
93pub struct FlowShipWatchInputs<'a> {
94 pub store: &'a Path,
95 pub predicate_root: &'a Path,
96 pub touched_dirs: &'a [PathBuf],
97 pub persona: &'a str,
98 pub mock_pr_out: Option<&'a Path>,
101}
102
103pub fn ship_watch_payload(inputs: &FlowShipWatchInputs<'_>) -> Result<serde_json::Value, String> {
110 let store = open_store(inputs.store)?;
111 let atom_refs = store
112 .list_atoms()
113 .map_err(|error| format!("failed to list Flow atoms: {error}"))?;
114 if atom_refs.is_empty() {
115 return Ok(json!({
119 "status": "idle",
120 "reason": "no_atoms",
121 "persona": inputs.persona,
122 "phase": "phase_0",
123 "mode": "shadow",
124 "autonomy": "propose_with_approval",
125 "receipts_required": true,
126 }));
127 }
128
129 let atoms = atom_refs
130 .iter()
131 .map(|atom_ref| {
132 store
133 .get_atom(atom_ref.atom_id)
134 .map_err(|error| format!("failed to load atom {}: {error}", atom_ref.atom_id))
135 })
136 .collect::<Result<Vec<_>, _>>()?;
137 let intents = IntentClusterer::default().cluster(
138 atoms
139 .iter()
140 .enumerate()
141 .map(|(index, atom)| ObservedAtom::from_atom(atom, (index + 1) as u64)),
142 );
143 let intent_payload = intents
144 .iter()
145 .map(|intent| {
146 json!({
147 "id": intent.id,
148 "goal_description": intent.goal_description,
149 "atoms": intent.atoms,
150 "confidence": intent.confidence,
151 "origin_transcript_span": intent.origin_transcript_span,
152 })
153 })
154 .collect::<Vec<_>>();
155
156 let chains = current_predicate_chains(inputs.predicate_root, inputs.touched_dirs);
157 let diagnostics = discovery_diagnostics(&chains);
158 if has_discovery_error(&diagnostics) {
159 return Err(render_discovery_diagnostics(&diagnostics));
160 }
161 let bootstrap_payload = bootstrap_policy_payload(inputs.predicate_root);
162 let predicates = harn_vm::flow::resolve_predicates_for_touched_directories(&chains);
163 let predicate_payload = predicates
164 .iter()
165 .map(|predicate| {
166 json!({
167 "qualified_name": predicate.qualified_name,
168 "logical_name": predicate.logical_name,
169 "hash": predicate.predicate.source_hash,
170 "kind": predicate.predicate.kind,
171 "relative_dir": predicate.source.relative_dir,
172 "retroactive": predicate.predicate.retroactive,
173 })
174 })
175 .collect::<Vec<_>>();
176 let ceiling = harn_vm::flow::PredicateCeiling::default();
177 let ceiling_outcome = harn_vm::flow::enforce_predicate_ceiling(&predicates, &ceiling);
178 let ceiling_payload = serialize_ceiling_outcome(&ceiling_outcome, &ceiling);
179 let validation_status = match ceiling_outcome.violation().map(|v| v.level) {
180 None => "ok",
181 Some(harn_vm::flow::PredicateCeilingLevel::RequireApproval) => "require_approval",
182 Some(harn_vm::flow::PredicateCeilingLevel::Block) => "blocked",
183 };
184
185 let atom_ids: Vec<_> = atom_refs.iter().map(|atom| atom.atom_id).collect();
186 let slice = store
187 .derive_slice(&atom_ids)
188 .map_err(|error| format!("failed to derive candidate slice: {error}"))?;
189 let ship_receipt = store
190 .ship_slice(&slice)
191 .map_err(|error| format!("failed to persist Ship Captain receipt: {error}"))?;
192 let created_at = OffsetDateTime::now_utc()
193 .format(&Rfc3339)
194 .map_err(|error| format!("failed to format receipt timestamp: {error}"))?;
195 let mock_pr = json!({
196 "number": 0,
197 "state": "open",
198 "url": format!("mock://github/pull/{}", slice.id),
199 "title": format!("Flow slice {}", slice.id),
200 "body": format!(
201 "Shadow-mode Ship Captain candidate slice.\n\nAtoms: {}\nIntents: {}\nPredicates discovered: {}\nValidation: {}\n\nNo remote PR was opened.",
202 slice.atoms.len(),
203 intents.len(),
204 predicates.len(),
205 validation_status,
206 ),
207 "requires_approval": true,
208 "validation_status": validation_status,
209 });
210 let payload = json!({
211 "status": "mock_pr_opened",
212 "persona": inputs.persona,
213 "phase": "phase_0",
214 "mode": "shadow",
215 "autonomy": "propose_with_approval",
216 "receipts_required": true,
217 "created_at": created_at,
218 "slice": {
219 "id": slice.id,
220 "atoms": slice.atoms,
221 "atom_count": slice.atoms.len(),
222 },
223 "intents": intent_payload,
224 "predicate_validation": {
225 "predicate_root": inputs.predicate_root,
226 "touched_dirs": if inputs.touched_dirs.is_empty() {
227 vec![PathBuf::from(".")]
228 } else {
229 inputs.touched_dirs.to_vec()
230 },
231 "status": validation_status,
232 "predicates": predicate_payload,
233 "ceiling": ceiling_payload,
234 "bootstrap_policy": bootstrap_payload,
235 "diagnostics": diagnostics.iter().map(|(path, diagnostic)| json!({
236 "path": path,
237 "severity": discovery_severity_label(diagnostic.severity),
238 "message": diagnostic.message,
239 })).collect::<Vec<_>>(),
240 },
241 "ship_receipt": {
242 "slice_id": ship_receipt.slice_id,
243 "commit": ship_receipt.commit,
244 "ref_name": ship_receipt.ref_name,
245 },
246 "mock_pr": mock_pr,
247 "eval_packs": SHIP_CAPTAIN_EVAL_PACKS,
248 });
249
250 if let Some(path) = inputs.mock_pr_out {
251 write_json(path, &payload)
252 .map_err(|error| format!("failed to write mock PR receipt: {error}"))?;
253 }
254 Ok(payload)
255}
256
257fn run_ship_watch(args: &crate::cli::FlowShipWatchArgs) -> Result<i32, String> {
258 let inputs = FlowShipWatchInputs {
259 store: &args.store,
260 predicate_root: &args.predicate_root,
261 touched_dirs: &args.touched_dirs,
262 persona: &args.persona,
263 mock_pr_out: args.mock_pr_out.as_deref(),
264 };
265
266 if !args.json {
267 let chains = current_predicate_chains(&args.predicate_root, &args.touched_dirs);
268 let diagnostics = discovery_diagnostics(&chains);
269 if !has_discovery_error(&diagnostics) {
270 print_discovery_warnings(&diagnostics);
271 }
272 }
273
274 let payload = ship_watch_payload(&inputs)?;
275 let summary = match payload.get("status").and_then(|status| status.as_str()) {
276 Some("idle") => "Ship Captain idle: no atoms in the Flow store.".to_string(),
277 _ => match payload
278 .get("slice")
279 .and_then(|slice| slice.get("id"))
280 .and_then(|id| id.as_str())
281 {
282 Some(slice_id) => format!("mock PR opened for candidate slice {slice_id}"),
283 None => "Ship Captain receipt emitted.".to_string(),
284 },
285 };
286 print_payload(args.json, &summary, &payload);
287 Ok(0)
288}
289
290fn serialize_ceiling_outcome(
291 outcome: &harn_vm::flow::PredicateCeilingOutcome,
292 ceiling: &harn_vm::flow::PredicateCeiling,
293) -> serde_json::Value {
294 use harn_vm::flow::{PredicateCeilingLevel, PredicateCeilingOutcome};
295 let mut payload = json!({
296 "count": outcome.count(),
297 "require_approval_threshold": ceiling.require_approval_threshold,
298 "block_threshold": ceiling.block_threshold,
299 });
300 match outcome {
301 PredicateCeilingOutcome::Within { .. } => {
302 payload["status"] = json!("within");
303 }
304 PredicateCeilingOutcome::Exceeded(violation) => {
305 payload["status"] = json!(match violation.level {
306 PredicateCeilingLevel::RequireApproval => "require_approval",
307 PredicateCeilingLevel::Block => "blocked",
308 });
309 payload["threshold"] = json!(violation.threshold);
310 payload["message"] = json!(violation.message());
311 payload["top_contributors"] = json!(violation
312 .top_contributors
313 .iter()
314 .map(|item| json!({
315 "relative_dir": item.relative_dir,
316 "count": item.count,
317 }))
318 .collect::<Vec<_>>());
319 }
320 }
321 payload
322}
323
324fn bootstrap_policy_payload(predicate_root: &Path) -> serde_json::Value {
325 use harn_vm::flow::Approver;
326 let Some(discovered) = harn_vm::flow::discover_bootstrap_policy(predicate_root) else {
327 return json!({
328 "status": "absent",
329 "path": predicate_root.join(harn_vm::flow::META_INVARIANTS_FILE),
330 });
331 };
332 let maintainers = discovered
333 .policy
334 .maintainers
335 .iter()
336 .map(|approver| match approver {
337 Approver::Role { name } => json!({"kind": "role", "id": name}),
338 Approver::Principal { id } => json!({"kind": "principal", "id": id}),
339 })
340 .collect::<Vec<_>>();
341 let diagnostics = discovered
342 .diagnostics
343 .iter()
344 .map(|diagnostic| {
345 json!({
346 "severity": discovery_severity_label(diagnostic.severity),
347 "message": diagnostic.message,
348 })
349 })
350 .collect::<Vec<_>>();
351 json!({
352 "status": "present",
353 "path": discovered.path,
354 "hash": discovered.policy.hash,
355 "maintainers": maintainers,
356 "diagnostics": diagnostics,
357 })
358}
359
360fn discovery_severity_label(severity: harn_vm::flow::DiscoveryDiagnosticSeverity) -> &'static str {
361 match severity {
362 harn_vm::flow::DiscoveryDiagnosticSeverity::Warning => "warning",
363 harn_vm::flow::DiscoveryDiagnosticSeverity::Error => "error",
364 }
365}
366
367fn run_archivist_scan(args: &crate::cli::FlowArchivistScanArgs) -> Result<i32, String> {
368 let repo = args
369 .repo
370 .canonicalize()
371 .unwrap_or_else(|_| args.repo.clone());
372 let source_date = OffsetDateTime::now_utc().date().to_string();
373 let inventory = inventory_repo(&repo);
374 let stack_hints = inventory.stack_hints.clone();
375 let manifest = load_archivist_manifest(&repo, args.manifest.as_deref());
376 let invariant_files = find_invariant_dirs(&repo);
377 let mut seen = BTreeSet::new();
378 let mut predicates = Vec::new();
379 let mut discovery_diagnostics = Vec::new();
380 for dir in &invariant_files {
381 for file in harn_vm::flow::discover_invariants(&repo, dir) {
382 let relative_dir = file.relative_dir.clone();
383 for diagnostic in &file.diagnostics {
384 discovery_diagnostics.push(json!({
385 "relative_dir": relative_dir,
386 "path": file.path,
387 "severity": format!("{:?}", diagnostic.severity).to_lowercase(),
388 "message": diagnostic.message,
389 }));
390 }
391 for predicate in file.predicates {
392 if !seen.insert(predicate.source_hash.clone()) {
393 continue;
394 }
395 predicates.push(json!({
396 "name": predicate.name,
397 "hash": predicate.source_hash,
398 "kind": predicate.kind,
399 "fallback": predicate.fallback,
400 "relative_dir": relative_dir.clone(),
401 "retroactive": predicate.retroactive,
402 "archivist": predicate.archivist.map(|archivist| json!({
403 "evidence": archivist.evidence,
404 "confidence": archivist.confidence,
405 "source_date": archivist.source_date,
406 "coverage_examples": archivist.coverage_examples,
407 })),
408 }));
409 }
410 }
411 }
412 let convention = mine_convention_signals(&repo);
413 let motion = mine_motion_signals(&repo);
414 let bootstrap_payload = bootstrap_policy_payload(&repo);
415 let proposals = archivist_proposals(
416 &repo,
417 &inventory,
418 &convention,
419 &motion,
420 predicates.is_empty(),
421 &source_date,
422 );
423 let shadow_evaluation = shadow_evaluate(&repo, &args.store, args.shadow_days, &proposals)?;
424 let payload = json!({
425 "status": "proposal_set",
426 "persona": {
427 "name": "archivist",
428 "mode": "propose_only",
429 "autonomy": "propose_only",
430 "promotion": "human_review_required",
431 },
432 "repo": repo,
433 "manifest": manifest,
434 "inventory": inventory,
435 "stack_hints": stack_hints,
436 "convention_signals": convention,
437 "motion_signals": motion,
438 "seed_library": {
439 "repository": "https://github.com/burin-labs/harn-canon",
440 "strategy": "detected-stack seeds are copied into proposals, then repo-local evidence prunes them before review",
441 },
442 "existing_predicates": predicates,
443 "discovery_diagnostics": discovery_diagnostics,
444 "bootstrap_policy": bootstrap_payload,
445 "proposals": proposals,
446 "shadow_evaluation": shadow_evaluation,
447 });
448
449 if let Some(path) = &args.out {
450 write_json(path, &payload)
451 .map_err(|error| format!("failed to write Archivist proposal set: {error}"))?;
452 }
453 print_payload(args.json, "Archivist proposal set emitted.", &payload);
454 Ok(0)
455}
456
457#[derive(Clone, Debug, Default, Serialize)]
458struct RepoInventory {
459 stack_hints: Vec<&'static str>,
460 lockfiles: Vec<String>,
461 config_files: Vec<String>,
462 source_roots: Vec<String>,
463}
464
465#[derive(Clone, Debug, Serialize)]
466struct Signal {
467 kind: &'static str,
468 path: String,
469 detail: String,
470}
471
472#[derive(Clone, Debug, Serialize)]
473struct MotionSignal {
474 kind: &'static str,
475 count: usize,
476 examples: Vec<String>,
477}
478
479#[derive(Clone, Debug)]
480struct ArchivistProposal {
481 id: &'static str,
482 title: &'static str,
483 path: String,
484 rationale: String,
485 predicate_name: &'static str,
486 match_terms: Vec<&'static str>,
487 evidence: Vec<String>,
488 confidence: f64,
489 coverage_examples: Vec<String>,
490 source: String,
491}
492
493impl Serialize for ArchivistProposal {
494 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
495 let mut state = serializer.serialize_struct("ArchivistProposal", 11)?;
496 state.serialize_field("id", self.id)?;
497 state.serialize_field("title", self.title)?;
498 state.serialize_field("path", &self.path)?;
499 state.serialize_field("rationale", &self.rationale)?;
500 state.serialize_field("predicate_name", self.predicate_name)?;
501 state.serialize_field("autonomy", "propose_only")?;
502 state.serialize_field("promotion", "human_review_required")?;
503 state.serialize_field("evidence", &self.evidence)?;
504 state.serialize_field("confidence", &self.confidence)?;
505 state.serialize_field("coverage_examples", &self.coverage_examples)?;
506 state.serialize_field("predicate_source", &self.source)?;
507 state.end()
508 }
509}
510
511fn inventory_repo(repo: &Path) -> RepoInventory {
512 let mut inventory = RepoInventory::default();
513 let known = [
514 ("Cargo.toml", "rust", "config"),
515 ("Cargo.lock", "rust", "lockfile"),
516 ("rust-toolchain.toml", "rust", "config"),
517 ("rustfmt.toml", "rust", "config"),
518 ("clippy.toml", "rust", "config"),
519 ("package.json", "javascript", "config"),
520 ("package-lock.json", "javascript", "lockfile"),
521 ("pnpm-lock.yaml", "javascript", "lockfile"),
522 ("yarn.lock", "javascript", "lockfile"),
523 ("tsconfig.json", "typescript", "config"),
524 ("pyproject.toml", "python", "config"),
525 ("poetry.lock", "python", "lockfile"),
526 ("uv.lock", "python", "lockfile"),
527 ("go.mod", "go", "config"),
528 ("go.sum", "go", "lockfile"),
529 ("Package.swift", "swift", "config"),
530 ];
531 for (path, stack, kind) in known {
532 if repo.join(path).exists() {
533 push_unique(&mut inventory.stack_hints, stack);
534 match kind {
535 "lockfile" => inventory.lockfiles.push(path.to_string()),
536 _ => inventory.config_files.push(path.to_string()),
537 }
538 }
539 }
540 if repo.join(".github/workflows").is_dir() {
541 inventory.config_files.push(".github/workflows".to_string());
542 }
543 for root in ["crates", "src", "docs/src", "conformance/tests", "examples"] {
544 if repo.join(root).exists() {
545 inventory.source_roots.push(root.to_string());
546 }
547 }
548 inventory
549}
550
551fn push_unique(values: &mut Vec<&'static str>, value: &'static str) {
552 if !values.contains(&value) {
553 values.push(value);
554 }
555}
556
557fn load_archivist_manifest(repo: &Path, explicit: Option<&Path>) -> serde_json::Value {
558 let explicit_manifest = explicit.is_some();
559 let candidates = explicit
560 .map(|path| vec![path.to_path_buf()])
561 .unwrap_or_else(|| {
562 [
563 repo.join("harn.toml"),
564 repo.join("examples/personas/flow.harn.toml"),
565 repo.join("examples/personas/harn.toml"),
566 ]
567 .into_iter()
568 .filter(|path| path.is_file())
569 .collect()
570 });
571 let mut loaded_without_archivist = None;
572 let mut first_invalid = None;
573 for candidate in candidates {
574 match crate::package::load_personas_from_manifest_path(&candidate) {
575 Ok(catalog) => {
576 let archivist = catalog
577 .personas
578 .iter()
579 .find(|persona| persona.name.as_deref() == Some("archivist"));
580 if let Some(persona) = archivist {
581 return json!({
582 "status": "loaded",
583 "path": catalog.manifest_path,
584 "persona": persona,
585 });
586 }
587 loaded_without_archivist.get_or_insert_with(|| json!({
588 "status": "loaded_without_archivist",
589 "path": catalog.manifest_path,
590 "personas": catalog.personas.iter().filter_map(|p| p.name.clone()).collect::<Vec<_>>(),
591 }));
592 }
593 Err(errors) => {
594 let invalid = json!({
595 "status": "invalid",
596 "path": candidate,
597 "errors": errors.iter().map(ToString::to_string).collect::<Vec<_>>(),
598 });
599 if explicit_manifest {
600 return invalid;
601 }
602 first_invalid.get_or_insert(invalid);
603 }
604 }
605 }
606 if let Some(loaded) = loaded_without_archivist {
607 return loaded;
608 }
609 if let Some(invalid) = first_invalid {
610 return invalid;
611 }
612 json!({
613 "status": "not_found",
614 "searched": ["harn.toml", "examples/personas/flow.harn.toml", "examples/personas/harn.toml"],
615 })
616}
617
618fn mine_convention_signals(repo: &Path) -> Vec<Signal> {
619 let mut signals = Vec::new();
620 for path in walk_repo_files(repo, 4_000) {
621 let relative = relative_path(repo, &path);
622 let file_name = path
623 .file_name()
624 .and_then(|name| name.to_str())
625 .unwrap_or("");
626 if matches!(
627 file_name,
628 "rustfmt.toml" | "clippy.toml" | "deny.toml" | ".markdownlint.json" | ".prettierrc"
629 ) {
630 signals.push(Signal {
631 kind: "lint_config",
632 path: relative.clone(),
633 detail: "repo-local style or lint policy".to_string(),
634 });
635 }
636 if relative.ends_with(".harn")
637 || relative.ends_with(".rs")
638 || relative.ends_with(".md")
639 || relative.ends_with(".toml")
640 {
641 if let Ok(source) = fs::read_to_string(&path) {
642 for (index, line) in source.lines().enumerate() {
643 let trimmed = line.trim_start();
644 let is_comment = trimmed.starts_with("//")
645 || trimmed.starts_with('#')
646 || trimmed.starts_with("<!--");
647 if is_comment {
648 #[expect(
649 clippy::string_slice,
650 reason = "to_ascii_lowercase preserves byte offsets, so pos is a \
651 char boundary in trimmed"
652 )]
653 if let Some(pos) = trimmed.to_ascii_lowercase().find("invariant:") {
654 signals.push(Signal {
655 kind: "inline_invariant",
656 path: format!("{relative}:{}", index + 1),
657 detail: trimmed[pos..].trim().chars().take(180).collect(),
658 });
659 }
660 }
661 if signals.len() >= 80 {
662 return signals;
663 }
664 }
665 }
666 }
667 }
668 signals
669}
670
671fn mine_motion_signals(repo: &Path) -> Vec<MotionSignal> {
672 let output = Command::new("git")
673 .arg("-C")
674 .arg(repo)
675 .args([
676 "log",
677 "--since=90 days ago",
678 "--pretty=%s",
679 "--max-count=200",
680 ])
681 .output();
682 let Ok(output) = output else {
683 return Vec::new();
684 };
685 if !output.status.success() {
686 return Vec::new();
687 }
688 let stdout = String::from_utf8_lossy(&output.stdout);
689 let buckets: [(&str, &[&str]); 4] = [
690 ("tests", &["test", "coverage", "conformance"]),
691 ("lint_format", &["lint", "format", "fmt", "clippy"]),
692 (
693 "flow_predicates",
694 &["flow", "predicate", "invariant", "archivist"],
695 ),
696 ("release_docs", &["release", "docs", "changelog"]),
697 ];
698 let mut counts: BTreeMap<&'static str, Vec<String>> = BTreeMap::new();
699 for subject in stdout.lines() {
700 let lower = subject.to_ascii_lowercase();
701 for (kind, terms) in buckets {
702 if terms.iter().any(|term| lower.contains(term)) {
703 counts
704 .entry(kind)
705 .or_default()
706 .push(subject.chars().take(140).collect());
707 }
708 }
709 }
710 counts
711 .into_iter()
712 .map(|(kind, examples)| MotionSignal {
713 kind,
714 count: examples.len(),
715 examples: examples.into_iter().take(5).collect(),
716 })
717 .collect()
718}
719
720fn archivist_proposals(
721 repo: &Path,
722 inventory: &RepoInventory,
723 convention: &[Signal],
724 motion: &[MotionSignal],
725 no_existing_predicates: bool,
726 source_date: &str,
727) -> Vec<ArchivistProposal> {
728 let mut proposals = Vec::new();
729 if no_existing_predicates {
730 proposals.push(bootstrap_proposal(source_date));
731 }
732 if inventory.stack_hints.contains(&"rust") {
733 proposals.push(rust_unsafe_proposal(repo, source_date));
734 proposals.push(rust_panics_proposal(repo, source_date));
735 }
736 if inventory
737 .config_files
738 .iter()
739 .any(|path| path == ".github/workflows")
740 {
741 proposals.push(github_actions_permissions_proposal(source_date));
742 }
743 if motion
744 .iter()
745 .any(|signal| signal.kind == "tests" && signal.count >= 3)
746 {
747 proposals.push(test_motion_proposal(source_date));
748 }
749 let inline_signals = convention
750 .iter()
751 .filter(|signal| signal.kind == "inline_invariant")
752 .take(5)
753 .collect::<Vec<_>>();
754 if !inline_signals.is_empty() {
755 proposals.push(inline_invariant_proposal(&inline_signals, source_date));
756 }
757 proposals
758}
759
760fn bootstrap_proposal(source_date: &str) -> ArchivistProposal {
761 let evidence = vec![
762 "https://slsa.dev/spec/v1.0/provenance".to_string(),
763 "https://in-toto.io/attestation-spec/".to_string(),
764 ];
765 let coverage_examples = vec![
766 "invariants.harn".to_string(),
767 "meta-invariants.harn".to_string(),
768 ];
769 proposal(
770 "bootstrap-meta-invariants",
771 "Seed repo-wide predicate authorship metadata",
772 "invariants.harn",
773 "The repository has no discovered Flow predicates; seed review-only bootstrap metadata before expanding policy.",
774 "predicate_metadata_is_reviewable",
775 vec!["@archivist", "@semantic", "@deterministic"],
776 evidence,
777 0.72,
778 coverage_examples,
779 source_date,
780 "flow_invariant_warn(\"bootstrap predicate metadata should be reviewed by a human maintainer\")",
781 )
782}
783
784fn rust_unsafe_proposal(repo: &Path, source_date: &str) -> ArchivistProposal {
785 let examples = files_containing(repo, "unsafe", &["rs"], 5);
786 proposal(
787 "rust-unsafe-safety-comment",
788 "Require review evidence near new Rust unsafe blocks",
789 "invariants.harn",
790 "Rust is detected and unsafe blocks are a recurring high-value review boundary; propose a deterministic guard that warns on unsafe additions without nearby safety rationale.",
791 "rust_unsafe_requires_safety_comment",
792 vec!["unsafe", "SAFETY:"],
793 vec![
794 "https://doc.rust-lang.org/clippy/lint_configuration.html#undocumented_unsafe_blocks".to_string(),
795 "https://rust-lang.github.io/api-guidelines/documentation.html".to_string(),
796 ],
797 0.82,
798 examples,
799 source_date,
800 "flow_invariant_warn(\"new unsafe code should include nearby SAFETY rationale or explicit reviewer approval\")",
801 )
802}
803
804fn rust_panics_proposal(repo: &Path, source_date: &str) -> ArchivistProposal {
805 let mut examples = files_containing(repo, "panic!", &["rs"], 5);
806 examples.extend(files_containing(
807 repo,
808 ".unwrap()",
809 &["rs"],
810 5 - examples.len().min(5),
811 ));
812 proposal(
813 "rust-library-panic-surface",
814 "Flag new library panic surfaces without tests or documentation",
815 "invariants.harn",
816 "The Rust API Guidelines call out documented panic conditions; Flow can cheaply warn when atoms add panic-prone surfaces in library crates.",
817 "rust_library_panics_are_documented",
818 vec!["panic!", "unwrap()", "expect("],
819 vec![
820 "https://rust-lang.github.io/api-guidelines/documentation.html#c-failure".to_string(),
821 "https://rust-lang.github.io/rust-clippy/beta/".to_string(),
822 ],
823 0.76,
824 examples,
825 source_date,
826 "flow_invariant_warn(\"new panic-prone Rust paths should include tests or documented panic conditions\")",
827 )
828}
829
830fn github_actions_permissions_proposal(source_date: &str) -> ArchivistProposal {
831 proposal(
832 "github-actions-minimal-permissions",
833 "Warn on workflow edits without explicit permissions",
834 ".github/invariants.harn",
835 "GitHub workflow files are present; explicit job/workflow permissions make CI authority reviewable and reduce supply-chain blast radius.",
836 "github_actions_permissions_are_explicit",
837 vec!["permissions:", "uses:"],
838 vec![
839 "https://docs.github.com/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions".to_string(),
840 "https://docs.github.com/code-security/supply-chain-security/understanding-your-software-supply-chain/about-supply-chain-security".to_string(),
841 ],
842 0.79,
843 vec![".github/workflows".to_string()],
844 source_date,
845 "flow_invariant_warn(\"workflow edits should keep explicit least-privilege permissions\")",
846 )
847}
848
849fn test_motion_proposal(source_date: &str) -> ArchivistProposal {
850 proposal(
851 "motion-tests-near-flow-changes",
852 "Keep test coverage close to recurring Flow changes",
853 "invariants.harn",
854 "Recent history repeatedly touches tests/conformance around Flow work; propose a warning when Flow atoms lack nearby test coverage evidence.",
855 "flow_changes_keep_tests_nearby",
856 vec!["flow", "predicate", "conformance", "test"],
857 vec![
858 "git log --since='90 days ago' --pretty=%s".to_string(),
859 "conformance/tests/".to_string(),
860 ],
861 0.68,
862 vec!["crates/harn-vm/src/flow".to_string(), "conformance/tests".to_string()],
863 source_date,
864 "flow_invariant_warn(\"Flow predicate/runtime changes should carry focused tests or conformance coverage\")",
865 )
866}
867
868fn inline_invariant_proposal(signals: &[&Signal], source_date: &str) -> ArchivistProposal {
869 let id = "inline-invariant-crystallization";
870 let examples = signals
871 .iter()
872 .map(|signal| signal.path.clone())
873 .collect::<Vec<_>>();
874 proposal(
875 id,
876 "Crystallize inline invariant comment into Flow predicate",
877 "invariants.harn",
878 "Found inline invariant comments; propose turning recurring comments into reviewable predicate metadata.",
879 "inline_invariant_comment_is_crystallized",
880 vec!["invariant:"],
881 examples.clone(),
882 0.64,
883 examples,
884 source_date,
885 "flow_invariant_warn(\"inline invariant comments should graduate into reviewable Flow predicates when they recur\")",
886 )
887}
888
889#[allow(clippy::too_many_arguments)]
890fn proposal(
891 id: &'static str,
892 title: &'static str,
893 path: &str,
894 rationale: &str,
895 predicate_name: &'static str,
896 match_terms: Vec<&'static str>,
897 evidence: Vec<String>,
898 confidence: f64,
899 coverage_examples: Vec<String>,
900 source_date: &str,
901 result_expr: &str,
902) -> ArchivistProposal {
903 let evidence_harn = evidence
904 .iter()
905 .map(|item| format!("{item:?}"))
906 .collect::<Vec<_>>()
907 .join(", ");
908 let coverage_harn = coverage_examples
909 .iter()
910 .map(|item| format!("{item:?}"))
911 .collect::<Vec<_>>()
912 .join(", ");
913 let source = format!(
914 "@invariant\n@deterministic\n@archivist(evidence: [{evidence_harn}], confidence: {confidence:.2}, source_date: {source_date:?}, coverage_examples: [{coverage_harn}])\nfn {predicate_name}(slice) {{\n return {result_expr}\n}}\n"
915 );
916 ArchivistProposal {
917 id,
918 title,
919 path: path.to_string(),
920 rationale: rationale.to_string(),
921 predicate_name,
922 match_terms,
923 evidence,
924 confidence,
925 coverage_examples,
926 source,
927 }
928}
929
930fn shadow_evaluate(
931 repo: &Path,
932 store_path: &Path,
933 shadow_days: u32,
934 proposals: &[ArchivistProposal],
935) -> Result<serde_json::Value, String> {
936 let store_path = if store_path.is_absolute() {
937 store_path.to_path_buf()
938 } else {
939 repo.join(store_path)
940 };
941 if !store_path.is_file() {
942 return Ok(json!({
943 "status": "no_flow_store",
944 "store": store_path,
945 "window_days": shadow_days,
946 "recent_atoms": 0,
947 "proposal_results": empty_shadow_results(proposals),
948 "false_positive_candidates": [],
949 }));
950 }
951 let store = SqliteFlowStore::open(&store_path, "archivist-shadow").map_err(|error| {
952 format!(
953 "failed to open Flow store {}: {error}",
954 store_path.display()
955 )
956 })?;
957 let since = OffsetDateTime::now_utc() - Duration::days(i64::from(shadow_days));
958 let refs = store
959 .list_atoms()
960 .map_err(|error| format!("failed to list Flow atoms: {error}"))?;
961 let mut recent_atoms = Vec::new();
962 for atom_ref in refs {
963 let atom = store
964 .get_atom(atom_ref.atom_id)
965 .map_err(|error| format!("failed to load Flow atom {}: {error}", atom_ref.atom_id))?;
966 if atom.provenance.timestamp >= since {
967 recent_atoms.push(atom);
968 }
969 }
970
971 let mut false_positive_candidates = Vec::new();
972 let mut results = Vec::new();
973 for proposal in proposals {
974 let mut matched_atoms = 0usize;
975 for atom in &recent_atoms {
976 let inserted = inserted_text(atom);
977 if proposal.match_terms.iter().any(|term| {
978 inserted
979 .to_ascii_lowercase()
980 .contains(&term.to_ascii_lowercase())
981 }) {
982 matched_atoms += 1;
983 if likely_false_positive(proposal, &inserted) {
984 false_positive_candidates.push(json!({
985 "proposal_id": proposal.id,
986 "atom": atom.id,
987 "transcript_ref": atom.provenance.transcript_ref,
988 "diff_span": first_insert_span(atom),
989 "reason": "heuristic match may already contain satisfying context",
990 }));
991 }
992 }
993 }
994 results.push(json!({
995 "proposal_id": proposal.id,
996 "recent_atoms": recent_atoms.len(),
997 "matching_atoms": matched_atoms,
998 "estimated_coverage": if recent_atoms.is_empty() { 0.0 } else { matched_atoms as f64 / recent_atoms.len() as f64 },
999 }));
1000 }
1001 Ok(json!({
1002 "status": "evaluated",
1003 "store": store_path,
1004 "window_days": shadow_days,
1005 "recent_atoms": recent_atoms.len(),
1006 "proposal_results": results,
1007 "false_positive_candidates": false_positive_candidates,
1008 }))
1009}
1010
1011fn empty_shadow_results(proposals: &[ArchivistProposal]) -> Vec<serde_json::Value> {
1012 proposals
1013 .iter()
1014 .map(|proposal| {
1015 json!({
1016 "proposal_id": proposal.id,
1017 "recent_atoms": 0,
1018 "matching_atoms": 0,
1019 "estimated_coverage": 0.0,
1020 })
1021 })
1022 .collect()
1023}
1024
1025fn inserted_text(atom: &harn_vm::flow::Atom) -> String {
1026 atom.ops
1027 .iter()
1028 .filter_map(|op| match op {
1029 TextOp::Insert { content, .. } => Some(content.as_str()),
1030 TextOp::Delete { .. } => None,
1031 })
1032 .collect::<Vec<_>>()
1033 .join("\n")
1034}
1035
1036fn first_insert_span(atom: &harn_vm::flow::Atom) -> serde_json::Value {
1037 atom.ops
1038 .iter()
1039 .find_map(|op| match op {
1040 TextOp::Insert { offset, content } => Some(json!({
1041 "start": offset,
1042 "end": offset.saturating_add(content.len() as u64),
1043 })),
1044 TextOp::Delete { .. } => None,
1045 })
1046 .unwrap_or_else(|| json!({"start": 0, "end": 0}))
1047}
1048
1049fn likely_false_positive(proposal: &ArchivistProposal, inserted: &str) -> bool {
1050 match proposal.id {
1051 "rust-unsafe-safety-comment" => {
1052 inserted.contains("unsafe") && inserted.to_ascii_lowercase().contains("safety")
1053 }
1054 "github-actions-minimal-permissions" => {
1055 inserted.contains("permissions:") && inserted.contains("uses:")
1056 }
1057 _ => false,
1058 }
1059}
1060
1061fn files_containing(repo: &Path, needle: &str, extensions: &[&str], limit: usize) -> Vec<String> {
1062 if limit == 0 {
1063 return Vec::new();
1064 }
1065 let needle = needle.to_ascii_lowercase();
1066 let mut matches = Vec::new();
1067 for path in walk_repo_files(repo, 4_000) {
1068 let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
1069 continue;
1070 };
1071 if !extensions.contains(&ext) {
1072 continue;
1073 }
1074 let Ok(source) = fs::read_to_string(&path) else {
1075 continue;
1076 };
1077 if source.to_ascii_lowercase().contains(&needle) {
1078 matches.push(relative_path(repo, &path));
1079 if matches.len() >= limit {
1080 break;
1081 }
1082 }
1083 }
1084 matches
1085}
1086
1087fn walk_repo_files(repo: &Path, limit: usize) -> Vec<PathBuf> {
1088 let mut files = Vec::new();
1089 collect_repo_files(repo, repo, limit, &mut files);
1090 files
1091}
1092
1093fn collect_repo_files(root: &Path, dir: &Path, limit: usize, out: &mut Vec<PathBuf>) {
1094 if out.len() >= limit {
1095 return;
1096 }
1097 let Ok(entries) = fs::read_dir(dir) else {
1098 return;
1099 };
1100 let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
1101 entries.sort_by_key(|entry| entry.path());
1102 for entry in entries {
1103 if out.len() >= limit {
1104 return;
1105 }
1106 let path = entry.path();
1107 let name = path
1108 .file_name()
1109 .and_then(|name| name.to_str())
1110 .unwrap_or_default();
1111 if path.is_dir() {
1112 if should_skip_scan_dir(root, &path) {
1113 continue;
1114 }
1115 collect_repo_files(root, &path, limit, out);
1116 } else if path.is_file() {
1117 let relative = relative_path(root, &path);
1118 if !relative.ends_with(".lock")
1119 || matches!(name, "Cargo.lock" | "package-lock.json" | "yarn.lock")
1120 {
1121 out.push(path);
1122 }
1123 }
1124 }
1125}
1126
1127fn should_skip_scan_dir(root: &Path, path: &Path) -> bool {
1128 if path.strip_prefix(root).is_ok_and(|relative| {
1129 crate::path_policy::is_generated_docs_output(
1130 relative,
1131 crate::path_policy::PathEntryKind::Directory,
1132 )
1133 }) {
1134 return true;
1135 }
1136 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1137 return false;
1138 };
1139 matches!(
1140 name,
1141 ".git" | "target" | "node_modules" | ".claude" | ".burin"
1142 ) || crate::path_policy::is_harn_internal_entry(
1143 name,
1144 crate::path_policy::PathEntryKind::Directory,
1145 )
1146}
1147
1148fn relative_path(root: &Path, path: &Path) -> String {
1149 path.strip_prefix(root)
1150 .unwrap_or(path)
1151 .components()
1152 .filter_map(|component| match component {
1153 std::path::Component::Normal(name) => Some(name.to_string_lossy().into_owned()),
1154 _ => None,
1155 })
1156 .collect::<Vec<_>>()
1157 .join("/")
1158}
1159
1160fn current_predicate_chains(
1161 root: &Path,
1162 touched_dirs: &[PathBuf],
1163) -> Vec<Vec<harn_vm::flow::DiscoveredInvariantFile>> {
1164 let dirs: Vec<PathBuf> = if touched_dirs.is_empty() {
1165 vec![PathBuf::from(".")]
1166 } else {
1167 touched_dirs.to_vec()
1168 };
1169 dirs.into_iter()
1170 .map(|dir| harn_vm::flow::discover_invariants(root, &dir))
1171 .collect()
1172}
1173
1174fn open_store(path: &Path) -> Result<SqliteFlowStore, String> {
1175 if let Some(parent) = path
1176 .parent()
1177 .filter(|parent| !parent.as_os_str().is_empty())
1178 {
1179 fs::create_dir_all(parent).map_err(|error| error.to_string())?;
1180 }
1181 SqliteFlowStore::open(path, "flow-cli").map_err(|error| error.to_string())
1182}
1183
1184fn find_invariant_dirs(root: &Path) -> Vec<PathBuf> {
1185 let mut dirs = Vec::new();
1186 collect_invariant_dirs(root, root, &mut dirs);
1187 dirs
1188}
1189
1190fn collect_invariant_dirs(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) {
1191 let Ok(entries) = fs::read_dir(dir) else {
1192 return;
1193 };
1194 let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
1195 entries.sort_by_key(|entry| entry.path());
1196 for entry in entries {
1197 let path = entry.path();
1198 if path.is_dir() {
1199 let name = path
1200 .file_name()
1201 .and_then(|name| name.to_str())
1202 .unwrap_or_default();
1203 if matches!(name, ".git" | "target" | "node_modules") {
1204 continue;
1205 }
1206 collect_invariant_dirs(root, &path, out);
1207 } else if path.file_name().and_then(|name| name.to_str()) == Some("invariants.harn") {
1208 out.push(path.parent().unwrap_or(root).to_path_buf());
1209 }
1210 }
1211}
1212
1213fn print_human_report(
1214 since: &str,
1215 report: &harn_vm::flow::ReplayAuditReport,
1216 created_at_by_slice: &std::collections::BTreeMap<harn_vm::flow::SliceId, String>,
1217) {
1218 println!(
1219 "Audited {} shipped derived slice(s) since {since}; {} slice(s) have advisory drift.",
1220 report.audited_slices, report.drifted_slices
1221 );
1222 if report.slices.is_empty() {
1223 return;
1224 }
1225 for slice in &report.slices {
1226 let created_at = created_at_by_slice
1227 .get(&slice.slice_id)
1228 .map(String::as_str)
1229 .unwrap_or("unknown");
1230 println!("slice {} created_at={created_at}", slice.slice_id);
1231 if !slice.advisory_drift.is_empty() {
1232 println!(" current @retroactive predicates not pinned:");
1233 for predicate in &slice.advisory_drift {
1234 println!(" - {} {}", predicate.name, predicate.hash.as_str());
1235 }
1236 }
1237 if !slice.historical_only_predicates.is_empty() {
1238 println!(" historical predicate hashes no longer in current set:");
1239 for hash in &slice.historical_only_predicates {
1240 println!(" - {}", hash.as_str());
1241 }
1242 }
1243 }
1244}
1245
1246fn discovery_diagnostics(
1247 chains: &[Vec<harn_vm::flow::DiscoveredInvariantFile>],
1248) -> Vec<(String, &harn_vm::flow::DiscoveryDiagnostic)> {
1249 chains
1250 .iter()
1251 .flat_map(|chain| chain.iter())
1252 .flat_map(|file| {
1253 file.diagnostics
1254 .iter()
1255 .map(move |diagnostic| (file.path.display().to_string(), diagnostic))
1256 })
1257 .collect()
1258}
1259
1260fn has_discovery_error(diagnostics: &[(String, &harn_vm::flow::DiscoveryDiagnostic)]) -> bool {
1261 diagnostics.iter().any(|(_, diagnostic)| {
1262 diagnostic.severity == harn_vm::flow::DiscoveryDiagnosticSeverity::Error
1263 })
1264}
1265
1266fn print_discovery_warnings(diagnostics: &[(String, &harn_vm::flow::DiscoveryDiagnostic)]) {
1267 for (path, diagnostic) in diagnostics.iter().filter(|(_, diagnostic)| {
1268 diagnostic.severity == harn_vm::flow::DiscoveryDiagnosticSeverity::Warning
1269 }) {
1270 eprintln!("warning: {path}: {}", diagnostic.message);
1271 }
1272}
1273
1274fn render_discovery_diagnostics(
1275 diagnostics: &[(String, &harn_vm::flow::DiscoveryDiagnostic)],
1276) -> String {
1277 diagnostics
1278 .iter()
1279 .map(|(path, diagnostic)| format!("{path}: {}", diagnostic.message))
1280 .collect::<Vec<_>>()
1281 .join("\n")
1282}
1283
1284fn write_json(path: &Path, value: &serde_json::Value) -> Result<(), std::io::Error> {
1285 if let Some(parent) = path
1286 .parent()
1287 .filter(|parent| !parent.as_os_str().is_empty())
1288 {
1289 fs::create_dir_all(parent)?;
1290 }
1291 fs::write(path, serde_json::to_vec_pretty(value).unwrap())
1292}
1293
1294fn print_payload(json_output: bool, text: &str, payload: &serde_json::Value) {
1295 if json_output {
1296 println!("{}", serde_json::to_string_pretty(payload).unwrap());
1297 } else {
1298 println!("{text}");
1299 }
1300}
1301
1302fn parse_since(raw: &str) -> Result<OffsetDateTime, String> {
1303 if let Ok(parsed) = OffsetDateTime::parse(raw, &Rfc3339) {
1304 return Ok(parsed);
1305 }
1306 if let Ok(unix) = raw.parse::<i64>() {
1307 let parsed = if raw.len() > 10 {
1308 OffsetDateTime::from_unix_timestamp_nanos(unix as i128 * 1_000_000)
1309 } else {
1310 OffsetDateTime::from_unix_timestamp(unix)
1311 };
1312 return parsed.map_err(|error| format!("invalid --since timestamp '{raw}': {error}"));
1313 }
1314 let date_format = time::format_description::parse_borrowed::<1>("[year]-[month]-[day]")
1315 .map_err(|error| format!("failed to build date parser: {error}"))?;
1316 let date = Date::parse(raw, &date_format).map_err(|_| {
1317 format!("invalid --since date '{raw}'; use RFC3339, unix time, or YYYY-MM-DD")
1318 })?;
1319 Ok(date.with_time(Time::MIDNIGHT).assume_utc())
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324 use super::*;
1325 use ed25519_dalek::SigningKey;
1326 use harn_vm::flow::{Atom, Provenance};
1327
1328 #[test]
1329 fn repo_context_walk_uses_exact_generated_and_harn_state_boundaries() {
1330 let temp = tempfile::tempdir().unwrap();
1331 for relative in [
1332 "src/main.rs",
1333 "docs/dist/generated.md",
1334 "nested/docs/dist/source.md",
1335 ".harn-runs/session/context.md",
1336 ] {
1337 let path = temp.path().join(relative);
1338 fs::create_dir_all(path.parent().unwrap()).unwrap();
1339 fs::write(path, relative).unwrap();
1340 }
1341
1342 let relative = walk_repo_files(temp.path(), 100)
1343 .into_iter()
1344 .map(|path| path.strip_prefix(temp.path()).unwrap().to_path_buf())
1345 .collect::<Vec<_>>();
1346
1347 assert_eq!(
1348 relative,
1349 [
1350 PathBuf::from("nested/docs/dist/source.md"),
1351 PathBuf::from("src/main.rs"),
1352 ]
1353 );
1354 }
1355
1356 #[test]
1357 fn parse_since_accepts_rfc3339_unix_and_date() {
1358 assert_eq!(
1359 parse_since("2026-04-26T12:00:00Z")
1360 .unwrap()
1361 .unix_timestamp(),
1362 1_777_204_800
1363 );
1364 assert_eq!(
1365 parse_since("1777205600").unwrap().unix_timestamp(),
1366 1_777_205_600
1367 );
1368 assert_eq!(
1369 parse_since("2026-04-26").unwrap().unix_timestamp(),
1370 1_777_161_600
1371 );
1372 }
1373
1374 #[test]
1375 fn archivist_rust_proposal_is_parseable_harn_with_provenance() {
1376 let temp = tempfile::tempdir().unwrap();
1377 fs::write(
1378 temp.path().join("Cargo.toml"),
1379 "[package]\nname = \"demo\"\n",
1380 )
1381 .unwrap();
1382 fs::create_dir_all(temp.path().join("src")).unwrap();
1383 fs::write(temp.path().join("src/lib.rs"), "pub unsafe fn raw() {}\n").unwrap();
1384
1385 let inventory = inventory_repo(temp.path());
1386 let proposals = archivist_proposals(temp.path(), &inventory, &[], &[], true, "2026-04-26");
1387 let rust = proposals
1388 .iter()
1389 .find(|proposal| proposal.id == "rust-unsafe-safety-comment")
1390 .expect("rust unsafe proposal");
1391
1392 let parsed = harn_vm::flow::parse_invariants_source(&rust.source);
1393 assert!(
1394 parsed.diagnostics.is_empty(),
1395 "generated source should parse cleanly: {:?}",
1396 parsed.diagnostics
1397 );
1398 assert_eq!(
1399 parsed.predicates[0].name,
1400 "rust_unsafe_requires_safety_comment"
1401 );
1402 assert!(parsed.predicates[0].archivist.is_some());
1403 }
1404
1405 #[test]
1406 fn shadow_evaluate_reports_false_positive_atom_pointers() {
1407 let temp = tempfile::tempdir().unwrap();
1408 fs::write(
1409 temp.path().join("Cargo.toml"),
1410 "[package]\nname = \"demo\"\n",
1411 )
1412 .unwrap();
1413 let store_path = temp.path().join(".harn/flow.sqlite");
1414 fs::create_dir_all(store_path.parent().unwrap()).unwrap();
1415
1416 {
1417 let store = SqliteFlowStore::open(&store_path, "test").unwrap();
1418 let principal = SigningKey::from_bytes(&[7; 32]);
1419 let persona = SigningKey::from_bytes(&[8; 32]);
1420 let atom = Atom::sign(
1421 vec![TextOp::Insert {
1422 offset: 0,
1423 content: "unsafe { /* SAFETY: fixture */ }".to_string(),
1424 }],
1425 Vec::new(),
1426 Provenance::new("user:test", "archivist-test", "run-1", "trace-1", "tx-1"),
1427 None,
1428 &principal,
1429 &persona,
1430 )
1431 .unwrap();
1432 store.emit_atoms(&[atom]).unwrap();
1433 }
1434
1435 let proposal = rust_unsafe_proposal(temp.path(), "2026-04-26");
1436 let report = shadow_evaluate(temp.path(), &store_path, 30, &[proposal]).unwrap();
1437 assert_eq!(report["status"], "evaluated");
1438 assert_eq!(report["recent_atoms"], 1);
1439 assert_eq!(
1440 report["false_positive_candidates"][0]["transcript_ref"],
1441 "tx-1"
1442 );
1443 assert!(report["false_positive_candidates"][0]["atom"].is_string());
1444 }
1445}