Skip to main content

harn_cli/commands/
flow.rs

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        &current_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/// Inputs for [`ship_watch_payload`]. Mirrors the fields the CLI's
89/// `harn flow ship watch` accepts, but lets in-process callers (tests
90/// and library consumers) build the JSON receipt without going through
91/// clap or the binary surface.
92#[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    /// Optional path to receive the JSON receipt as a side effect, matching
99    /// the CLI's `--mock-pr-out` flag.
100    pub mock_pr_out: Option<&'a Path>,
101}
102
103/// In-process implementation of `harn flow ship watch`.
104///
105/// Returns the same JSON payload the CLI prints to stdout. When
106/// `inputs.mock_pr_out` is set the payload is also written to that
107/// path, matching the binary's `--mock-pr-out` behavior so callers can
108/// verify the file/stdout contract without spawning a subprocess.
109pub 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        // Idle payload intentionally does not honor `mock_pr_out` — the
116        // CLI's pre-#1106 behavior only wrote the receipt file when an
117        // actual mock PR was opened, not on idle no-op runs.
118        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}