cordance-cli 0.1.1

Cordance CLI — installs the `cordance` binary. The umbrella package `cordance` re-exports this entry; either install command works.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
//! `cordance pack` — the core compilation pipeline.

use std::collections::HashMap;
use std::io::Read;

use anyhow::{Context, Result};
use camino::Utf8PathBuf;
use cordance_core::lock::SourceLock;
use cordance_core::pack::{CordancePack, DoctrinePin, PackTargets, ProjectIdentity};
use sha2::{Digest, Sha256};

pub struct PackConfig {
    pub target: Utf8PathBuf,
    pub output_mode: OutputMode,
    pub selected_targets: PackTargets,
    /// Override for the doctrine root. When `None`, resolved from `cordance.toml`
    /// (defaulting to `../engineering-doctrine` relative to `target`).
    pub doctrine_root: Option<Utf8PathBuf>,
    /// Optional override for the LLM provider. `None` means "fall back to
    /// `cordance.toml [llm].provider`". The string `"none"` disables LLM
    /// enrichment even when the config file enables it.
    pub llm_provider: Option<String>,
    /// Optional override for the Ollama model name. `None` means "use the
    /// adapter default" (`qwen2.5-coder:14b`). Only consulted when the resolved
    /// provider is `ollama`.
    pub ollama_model: Option<String>,
    /// When `true`, suppress human-readable progress output on stdout
    /// (e.g. "would write: AGENTS.md (2301 bytes)"). The MCP server path
    /// MUST set this — stdout is reserved for JSON-RPC frames.
    pub quiet: bool,
}

#[derive(Clone, Copy, PartialEq, Eq)]
pub enum OutputMode {
    Write,
    DryRun,
    Diff,
}

impl OutputMode {
    pub fn from_str(s: &str) -> Self {
        match s {
            "dry-run" => Self::DryRun,
            "diff" => Self::Diff,
            _ => Self::Write,
        }
    }
}

