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 if let Some(pos) = trimmed.to_ascii_lowercase().find("invariant:") {
649 signals.push(Signal {
650 kind: "inline_invariant",
651 path: format!("{relative}:{}", index + 1),
652 detail: trimmed[pos..].trim().chars().take(180).collect(),
653 });
654 }
655 }
656 if signals.len() >= 80 {
657 return signals;
658 }
659 }
660 }
661 }
662 }
663 signals
664}
665
666fn mine_motion_signals(repo: &Path) -> Vec<MotionSignal> {
667 let output = Command::new("git")
668 .arg("-C")
669 .arg(repo)
670 .args([
671 "log",
672 "--since=90 days ago",
673 "--pretty=%s",
674 "--max-count=200",
675 ])
676 .output();
677 let Ok(output) = output else {
678 return Vec::new();
679 };
680 if !output.status.success() {
681 return Vec::new();
682 }
683 let stdout = String::from_utf8_lossy(&output.stdout);
684 let buckets: [(&str, &[&str]); 4] = [
685 ("tests", &["test", "coverage", "conformance"]),
686 ("lint_format", &["lint", "format", "fmt", "clippy"]),
687 (
688 "flow_predicates",
689 &["flow", "predicate", "invariant", "archivist"],
690 ),
691 ("release_docs", &["release", "docs", "changelog"]),
692 ];
693 let mut counts: BTreeMap<&'static str, Vec<String>> = BTreeMap::new();
694 for subject in stdout.lines() {
695 let lower = subject.to_ascii_lowercase();
696 for (kind, terms) in buckets {
697 if terms.iter().any(|term| lower.contains(term)) {
698 counts
699 .entry(kind)
700 .or_default()
701 .push(subject.chars().take(140).collect());
702 }
703 }
704 }
705 counts
706 .into_iter()
707 .map(|(kind, examples)| MotionSignal {
708 kind,
709 count: examples.len(),
710 examples: examples.into_iter().take(5).collect(),
711 })
712 .collect()
713}
714
715fn archivist_proposals(
716 repo: &Path,
717 inventory: &RepoInventory,
718 convention: &[Signal],
719 motion: &[MotionSignal],
720 no_existing_predicates: bool,
721 source_date: &str,
722) -> Vec<ArchivistProposal> {
723 let mut proposals = Vec::new();
724 if no_existing_predicates {
725 proposals.push(bootstrap_proposal(source_date));
726 }
727 if inventory.stack_hints.contains(&"rust") {
728 proposals.push(rust_unsafe_proposal(repo, source_date));
729 proposals.push(rust_panics_proposal(repo, source_date));
730 }
731 if inventory
732 .config_files
733 .iter()
734 .any(|path| path == ".github/workflows")
735 {
736 proposals.push(github_actions_permissions_proposal(source_date));
737 }
738 if motion
739 .iter()
740 .any(|signal| signal.kind == "tests" && signal.count >= 3)
741 {
742 proposals.push(test_motion_proposal(source_date));
743 }
744 let inline_signals = convention
745 .iter()
746 .filter(|signal| signal.kind == "inline_invariant")
747 .take(5)
748 .collect::<Vec<_>>();
749 if !inline_signals.is_empty() {
750 proposals.push(inline_invariant_proposal(&inline_signals, source_date));
751 }
752 proposals
753}
754
755fn bootstrap_proposal(source_date: &str) -> ArchivistProposal {
756 let evidence = vec![
757 "https://slsa.dev/spec/v1.0/provenance".to_string(),
758 "https://in-toto.io/attestation-spec/".to_string(),
759 ];
760 let coverage_examples = vec![
761 "invariants.harn".to_string(),
762 "meta-invariants.harn".to_string(),
763 ];
764 proposal(
765 "bootstrap-meta-invariants",
766 "Seed repo-wide predicate authorship metadata",
767 "invariants.harn",
768 "The repository has no discovered Flow predicates; seed review-only bootstrap metadata before expanding policy.",
769 "predicate_metadata_is_reviewable",
770 vec!["@archivist", "@semantic", "@deterministic"],
771 evidence,
772 0.72,
773 coverage_examples,
774 source_date,
775 "flow_invariant_warn(\"bootstrap predicate metadata should be reviewed by a human maintainer\")",
776 )
777}
778
779fn rust_unsafe_proposal(repo: &Path, source_date: &str) -> ArchivistProposal {
780 let examples = files_containing(repo, "unsafe", &["rs"], 5);
781 proposal(
782 "rust-unsafe-safety-comment",
783 "Require review evidence near new Rust unsafe blocks",
784 "invariants.harn",
785 "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.",
786 "rust_unsafe_requires_safety_comment",
787 vec!["unsafe", "SAFETY:"],
788 vec![
789 "https://doc.rust-lang.org/clippy/lint_configuration.html#undocumented_unsafe_blocks".to_string(),
790 "https://rust-lang.github.io/api-guidelines/documentation.html".to_string(),
791 ],
792 0.82,
793 examples,
794 source_date,
795 "flow_invariant_warn(\"new unsafe code should include nearby SAFETY rationale or explicit reviewer approval\")",
796 )
797}
798
799fn rust_panics_proposal(repo: &Path, source_date: &str) -> ArchivistProposal {
800 let mut examples = files_containing(repo, "panic!", &["rs"], 5);
801 examples.extend(files_containing(
802 repo,
803 ".unwrap()",
804 &["rs"],
805 5 - examples.len().min(5),
806 ));
807 proposal(
808 "rust-library-panic-surface",
809 "Flag new library panic surfaces without tests or documentation",
810 "invariants.harn",
811 "The Rust API Guidelines call out documented panic conditions; Flow can cheaply warn when atoms add panic-prone surfaces in library crates.",
812 "rust_library_panics_are_documented",
813 vec!["panic!", "unwrap()", "expect("],
814 vec![
815 "https://rust-lang.github.io/api-guidelines/documentation.html#c-failure".to_string(),
816 "https://rust-lang.github.io/rust-clippy/beta/".to_string(),
817 ],
818 0.76,
819 examples,
820 source_date,
821 "flow_invariant_warn(\"new panic-prone Rust paths should include tests or documented panic conditions\")",
822 )
823}
824
825fn github_actions_permissions_proposal(source_date: &str) -> ArchivistProposal {
826 proposal(
827 "github-actions-minimal-permissions",
828 "Warn on workflow edits without explicit permissions",
829 ".github/invariants.harn",
830 "GitHub workflow files are present; explicit job/workflow permissions make CI authority reviewable and reduce supply-chain blast radius.",
831 "github_actions_permissions_are_explicit",
832 vec!["permissions:", "uses:"],
833 vec![
834 "https://docs.github.com/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions".to_string(),
835 "https://docs.github.com/code-security/supply-chain-security/understanding-your-software-supply-chain/about-supply-chain-security".to_string(),
836 ],
837 0.79,
838 vec![".github/workflows".to_string()],
839 source_date,
840 "flow_invariant_warn(\"workflow edits should keep explicit least-privilege permissions\")",
841 )
842}
843
844fn test_motion_proposal(source_date: &str) -> ArchivistProposal {
845 proposal(
846 "motion-tests-near-flow-changes",
847 "Keep test coverage close to recurring Flow changes",
848 "invariants.harn",
849 "Recent history repeatedly touches tests/conformance around Flow work; propose a warning when Flow atoms lack nearby test coverage evidence.",
850 "flow_changes_keep_tests_nearby",
851 vec!["flow", "predicate", "conformance", "test"],
852 vec![
853 "git log --since='90 days ago' --pretty=%s".to_string(),
854 "conformance/tests/".to_string(),
855 ],
856 0.68,
857 vec!["crates/harn-vm/src/flow".to_string(), "conformance/tests".to_string()],
858 source_date,
859 "flow_invariant_warn(\"Flow predicate/runtime changes should carry focused tests or conformance coverage\")",
860 )
861}
862
863fn inline_invariant_proposal(signals: &[&Signal], source_date: &str) -> ArchivistProposal {
864 let id = "inline-invariant-crystallization";
865 let examples = signals
866 .iter()
867 .map(|signal| signal.path.clone())
868 .collect::<Vec<_>>();
869 proposal(
870 id,
871 "Crystallize inline invariant comment into Flow predicate",
872 "invariants.harn",
873 "Found inline invariant comments; propose turning recurring comments into reviewable predicate metadata.",
874 "inline_invariant_comment_is_crystallized",
875 vec!["invariant:"],
876 examples.clone(),
877 0.64,
878 examples,
879 source_date,
880 "flow_invariant_warn(\"inline invariant comments should graduate into reviewable Flow predicates when they recur\")",
881 )
882}
883
884#[allow(clippy::too_many_arguments)]
885fn proposal(
886 id: &'static str,
887 title: &'static str,
888 path: &str,
889 rationale: &str,
890 predicate_name: &'static str,
891 match_terms: Vec<&'static str>,
892 evidence: Vec<String>,
893 confidence: f64,
894 coverage_examples: Vec<String>,
895 source_date: &str,
896 result_expr: &str,
897) -> ArchivistProposal {
898 let evidence_harn = evidence
899 .iter()
900 .map(|item| format!("{item:?}"))
901 .collect::<Vec<_>>()
902 .join(", ");
903 let coverage_harn = coverage_examples
904 .iter()
905 .map(|item| format!("{item:?}"))
906 .collect::<Vec<_>>()
907 .join(", ");
908 let source = format!(
909 "@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"
910 );
911 ArchivistProposal {
912 id,
913 title,
914 path: path.to_string(),
915 rationale: rationale.to_string(),
916 predicate_name,
917 match_terms,
918 evidence,
919 confidence,
920 coverage_examples,
921 source,
922 }
923}
924
925fn shadow_evaluate(
926 repo: &Path,
927 store_path: &Path,
928 shadow_days: u32,
929 proposals: &[ArchivistProposal],
930) -> Result<serde_json::Value, String> {
931 let store_path = if store_path.is_absolute() {
932 store_path.to_path_buf()
933 } else {
934 repo.join(store_path)
935 };
936 if !store_path.is_file() {
937 return Ok(json!({
938 "status": "no_flow_store",
939 "store": store_path,
940 "window_days": shadow_days,
941 "recent_atoms": 0,
942 "proposal_results": empty_shadow_results(proposals),
943 "false_positive_candidates": [],
944 }));
945 }
946 let store = SqliteFlowStore::open(&store_path, "archivist-shadow").map_err(|error| {
947 format!(
948 "failed to open Flow store {}: {error}",
949 store_path.display()
950 )
951 })?;
952 let since = OffsetDateTime::now_utc() - Duration::days(i64::from(shadow_days));
953 let refs = store
954 .list_atoms()
955 .map_err(|error| format!("failed to list Flow atoms: {error}"))?;
956 let mut recent_atoms = Vec::new();
957 for atom_ref in refs {
958 let atom = store
959 .get_atom(atom_ref.atom_id)
960 .map_err(|error| format!("failed to load Flow atom {}: {error}", atom_ref.atom_id))?;
961 if atom.provenance.timestamp >= since {
962 recent_atoms.push(atom);
963 }
964 }
965
966 let mut false_positive_candidates = Vec::new();
967 let mut results = Vec::new();
968 for proposal in proposals {
969 let mut matched_atoms = 0usize;
970 for atom in &recent_atoms {
971 let inserted = inserted_text(atom);
972 if proposal.match_terms.iter().any(|term| {
973 inserted
974 .to_ascii_lowercase()
975 .contains(&term.to_ascii_lowercase())
976 }) {
977 matched_atoms += 1;
978 if likely_false_positive(proposal, &inserted) {
979 false_positive_candidates.push(json!({
980 "proposal_id": proposal.id,
981 "atom": atom.id,
982 "transcript_ref": atom.provenance.transcript_ref,
983 "diff_span": first_insert_span(atom),
984 "reason": "heuristic match may already contain satisfying context",
985 }));
986 }
987 }
988 }
989 results.push(json!({
990 "proposal_id": proposal.id,
991 "recent_atoms": recent_atoms.len(),
992 "matching_atoms": matched_atoms,
993 "estimated_coverage": if recent_atoms.is_empty() { 0.0 } else { matched_atoms as f64 / recent_atoms.len() as f64 },
994 }));
995 }
996 Ok(json!({
997 "status": "evaluated",
998 "store": store_path,
999 "window_days": shadow_days,
1000 "recent_atoms": recent_atoms.len(),
1001 "proposal_results": results,
1002 "false_positive_candidates": false_positive_candidates,
1003 }))
1004}
1005
1006fn empty_shadow_results(proposals: &[ArchivistProposal]) -> Vec<serde_json::Value> {
1007 proposals
1008 .iter()
1009 .map(|proposal| {
1010 json!({
1011 "proposal_id": proposal.id,
1012 "recent_atoms": 0,
1013 "matching_atoms": 0,
1014 "estimated_coverage": 0.0,
1015 })
1016 })
1017 .collect()
1018}
1019
1020fn inserted_text(atom: &harn_vm::flow::Atom) -> String {
1021 atom.ops
1022 .iter()
1023 .filter_map(|op| match op {
1024 TextOp::Insert { content, .. } => Some(content.as_str()),
1025 TextOp::Delete { .. } => None,
1026 })
1027 .collect::<Vec<_>>()
1028 .join("\n")
1029}
1030
1031fn first_insert_span(atom: &harn_vm::flow::Atom) -> serde_json::Value {
1032 atom.ops
1033 .iter()
1034 .find_map(|op| match op {
1035 TextOp::Insert { offset, content } => Some(json!({
1036 "start": offset,
1037 "end": offset.saturating_add(content.len() as u64),
1038 })),
1039 TextOp::Delete { .. } => None,
1040 })
1041 .unwrap_or_else(|| json!({"start": 0, "end": 0}))
1042}
1043
1044fn likely_false_positive(proposal: &ArchivistProposal, inserted: &str) -> bool {
1045 match proposal.id {
1046 "rust-unsafe-safety-comment" => {
1047 inserted.contains("unsafe") && inserted.to_ascii_lowercase().contains("safety")
1048 }
1049 "github-actions-minimal-permissions" => {
1050 inserted.contains("permissions:") && inserted.contains("uses:")
1051 }
1052 _ => false,
1053 }
1054}
1055
1056fn files_containing(repo: &Path, needle: &str, extensions: &[&str], limit: usize) -> Vec<String> {
1057 if limit == 0 {
1058 return Vec::new();
1059 }
1060 let needle = needle.to_ascii_lowercase();
1061 let mut matches = Vec::new();
1062 for path in walk_repo_files(repo, 4_000) {
1063 let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
1064 continue;
1065 };
1066 if !extensions.contains(&ext) {
1067 continue;
1068 }
1069 let Ok(source) = fs::read_to_string(&path) else {
1070 continue;
1071 };
1072 if source.to_ascii_lowercase().contains(&needle) {
1073 matches.push(relative_path(repo, &path));
1074 if matches.len() >= limit {
1075 break;
1076 }
1077 }
1078 }
1079 matches
1080}
1081
1082fn walk_repo_files(repo: &Path, limit: usize) -> Vec<PathBuf> {
1083 let mut files = Vec::new();
1084 collect_repo_files(repo, repo, limit, &mut files);
1085 files
1086}
1087
1088fn collect_repo_files(root: &Path, dir: &Path, limit: usize, out: &mut Vec<PathBuf>) {
1089 if out.len() >= limit {
1090 return;
1091 }
1092 let Ok(entries) = fs::read_dir(dir) else {
1093 return;
1094 };
1095 let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
1096 entries.sort_by_key(|entry| entry.path());
1097 for entry in entries {
1098 if out.len() >= limit {
1099 return;
1100 }
1101 let path = entry.path();
1102 let name = path
1103 .file_name()
1104 .and_then(|name| name.to_str())
1105 .unwrap_or_default();
1106 if path.is_dir() {
1107 if should_skip_scan_dir(root, &path) {
1108 continue;
1109 }
1110 collect_repo_files(root, &path, limit, out);
1111 } else if path.is_file() {
1112 let relative = relative_path(root, &path);
1113 if !relative.ends_with(".lock")
1114 || matches!(name, "Cargo.lock" | "package-lock.json" | "yarn.lock")
1115 {
1116 out.push(path);
1117 }
1118 }
1119 }
1120}
1121
1122fn should_skip_scan_dir(root: &Path, path: &Path) -> bool {
1123 if path.strip_prefix(root).is_ok_and(|relative| {
1124 crate::path_policy::is_generated_docs_output(
1125 relative,
1126 crate::path_policy::PathEntryKind::Directory,
1127 )
1128 }) {
1129 return true;
1130 }
1131 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1132 return false;
1133 };
1134 matches!(
1135 name,
1136 ".git" | "target" | "node_modules" | ".claude" | ".burin"
1137 ) || crate::path_policy::is_harn_internal_entry(
1138 name,
1139 crate::path_policy::PathEntryKind::Directory,
1140 )
1141}
1142
1143fn relative_path(root: &Path, path: &Path) -> String {
1144 path.strip_prefix(root)
1145 .unwrap_or(path)
1146 .components()
1147 .filter_map(|component| match component {
1148 std::path::Component::Normal(name) => Some(name.to_string_lossy().into_owned()),
1149 _ => None,
1150 })
1151 .collect::<Vec<_>>()
1152 .join("/")
1153}
1154
1155fn current_predicate_chains(
1156 root: &Path,
1157 touched_dirs: &[PathBuf],
1158) -> Vec<Vec<harn_vm::flow::DiscoveredInvariantFile>> {
1159 let dirs: Vec<PathBuf> = if touched_dirs.is_empty() {
1160 vec![PathBuf::from(".")]
1161 } else {
1162 touched_dirs.to_vec()
1163 };
1164 dirs.into_iter()
1165 .map(|dir| harn_vm::flow::discover_invariants(root, &dir))
1166 .collect()
1167}
1168
1169fn open_store(path: &Path) -> Result<SqliteFlowStore, String> {
1170 if let Some(parent) = path
1171 .parent()
1172 .filter(|parent| !parent.as_os_str().is_empty())
1173 {
1174 fs::create_dir_all(parent).map_err(|error| error.to_string())?;
1175 }
1176 SqliteFlowStore::open(path, "flow-cli").map_err(|error| error.to_string())
1177}
1178
1179fn find_invariant_dirs(root: &Path) -> Vec<PathBuf> {
1180 let mut dirs = Vec::new();
1181 collect_invariant_dirs(root, root, &mut dirs);
1182 dirs
1183}
1184
1185fn collect_invariant_dirs(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) {
1186 let Ok(entries) = fs::read_dir(dir) else {
1187 return;
1188 };
1189 let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
1190 entries.sort_by_key(|entry| entry.path());
1191 for entry in entries {
1192 let path = entry.path();
1193 if path.is_dir() {
1194 let name = path
1195 .file_name()
1196 .and_then(|name| name.to_str())
1197 .unwrap_or_default();
1198 if matches!(name, ".git" | "target" | "node_modules") {
1199 continue;
1200 }
1201 collect_invariant_dirs(root, &path, out);
1202 } else if path.file_name().and_then(|name| name.to_str()) == Some("invariants.harn") {
1203 out.push(path.parent().unwrap_or(root).to_path_buf());
1204 }
1205 }
1206}
1207
1208fn print_human_report(
1209 since: &str,
1210 report: &harn_vm::flow::ReplayAuditReport,
1211 created_at_by_slice: &std::collections::BTreeMap<harn_vm::flow::SliceId, String>,
1212) {
1213 println!(
1214 "Audited {} shipped derived slice(s) since {since}; {} slice(s) have advisory drift.",
1215 report.audited_slices, report.drifted_slices
1216 );
1217 if report.slices.is_empty() {
1218 return;
1219 }
1220 for slice in &report.slices {
1221 let created_at = created_at_by_slice
1222 .get(&slice.slice_id)
1223 .map(String::as_str)
1224 .unwrap_or("unknown");
1225 println!("slice {} created_at={created_at}", slice.slice_id);
1226 if !slice.advisory_drift.is_empty() {
1227 println!(" current @retroactive predicates not pinned:");
1228 for predicate in &slice.advisory_drift {
1229 println!(" - {} {}", predicate.name, predicate.hash.as_str());
1230 }
1231 }
1232 if !slice.historical_only_predicates.is_empty() {
1233 println!(" historical predicate hashes no longer in current set:");
1234 for hash in &slice.historical_only_predicates {
1235 println!(" - {}", hash.as_str());
1236 }
1237 }
1238 }
1239}
1240
1241fn discovery_diagnostics(
1242 chains: &[Vec<harn_vm::flow::DiscoveredInvariantFile>],
1243) -> Vec<(String, &harn_vm::flow::DiscoveryDiagnostic)> {
1244 chains
1245 .iter()
1246 .flat_map(|chain| chain.iter())
1247 .flat_map(|file| {
1248 file.diagnostics
1249 .iter()
1250 .map(move |diagnostic| (file.path.display().to_string(), diagnostic))
1251 })
1252 .collect()
1253}
1254
1255fn has_discovery_error(diagnostics: &[(String, &harn_vm::flow::DiscoveryDiagnostic)]) -> bool {
1256 diagnostics.iter().any(|(_, diagnostic)| {
1257 diagnostic.severity == harn_vm::flow::DiscoveryDiagnosticSeverity::Error
1258 })
1259}
1260
1261fn print_discovery_warnings(diagnostics: &[(String, &harn_vm::flow::DiscoveryDiagnostic)]) {
1262 for (path, diagnostic) in diagnostics.iter().filter(|(_, diagnostic)| {
1263 diagnostic.severity == harn_vm::flow::DiscoveryDiagnosticSeverity::Warning
1264 }) {
1265 eprintln!("warning: {path}: {}", diagnostic.message);
1266 }
1267}
1268
1269fn render_discovery_diagnostics(
1270 diagnostics: &[(String, &harn_vm::flow::DiscoveryDiagnostic)],
1271) -> String {
1272 diagnostics
1273 .iter()
1274 .map(|(path, diagnostic)| format!("{path}: {}", diagnostic.message))
1275 .collect::<Vec<_>>()
1276 .join("\n")
1277}
1278
1279fn write_json(path: &Path, value: &serde_json::Value) -> Result<(), std::io::Error> {
1280 if let Some(parent) = path
1281 .parent()
1282 .filter(|parent| !parent.as_os_str().is_empty())
1283 {
1284 fs::create_dir_all(parent)?;
1285 }
1286 fs::write(path, serde_json::to_vec_pretty(value).unwrap())
1287}
1288
1289fn print_payload(json_output: bool, text: &str, payload: &serde_json::Value) {
1290 if json_output {
1291 println!("{}", serde_json::to_string_pretty(payload).unwrap());
1292 } else {
1293 println!("{text}");
1294 }
1295}
1296
1297fn parse_since(raw: &str) -> Result<OffsetDateTime, String> {
1298 if let Ok(parsed) = OffsetDateTime::parse(raw, &Rfc3339) {
1299 return Ok(parsed);
1300 }
1301 if let Ok(unix) = raw.parse::<i64>() {
1302 let parsed = if raw.len() > 10 {
1303 OffsetDateTime::from_unix_timestamp_nanos(unix as i128 * 1_000_000)
1304 } else {
1305 OffsetDateTime::from_unix_timestamp(unix)
1306 };
1307 return parsed.map_err(|error| format!("invalid --since timestamp '{raw}': {error}"));
1308 }
1309 let date_format = time::format_description::parse_borrowed::<1>("[year]-[month]-[day]")
1310 .map_err(|error| format!("failed to build date parser: {error}"))?;
1311 let date = Date::parse(raw, &date_format).map_err(|_| {
1312 format!("invalid --since date '{raw}'; use RFC3339, unix time, or YYYY-MM-DD")
1313 })?;
1314 Ok(date.with_time(Time::MIDNIGHT).assume_utc())
1315}
1316
1317#[cfg(test)]
1318mod tests {
1319 use super::*;
1320 use ed25519_dalek::SigningKey;
1321 use harn_vm::flow::{Atom, Provenance};
1322
1323 #[test]
1324 fn repo_context_walk_uses_exact_generated_and_harn_state_boundaries() {
1325 let temp = tempfile::tempdir().unwrap();
1326 for relative in [
1327 "src/main.rs",
1328 "docs/dist/generated.md",
1329 "nested/docs/dist/source.md",
1330 ".harn-runs/session/context.md",
1331 ] {
1332 let path = temp.path().join(relative);
1333 fs::create_dir_all(path.parent().unwrap()).unwrap();
1334 fs::write(path, relative).unwrap();
1335 }
1336
1337 let relative = walk_repo_files(temp.path(), 100)
1338 .into_iter()
1339 .map(|path| path.strip_prefix(temp.path()).unwrap().to_path_buf())
1340 .collect::<Vec<_>>();
1341
1342 assert_eq!(
1343 relative,
1344 [
1345 PathBuf::from("nested/docs/dist/source.md"),
1346 PathBuf::from("src/main.rs"),
1347 ]
1348 );
1349 }
1350
1351 #[test]
1352 fn parse_since_accepts_rfc3339_unix_and_date() {
1353 assert_eq!(
1354 parse_since("2026-04-26T12:00:00Z")
1355 .unwrap()
1356 .unix_timestamp(),
1357 1_777_204_800
1358 );
1359 assert_eq!(
1360 parse_since("1777205600").unwrap().unix_timestamp(),
1361 1_777_205_600
1362 );
1363 assert_eq!(
1364 parse_since("2026-04-26").unwrap().unix_timestamp(),
1365 1_777_161_600
1366 );
1367 }
1368
1369 #[test]
1370 fn archivist_rust_proposal_is_parseable_harn_with_provenance() {
1371 let temp = tempfile::tempdir().unwrap();
1372 fs::write(
1373 temp.path().join("Cargo.toml"),
1374 "[package]\nname = \"demo\"\n",
1375 )
1376 .unwrap();
1377 fs::create_dir_all(temp.path().join("src")).unwrap();
1378 fs::write(temp.path().join("src/lib.rs"), "pub unsafe fn raw() {}\n").unwrap();
1379
1380 let inventory = inventory_repo(temp.path());
1381 let proposals = archivist_proposals(temp.path(), &inventory, &[], &[], true, "2026-04-26");
1382 let rust = proposals
1383 .iter()
1384 .find(|proposal| proposal.id == "rust-unsafe-safety-comment")
1385 .expect("rust unsafe proposal");
1386
1387 let parsed = harn_vm::flow::parse_invariants_source(&rust.source);
1388 assert!(
1389 parsed.diagnostics.is_empty(),
1390 "generated source should parse cleanly: {:?}",
1391 parsed.diagnostics
1392 );
1393 assert_eq!(
1394 parsed.predicates[0].name,
1395 "rust_unsafe_requires_safety_comment"
1396 );
1397 assert!(parsed.predicates[0].archivist.is_some());
1398 }
1399
1400 #[test]
1401 fn shadow_evaluate_reports_false_positive_atom_pointers() {
1402 let temp = tempfile::tempdir().unwrap();
1403 fs::write(
1404 temp.path().join("Cargo.toml"),
1405 "[package]\nname = \"demo\"\n",
1406 )
1407 .unwrap();
1408 let store_path = temp.path().join(".harn/flow.sqlite");
1409 fs::create_dir_all(store_path.parent().unwrap()).unwrap();
1410
1411 {
1412 let store = SqliteFlowStore::open(&store_path, "test").unwrap();
1413 let principal = SigningKey::from_bytes(&[7; 32]);
1414 let persona = SigningKey::from_bytes(&[8; 32]);
1415 let atom = Atom::sign(
1416 vec![TextOp::Insert {
1417 offset: 0,
1418 content: "unsafe { /* SAFETY: fixture */ }".to_string(),
1419 }],
1420 Vec::new(),
1421 Provenance::new("user:test", "archivist-test", "run-1", "trace-1", "tx-1"),
1422 None,
1423 &principal,
1424 &persona,
1425 )
1426 .unwrap();
1427 store.emit_atoms(&[atom]).unwrap();
1428 }
1429
1430 let proposal = rust_unsafe_proposal(temp.path(), "2026-04-26");
1431 let report = shadow_evaluate(temp.path(), &store_path, 30, &[proposal]).unwrap();
1432 assert_eq!(report["status"], "evaluated");
1433 assert_eq!(report["recent_atoms"], 1);
1434 assert_eq!(
1435 report["false_positive_candidates"][0]["transcript_ref"],
1436 "tx-1"
1437 );
1438 assert!(report["false_positive_candidates"][0]["atom"].is_string());
1439 }
1440}