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