// Pack pipeline orchestrator. Round-4 codereview-8 flagged this for v1.0
// extraction into a `PackPipeline` trait; until that lands, the function is
// intentionally a sequence of well-commented steps (config → scan → doctrine
// → IR → LLM enrichment → emitters → lock → advise → evidence map) and the
// line count reflects pipeline depth, not nesting depth.
#[allow(clippy::too_many_lines)]
pub fn run(config: &PackConfig) -> Result<CordancePack> {
    // 0. Load project config. Strict: a malformed `cordance.toml` must fail
    //    the build loudly with the file path attached so the operator can
    //    fix it. Silent downgrade-to-defaults was the round-2 bughunt #3
    //    finding.
    let cfg = crate::config::Config::load_strict(&config.target)
        .with_context(|| format!("loading cordance.toml at {}", config.target))?;

    // 1. Detect project name + kind
    let name = detect_project_name(&config.target);
    let kind = detect_project_kind(&config.target);
    let host_os = std::env::consts::OS.to_string();
    // The axiom version is the LATEST pin cordance compiles against. We
    // capture it now so the source lock can detect axiom-algorithm drift
    // even on empty packs (see ADR 0014 / BUILD_SPEC §13).
    let axiom_pin = Some(cfg.axiom_version(&config.target));

    // 2. Scan sources. Capture any scan error so we can record it on the
    //    pack's residual_risk audit trail instead of silently degrading to
    //    an empty source list (the failure mode flagged by codereview
    //    CRITICAL — `unwrap_or_default()` makes "scan broke" look identical
    //    to "no files").
    //
    //    Round-5 bughunt CRITICAL R5-bughunt-1: drop blocked entries from the
    //    pack IR. `cordance_scan::scan_repo` surfaces blocked surfaces with
    //    `blocked: true` so `cordance scan` can render them in the operator
    //    audit report, but `pack.sources` must not contain them — otherwise
    //    every blocked file's hash would be embedded in `pack.json`, and the
    //    cordance-internal `.cordance/*` artifacts (now blocked-by-default)
    //    would create a hash self-reference loop where consecutive
    //    `cordance pack` runs produce byte-different `pack.json` outputs.
    let (sources, blocked_count, scan_error) = match cordance_scan::scan_repo(&config.target) {
        Ok(all) => {
            let total = all.len();
            let unblocked: Vec<_> = all.into_iter().filter(|r| !r.blocked).collect();
            let blocked_n = total - unblocked.len();
            if blocked_n > 0 {
                tracing::debug!(
                    blocked = blocked_n,
                    total,
                    "cordance pack: dropped blocked entries from pack.sources"
                );
            }
            (unblocked, blocked_n, None)
        }
        Err(e) => {
            tracing::warn!(error = %e, "cordance pack: source scan failed");
            (vec![], 0, Some(e.to_string()))
        }
    };
    let _ = blocked_count; // reserved for a future residual_risk note if needed

    // 3. Resolve doctrine root: explicit override wins, then config, then default.
    // Keep the configured source string (unresolved) for use in DoctrinePin.source_path
    // so that generated files don't embed absolute machine paths.
    let doctrine_source_str = config
        .doctrine_root
        .as_ref()
        .map_or_else(|| cfg.doctrine.source.clone(), |p| p.as_str().to_string());
    let doctrine_root = config
        .doctrine_root
        .clone()
        .unwrap_or_else(|| cfg.doctrine_root(&config.target));

    // 4. Load doctrine.
    //
    // Round-3 redteam #2: the previous call to `load_doctrine_or_default`
    // bypassed every hardening that lives in `load_doctrine_with_fallback`:
    //  - fallback URL validation (https-only, no userinfo, no `..`, no IP
    //    literals, no IDNA homograph silently rewriting to github.com),
    //  - SHA-keyed cache directories that defeat short-prefix collision,
    //  - explicit-pin verification against the cached HEAD,
    //  - "auto"-pin self-consistency check (round-3 bughunt #4).
    // Those were only exercised in unit tests. We now route the
    // production path through the hardened loader so a missing sibling
    // triggers a properly-validated HTTPS clone, and a tampered cache is
    // caught before any prose is read.
    //
    // A doctrine failure is non-fatal — packs still produce useful output
    // without doctrine entries — but it MUST appear on the audit trail so
    // the operator can see we silently skipped doctrine. The fallback URL
    // and the optional pin come from cordance.toml; `"auto"` is mapped to
    // `None` so `load_doctrine_with_fallback` treats it as "no pin
    // assertion" but still applies the self-consistency cross-check.
    let pin_commit: Option<&str> = if cfg.doctrine.pin_commit == "auto" {
        None
    } else {
        Some(cfg.doctrine.pin_commit.as_str())
    };
    let mut doctrine_load_warning: Option<String> = None;
    // Doctrine cache must live OUTSIDE the target tree. Round-3 scoped it to
    // `<target>/.cordance/cache/doctrine/` for per-target isolation, but that
    // exposed round-4 redteam #1: a hostile target can pre-populate that path
    // with a self-consistent fake git repo (same dirname as its HEAD commit)
    // and the auto-pin loader will load it without any network call. Moving
    // the cache to an operator-trusted location (`dirs::cache_dir()` namespace,
    // resolved by `cordance_core::paths::doctrine_cache_dir_for_url`) makes
    // it attacker-uncontrollable. Each fallback URL gets its own
    // sha256-prefixed subdirectory so different operators / forks don't
    // share cache state.
    let doctrine_cache_dir =
        cordance_core::paths::doctrine_cache_dir_for_url(&cfg.doctrine.fallback_repo);
    let doctrine_idx = match cordance_doctrine::load_doctrine_with_fallback(
        &doctrine_root,
        &cfg.doctrine.fallback_repo,
        Some(&doctrine_cache_dir),
        pin_commit,
    ) {
        Ok(idx) => idx,
        Err(err) => {
            tracing::warn!(error = %err, "doctrine load failed; using empty index");
            doctrine_load_warning = Some(format!("doctrine load failed: {err}"));
            cordance_doctrine::DoctrineIndex::empty(doctrine_root.clone())
        }
    };
    let doctrine_pins: Vec<DoctrinePin> = if let Some(commit) = &doctrine_idx.commit {
        vec![DoctrinePin {
            repo: doctrine_idx.repo.clone(),
            commit: commit.clone(),
            // Use the configured source string (relative) to avoid absolute machine paths.
            // Normalise to forward slashes so the path is portable across host OSes.
            source_path: Utf8PathBuf::from(
                format!("{doctrine_source_str}/doctrine/SEMANTIC_INDEX.md")
                    .replace('\\', "/"),
            ),
        }]
    } else {
        vec![]
    };

    // 5. Build the pack IR
    let mut pack = CordancePack {
        schema: cordance_core::schema::CORDANCE_PACK_V1.into(),
        project: ProjectIdentity {
            name,
            repo_root: config.target.clone(),
            kind,
            host_os,
            axiom_pin,
        },
        sources,
        doctrine_pins,
        targets: config.selected_targets.clone(),
        outputs: vec![],
        source_lock: SourceLock::empty(),
        advise: cordance_core::advise::AdviseReport::empty(),
        residual_risk: vec!["claim_ceiling=candidate".into()],
    };

    // 5a. If the source scan failed, surface that loudly on the audit trail.
    //     The pack still runs to completion (downstream emitters tolerate an
    //     empty source set), but the residual_risk note guarantees the
    //     condition is never silently invisible.
    if let Some(err_msg) = scan_error {
        pack.residual_risk
            .push(format!("scan error: {err_msg}"));
    }

    // 5a-bis. Same treatment for doctrine load failures (round-3 redteam #2).
    //     The pack still ships, but the operator can see we ran with an
    //     empty doctrine index and which underlying error caused it
    //     (network failure vs. pin mismatch vs. URL rejected vs. tampered
    //     cache). This is the audit-trail half of "fail loudly without
    //     aborting the pipeline".
    if let Some(warning) = doctrine_load_warning {
        pack.residual_risk.push(warning);
    }

    // 5b. Optional LLM enrichment. ADR 0002: bounded candidate prose only.
    //     This runs BEFORE emitters and writes only to `pack.residual_risk`
    //     and to a side-channel file (`.cordance/llm-candidate.json`).
    //     LLM output MUST NEVER replace emitter content.
    maybe_run_llm(config, &cfg, &mut pack);

    // 6. Collect target emitters (AGENTS.md, CLAUDE.md, .cursor/, .codex/,
    //    harness-target) and dispatch. Pack.json is dispatched separately
    //    AFTER pack.outputs is populated (round-7 codereview #1 CRITICAL).
    let emitters = build_emitters(&config.selected_targets);
    let all_outputs = dispatch_emitters(&emitters, config, &pack)?;

    pack.outputs = all_outputs;
    pack.source_lock = SourceLock::compute_from_pack(&pack);

    // 7. Persist sources.lock metadata. No-op outside Write mode.
    write_sources_lock(config, &pack)?;

    // 8. Run advise
    if let Ok(report) = cordance_advise::run_all(&pack) {
        pack.advise = report;
    }

    // 9. Emit the pack.json AND evidence map LAST so they observe the
    //    populated `pack.outputs` / `pack.source_lock` / `pack.advise`.
    //
    //    Round-7 codereview #1 CRITICAL: `PackJsonEmitter` was previously
    //    pushed into `build_emitters` and dispatched with the target emitters
    //    BEFORE `pack.outputs` and `pack.source_lock` were populated. The
    //    serialized pack.json on disk had `outputs: []` and an empty internal
    //    source_lock — silently wrong for every consumer that reads the IR.
    //    Moving pack.json into the post-emit step (parallel to the evidence
    //    map) closes that.
    //
    //    These post-step outputs are intentionally NOT folded back into
    //    `pack.outputs` (the pack and map are metadata about the pack, not
    //    target artifacts like AGENTS.md). Errors here still propagate to
    //    the CLI so the operator sees a non-zero exit code (round-7
    //    redteam #1).
    let post_emit_emitters: Vec<Box<dyn cordance_emit::TargetEmitter>> = vec![
        Box::new(cordance_emit::pack_json::PackJsonEmitter),
        Box::new(cordance_emit::evidence_map::EvidenceMapEmitter),
    ];
    let _post_outputs = dispatch_emitters(&post_emit_emitters, config, &pack)?;

    Ok(pack)
}

/// Persist `pack.source_lock` to `<target>/.cordance/sources.lock` when the
/// pack is running in Write mode. Other modes are a no-op.
///
/// Round-5 redteam #4: a hostile target can pre-plant
/// `<target>/.cordance/sources.lock` as a symlink to operator-owned files
/// (`~/.bashrc`, `~/.ssh/authorized_keys`, …). Route through
/// `safe_write_with_mkdir` so the helper refuses to follow the link.
///
/// Round-7 bughunt #3 (R7-bughunt-3): the previous shape used
/// `serde_json::to_string_pretty(...).unwrap_or_default()` and
/// `safe_write_with_mkdir(...).ok()`, which silently turned a serialise
/// failure into a 0-byte `sources.lock` (and an I/O failure into no file at
/// all). Both shapes broke the next `cordance check` with an unhelpful
/// downstream parse error rather than a real signal. Propagate both errors
/// via `?` so the operator sees the actual cause.
fn write_sources_lock(config: &PackConfig, pack: &CordancePack) -> Result<()> {
    if config.output_mode != OutputMode::Write {
        return Ok(());
    }
    let lock_path = config.target.join(".cordance").join("sources.lock");
    let lock_json = serde_json::to_string_pretty(&pack.source_lock)
        .context("serialising sources.lock")?;
    cordance_core::fs::safe_write_with_mkdir(lock_path.as_std_path(), lock_json.as_bytes())
        .context("writing sources.lock")?;
    Ok(())
}

fn build_emitters(
    targets: &PackTargets,
) -> Vec<Box<dyn cordance_emit::TargetEmitter>> {
    let mut v: Vec<Box<dyn cordance_emit::TargetEmitter>> = vec![];
    if targets.claude_code {
        v.push(Box::new(cordance_emit::agents_md::AgentsMdEmitter));
        v.push(Box::new(cordance_emit::claude_md::ClaudeMdEmitter));
    }
    if targets.cursor {
        v.push(Box::new(cordance_emit::cursor::CursorEmitter));
    }
    if targets.codex {
        v.push(Box::new(cordance_emit::codex::CodexEmitter));
    }
    if targets.axiom_harness_target {
        v.push(Box::new(
            cordance_emit::harness_target::HarnessTargetEmitter,
        ));
    }
    // The `cortex_receipt` flag exists in `PackTargets` but is intentionally
    // NOT plumbed as a `TargetEmitter` here. Round-7 codereview #2 asked to
    // either plumb or remove. The round-7 fix lane initially plumbed it via
    // a `CortexReceiptEmitter`, but doing so broke `pack.json` determinism:
    // `cordance_cortex::build_receipt` legitimately stamps the receipt with
    // `Utc::now()` (cortex receipts are operator-initiated attestations of
    // a submission moment, NOT byte-deterministic pack artifacts), so two
    // consecutive `cordance pack` runs produced byte-different
    // `.cordance/cortex-receipt.json`, which propagated into pack.outputs
    // → pack.json → source_lock and broke runs-2-3 byte-determinism.
    //
    // The right routing is: `cordance pack` produces the deterministic
    // target outputs (AGENTS.md, CLAUDE.md, .cursor/, .codex/,
    // pai-axiom-project-harness-target.json). The cortex receipt is
    // produced by the dedicated `cordance cortex push` subcommand
    // (see `cortex_cmd.rs`), which takes the same pack as input and
    // applies a wall-clock at submission time. `cordance pack --targets
    // cortex-receipt` is therefore a no-op on the emitter side; the
    // `cortex_receipt: true` flag is preserved because `cortex_cmd::run`
    // uses it to signal "this pack run is for a cortex push" and bypass
    // unrelated target emitters.
    let _ = targets.cortex_receipt;
    // NOTE: `PackJsonEmitter` is NOT pushed here. It runs as a post-step in
    // `pack_cmd::run` AFTER `pack.outputs` / `pack.source_lock` are
    // populated, so the serialized pack.json reflects the final pack state
    // rather than an empty pre-emit snapshot. Round-7 codereview #1 CRITICAL.
    v
}

/// Round-7 redteam #1 CRITICAL: `dispatch_emitters` used to print emitter
/// failures via `eprintln!` and continue. Failed emits (including
/// `safe_write` refusals against attacker-planted symlinks/junctions) would
/// produce a "succeed-loud-but-exit-0" outcome: stderr complained about the
/// emitter but the pack reported `"N outputs written"` and the process
/// exited 0. The operator could not see the failure. Now every emitter
/// error stops dispatch and propagates as `anyhow::Error` so the CLI
/// surfaces it with a non-zero exit code.
fn dispatch_emitters(
    emitters: &[Box<dyn cordance_emit::TargetEmitter>],
    config: &PackConfig,
    pack: &CordancePack,
) -> Result<Vec<cordance_core::pack::PackOutput>> {
    let mut all_outputs = vec![];
    match config.output_mode {
        OutputMode::Write => {
            for emitter in emitters {
                let outputs = emitter
                    .emit(pack, &config.target)
                    .with_context(|| format!("emitter '{}' failed", emitter.name()))?;
                all_outputs.extend(outputs);
            }
        }
        OutputMode::DryRun => {
            for emitter in emitters {
                // Round-4 bughunt #3: plan() now takes repo_root so it can
                // apply the same fence-merge logic as emit(). Otherwise the
                // sha plan records (rendered) and the sha emit writes
                // (merged) diverge, and `cordance check` reports spurious
                // drift on every fenced output after the first pack.
                let outputs = emitter
                    .plan(pack, &config.target)
                    .with_context(|| format!("emitter '{}' plan failed", emitter.name()))?;
                if !config.quiet {
                    for o in &outputs {
                        println!("would write: {} ({} bytes)", o.path, o.bytes);
                    }
                }
                all_outputs.extend(outputs);
            }
        }
        OutputMode::Diff => {
            for emitter in emitters {
                let outputs = emitter
                    .plan(pack, &config.target)
                    .with_context(|| format!("emitter '{}' diff-plan failed", emitter.name()))?;
                for o in &outputs {
                    let abs = config.target.join(&o.path);
                    let line = if abs.exists() {
                        let on_disk = std::fs::read_to_string(&abs).unwrap_or_default();
                        let on_disk_sha = hex::encode(Sha256::digest(on_disk.as_bytes()));
                        if on_disk_sha == o.sha256 {
                            format!("unchanged: {}", o.path)
                        } else {
                            format!("changed: {}", o.path)
                        }
                    } else {
                        format!("new: {}", o.path)
                    };
                    if !config.quiet {
                        println!("{line}");
                    }
                    all_outputs.push(o.clone());
                }
            }
        }
    }
    Ok(all_outputs)
}

/// Read `{root}/Cargo.toml` (when present) and return the project name from
/// either `[package].name` or `[workspace.package].name`. Falls back to the
/// canonicalised directory name, then the raw last path component.
///
/// Replaces the round-1/-2/-3-flagged hand-rolled line scanner. The `toml`
/// crate handles `name = "..."`, `name="..."` (no space), workspace inheritance
/// (`[workspace.package].name`), and quoting subtleties uniformly.
fn detect_project_name(root: &Utf8PathBuf) -> String {
    if let Some(name) = read_cargo_package_name(root) {
        return name;
    }
    // Fallback: canonicalize to resolve `.` and get the real directory name.
    if let Ok(canonical) = std::fs::canonicalize(root.as_std_path()) {
        if let Some(dir_name) = canonical.file_name() {
            if let Some(s) = dir_name.to_str() {
                return s.to_string();
            }
        }
    }
    root.file_name().unwrap_or("unknown").to_string()
}

/// Helper: deserialise `{root}/Cargo.toml` and pull out the project name.
///
/// Returns `None` when the file is absent, unreadable, malformed, or simply
/// declares neither `[package].name` nor `[workspace.package].name`. The
/// minimal struct shape ignores every other Cargo field so a real Cargo.toml
/// with `[dependencies]`, `[features]`, `[[bin]]`, etc. parses cleanly.
fn read_cargo_package_name(root: &Utf8PathBuf) -> Option<String> {
    #[derive(serde::Deserialize, Default)]
    struct PkgSection {
        name: Option<String>,
    }
    #[derive(serde::Deserialize, Default)]
    struct WorkspaceSection {
        package: Option<PkgSection>,
    }
    #[derive(serde::Deserialize, Default)]
    struct CargoToml {
        package: Option<PkgSection>,
        workspace: Option<WorkspaceSection>,
    }

    let cargo = root.join("Cargo.toml");
    if !cargo.exists() {
        return None;
    }
    let content = std::fs::read_to_string(&cargo).ok()?;
    let parsed: CargoToml = toml::from_str(&content).ok()?;

    if let Some(pkg) = parsed.package {
        if let Some(name) = pkg.name {
            if !name.is_empty() {
                return Some(name);
            }
        }
    }
    if let Some(ws) = parsed.workspace {
        if let Some(pkg) = ws.package {
            if let Some(name) = pkg.name {
                if !name.is_empty() {
                    return Some(name);
                }
            }
        }
    }
    None
}

fn detect_project_kind(root: &Utf8PathBuf) -> String {
    if root.join("Cargo.toml").exists() {
        return "rust-workspace".into();
    }
    if root.join("package.json").exists() {
        return "typescript-node".into();
    }
    if root.join("pyproject.toml").exists() || root.join("setup.py").exists() {
        return "python".into();
    }
    if root.join("go.mod").exists() {
        return "go".into();
    }
    "unknown".into()
}

/// Resolve the effective LLM provider from CLI override + project config.
///
/// CLI override wins over `cordance.toml`. `"none"` (explicit) and `None`
/// (absent) both disable enrichment.
fn resolve_llm_provider(config: &PackConfig, cfg: &crate::config::Config) -> Option<String> {
    config
        .llm_provider
        .clone()
        .or_else(|| Some(cfg.llm.provider.clone()))
        .filter(|p| p != "none")
}

/// Run the optional LLM enrichment pass. ADR 0002: candidate prose only.
///
/// All output flows to:
///   - `pack.residual_risk` — an audit note that a candidate was attached
///   - `{target}/.cordance/llm-candidate.json` — the raw candidate document
///
/// Emitters never see this content. Network or schema failures are downgraded
/// to a `warning` on stderr and a `residual_risk` note; they never abort `pack`.
fn maybe_run_llm(config: &PackConfig, cfg: &crate::config::Config, pack: &mut CordancePack) {
    let Some(provider) = resolve_llm_provider(config, cfg) else {
        return;
    };

    if provider != "ollama" {
        pack.residual_risk
            .push(format!("LLM provider '{provider}' is not supported; skipping enrichment"));
        return;
    }

    // ADR 0015: build the adapter from [llm.ollama] config; CLI --ollama-model
    // overrides the configured model when provided.
    let mut adapter = cordance_llm::OllamaAdapter::from_config(&cfg.llm.ollama);
    if let Some(override_model) = &config.ollama_model {
        override_model.clone_into(&mut adapter.model);
    }
    let base_url = adapter.base_url.clone();
    let model = adapter.model.clone();

    if !adapter.is_available() {
        eprintln!(
            "cordance: warning — Ollama not reachable at {base_url}, skipping LLM enrichment"
        );
        pack.residual_risk
            .push(format!("LLM enrichment skipped: ollama/{model} at {base_url} unreachable"));
        return;
    }

    // Bound the source set: take up to 20 non-blocked sources. The adapter is
    // strict about citation, so we use the same slice for both the prompt and
    // the citation allow-list.
    let sources: Vec<_> = pack
        .sources
        .iter()
        .filter(|s| !s.blocked)
        .take(20)
        .cloned()
        .collect();
    let source_ids: Vec<String> = sources.iter().map(|s| s.id.clone()).collect();

    let prompt = cordance_llm::prompt::bounded_pack_summary_prompt(
        &sources,
        &[],
        "Summarise this project's main purpose in one paragraph as a candidate observation.",
    );

    // Round-2 fix-A: wire 4-gram grounding. The validator was already in
    // place but `generate()` (no grounding) was being called, so the
    // hardening never reached production. Load the cited source bodies
    // (bounded per-file to keep memory and prompt size predictable) and
    // hand them to `generate_with_grounding`.
    let source_content_map = load_source_content_map(pack, &source_ids);

    match adapter.generate_with_grounding(&prompt, &source_ids, &source_content_map) {
        Ok(candidate) => {
            let claim_count = candidate.claims.len();
            pack.residual_risk.push(format!(
                "LLM candidate prose attached: {claim_count} claim(s) from ollama/{model}"
            ));

            let candidate_path = config.target.join(".cordance").join("llm-candidate.json");
            match serde_json::to_string_pretty(&candidate) {
                Ok(json) => {
                    // Round-5 redteam #4: route through the symlink-refusing
                    // helper so a hostile target can't redirect this write
                    // at the operator's own files. `safe_write_with_mkdir`
                    // handles parent-dir creation, so the explicit
                    // `create_dir_all` above is no longer needed.
                    if let Err(e) = cordance_core::fs::safe_write_with_mkdir(
                        candidate_path.as_std_path(),
                        json.as_bytes(),
                    ) {
                        eprintln!(
                            "cordance: warning — failed to write {candidate_path}: {e}"
                        );
                    }
                }
                Err(e) => {
                    eprintln!(
                        "cordance: warning — failed to serialise LLM candidate: {e}"
                    );
                }
            }
        }
        Err(e) => {
            eprintln!("cordance: warning — Ollama generation failed: {e}");
            pack.residual_risk
                .push(format!("LLM enrichment failed: {e}"));
        }
    }
}

/// Read the cited source files into a `{source_id → content}` map suitable
/// for [`cordance_llm::OllamaAdapter::generate_with_grounding`].
///
/// The validator only needs enough text to compare against 4-gram windows of
/// each claim, so we cap each entry at 4 KiB. This keeps the prompt-adjacent
/// memory bounded even on repos with large source files and keeps the
/// grounding check cheap.
///
/// Blocked sources are skipped (they would not have been cited anyway since
/// the prompt builder filters them out). I/O errors are silently ignored
/// because a missing file simply means the validator will not be able to
/// ground a claim against it — that is the correct behaviour, not a fatal
/// pack error.
fn load_source_content_map(
    pack: &CordancePack,
    cited_ids: &[String],
) -> HashMap<String, String> {
    const MAX_PER_SOURCE: u64 = 4 * 1024;

    let mut map: HashMap<String, String> = HashMap::with_capacity(cited_ids.len());
    for id in cited_ids {
        let Some(record) = pack.sources.iter().find(|s| &s.id == id) else {
            continue;
        };
        if record.blocked {
            continue;
        }
        let abs = pack.project.repo_root.join(&record.path);
        let Ok(file) = std::fs::File::open(abs.as_std_path()) else {
            continue;
        };
        let mut buf = String::new();
        if file.take(MAX_PER_SOURCE).read_to_string(&mut buf).is_ok() {
            map.insert(id.clone(), buf);
        }
    }
    map
}

#[cfg(test)]
mod tests {
    use super::*;

    fn write_cargo(content: &str) -> (tempfile::TempDir, Utf8PathBuf) {
        let dir = tempfile::tempdir().expect("tempdir");
        let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
            .expect("tempdir is utf8");
        std::fs::write(target.join("Cargo.toml"), content).expect("write Cargo.toml");
        (dir, target)
    }

    #[test]
    fn detect_project_name_reads_package_section() {
        let (_d, target) = write_cargo(
            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
        );
        assert_eq!(detect_project_name(&target), "my-crate");
    }

    #[test]
    fn detect_project_name_handles_no_space_around_equals() {
        // The hand-rolled scanner used to break on `name="x"` (no whitespace).
        let (_d, target) = write_cargo("[package]\nname=\"compact\"\n");
        assert_eq!(detect_project_name(&target), "compact");
    }

    #[test]
    fn detect_project_name_reads_workspace_package_section() {
        // Cargo workspace inheritance shape that the line scanner missed.
        let (_d, target) = write_cargo(
            "[workspace]\nmembers = [\"foo\"]\n\n[workspace.package]\nname = \"ws-name\"\nversion = \"0.1.0\"\n",
        );
        assert_eq!(detect_project_name(&target), "ws-name");
    }

    #[test]
    fn detect_project_name_prefers_package_over_workspace_package() {
        // When both are present, `[package].name` wins (it's the crate-level
        // override that ws inheritance falls back from).
        let (_d, target) = write_cargo(
            "[package]\nname = \"actual\"\n\n[workspace.package]\nname = \"inherited\"\n",
        );
        assert_eq!(detect_project_name(&target), "actual");
    }

    #[test]
    fn detect_project_name_ignores_bin_name() {
        // The old scanner would match `name = "..."` inside `[[bin]]`. The
        // toml-typed parser correctly scopes to `[package]`.
        let (_d, target) = write_cargo(
            "[package]\nname = \"libname\"\n\n[[bin]]\nname = \"binname\"\npath = \"src/main.rs\"\n",
        );
        assert_eq!(detect_project_name(&target), "libname");
    }

    #[test]
    fn detect_project_name_falls_back_when_cargo_toml_absent() {
        let dir = tempfile::tempdir().expect("tempdir");
        let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
            .expect("tempdir is utf8");
        // Without Cargo.toml we expect the canonical directory name as the
        // fallback. That directory name is the tempdir's last component.
        let detected = detect_project_name(&target);
        let expected = target.file_name().unwrap_or("unknown").to_string();
        // tempdir canonicalisation can resolve the path through `/private` on
        // macOS, so allow either the raw last component or any non-empty
        // string — the contract is "non-empty, not a hand-rolled placeholder".
        assert!(!detected.is_empty());
        assert!(
            detected == expected
                || std::fs::canonicalize(target.as_std_path())
                    .ok()
                    .and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))
                    .is_some_and(|c| detected == c),
            "detected={detected} expected={expected}"
        );
    }

    #[test]
    fn detect_project_name_falls_back_when_cargo_toml_malformed() {
        // Unparseable TOML must not panic — fall back to the dir name.
        let (_d, target) = write_cargo("this is not = = valid toml");
        let detected = detect_project_name(&target);
        assert!(!detected.is_empty(), "must produce some non-empty fallback");
    }

    #[test]
    fn detect_project_name_falls_back_when_name_missing() {
        let (_d, target) = write_cargo("[package]\nversion = \"0.1.0\"\n");
        let detected = detect_project_name(&target);
        assert!(!detected.is_empty());
        // The result must not be `version` or any literal scraped from the toml.
        assert_ne!(detected, "0.1.0");
    }

    /// Round-7 bughunt #3 (R7-bughunt-3): the previous `write_sources_lock`
    /// shape used `unwrap_or_default()` on the serde result and `.ok()` on
    /// the write call, silently turning either failure into a 0-byte
    /// `sources.lock` (or no file at all). The fixed shape propagates both
    /// errors via `?`. This test pins the I/O-failure half of the contract:
    /// pre-plant `<target>/.cordance` as a REGULAR FILE, so the helper's
    /// `create_dir_all(<target>/.cordance)` must error (can't make a
    /// directory where a file already lives) — and `write_sources_lock`
    /// must surface that as `Err`, not `Ok(())`.
    ///
    /// The serialise-failure half cannot be exercised in a unit test today
    /// because every field of `SourceLock` is plain-JSON-friendly (`String`,
    /// `Option<String>`, `Vec<SourceLockEntry>`). The contract still
    /// matters: a future schema change that adds a non-serialisable shape
    /// (a `Map<NonSerialize, _>`, an `f64::NAN`, …) would otherwise
    /// silently land a 0-byte lock on every pack — the `?` in
    /// `to_string_pretty(...).context(...)?` is the load-bearing line.
    #[test]
    fn write_sources_lock_propagates_io_failure_instead_of_swallowing() {
        let dir = tempfile::tempdir().expect("tempdir");
        let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
            .expect("tempdir is utf8");

        // Plant `.cordance` as a regular FILE at the target root. The
        // helper's `create_dir_all(<target>/.cordance)` cannot succeed
        // because a non-directory file already occupies that path.
        std::fs::write(target.join(".cordance"), b"blocker").expect("seed blocker file");

        let config = PackConfig {
            target: target.clone(),
            output_mode: OutputMode::Write,
            selected_targets: PackTargets::default(),
            doctrine_root: None,
            llm_provider: None,
            ollama_model: None,
            quiet: true,
        };
        let pack = CordancePack {
            schema: "test".into(),
            project: ProjectIdentity {
                name: "test".into(),
                kind: "unknown".into(),
                repo_root: target,
                host_os: "test".into(),
                axiom_pin: None,
            },
            sources: vec![],
            doctrine_pins: vec![],
            targets: PackTargets::default(),
            outputs: vec![],
            source_lock: SourceLock::empty(),
            advise: cordance_core::advise::AdviseReport::empty(),
            residual_risk: vec![],
        };

        let result = write_sources_lock(&config, &pack);
        assert!(
            result.is_err(),
            "expected write_sources_lock to surface the create_dir_all I/O failure, got Ok"
        );
        let err = result.unwrap_err();
        let msg = format!("{err:#}");
        assert!(
            msg.contains("writing sources.lock"),
            "error chain must mention `writing sources.lock`; got: {msg}"
        );
    }
}