harn-cli 0.10.11

CLI for the Harn programming language — run, test, REPL, format, and lint
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
//! Persistent per-file result cache for `harn check` (#4391).
//!
//! `harn run` skips parse/typecheck/compile on warm starts via the
//! content-addressed bytecode cache, but `check` re-did every phase on every
//! invocation — cold == warm — even though CI lint gates and editor hooks
//! re-check overwhelmingly unchanged trees. This module caches each file's
//! *complete* check outcome (structured diagnostics + rendered text), keyed
//! so that any input that could change the outcome flips the key or fails
//! validation:
//!
//! - the file's content and its transitive user-import closure, stdlib
//!   digest, codegen fingerprint, harn version, and compiler options — all
//!   via the bytecode cache's [`bytecode_cache::CacheKey`];
//! - a build-time fingerprint of the check driver itself (`harn-lint`,
//!   `commands/check`, package-config parsing) — `HARN_CHECK_FINGERPRINT` —
//!   so within-version edits to lint/preflight logic invalidate entries
//!   exactly like #2621 did for the compiler;
//! - the effective per-file `CheckConfig` (exhaustively destructured so new
//!   fields cannot be forgotten), the `--invariants` flag, the path string
//!   diagnostics render with, and the file's cross-file lint-exemption set;
//! - a recorded log of every filesystem probe the preflight scan made
//!   beyond the import closure (template/asset reads, directory checks,
//!   project-root resolution, the "did you mean" basename walk), replayed
//!   and compared on every load — the ninja/ccache discovered-dependency
//!   model. Any mismatch is a miss and the file re-checks fully.
//!
//! Artifacts are small JSON files under `<cache>/check/<key>.harncheck`,
//! written atomically. `HARN_BYTECODE_CACHE=0` disables this cache together
//! with the bytecode cache; `HARN_CHECK_RESULT_CACHE=0` disables just this
//! one.

use std::cell::RefCell;
use std::io;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use harn_vm::bytecode_cache;

use crate::package::CheckConfig;

use super::check_cmd::{CheckDiagnostic, CheckFileReport, CheckFileStatus, CheckSpan};
use super::driver::CheckedFile;

/// Bump when the artifact layout or replay semantics change.
const RESULT_CACHE_SCHEMA: u32 = 1;

/// Kill switch for just the check-result cache (the shared
/// `HARN_BYTECODE_CACHE=0` toggle also disables it).
pub(crate) const RESULT_CACHE_ENV: &str = "HARN_CHECK_RESULT_CACHE";

/// Build-time fingerprint of the check pipeline's own sources (harn-lint,
/// commands/check, package config parsing). See `build.rs`.
const CHECK_FINGERPRINT: &str = env!("HARN_CHECK_FINGERPRINT");

/// True when the check-result cache should be consulted and written.
pub(super) fn enabled() -> bool {
    if !bytecode_cache::cache_enabled() {
        return false;
    }
    match std::env::var(RESULT_CACHE_ENV).ok().as_deref() {
        Some(value) => !matches!(
            value.to_ascii_lowercase().as_str(),
            "0" | "false" | "no" | "off"
        ),
        None => true,
    }
}

// ── Probe recording ─────────────────────────────────────────────────────────
//
// The preflight scan consults files *outside* the import closure (prompt
// templates, asset files, execution directories, project-root discovery).
// While a file is being checked, those consults run through the helpers
// below, which record the observed outcome. On a cache hit the recorded
// probes are re-executed and compared; any drift fails closed to a full
// re-check.

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(super) enum Probe {
    /// `path.exists()` at scan time.
    Exists { path: PathBuf, existed: bool },
    /// `path.is_dir()` at scan time.
    IsDir { path: PathBuf, was_dir: bool },
    /// `std::fs::read_to_string(path)` at scan time; `digest` is the sha256
    /// hex of the content, `None` when the read failed.
    ReadFile {
        path: PathBuf,
        digest: Option<String>,
    },
    /// `resolve_preflight_target(anchor, target, config)` candidates. The
    /// anchor is recorded because preflight scans imported modules too, so
    /// the resolution anchor is often *not* the file being checked. The
    /// resolver probes project-root markers and `harn.toml` asset roots
    /// internally, so replaying it captures layout changes.
    ResolveTarget {
        anchor: PathBuf,
        target: String,
        candidates: Vec<PathBuf>,
    },
    /// `find_unique_basename(root, basename)` outcome (the "did you mean"
    /// suggestion walk).
    WalkUnique {
        root: PathBuf,
        basename: String,
        result: Option<PathBuf>,
    },
}

thread_local! {
    static PROBE_LOG: RefCell<Option<Vec<Probe>>> = const { RefCell::new(None) };
}

/// Run `f` with probe recording enabled on this thread (when `active`),
/// returning its result and the recorded log. Nested recording is not
/// supported (the driver records exactly one file at a time per worker
/// thread).
pub(super) fn with_probe_recording<T>(active: bool, f: impl FnOnce() -> T) -> (T, Vec<Probe>) {
    if !active {
        return (f(), Vec::new());
    }
    PROBE_LOG.with(|log| *log.borrow_mut() = Some(Vec::new()));
    let value = f();
    let probes = PROBE_LOG.with(|log| log.borrow_mut().take().unwrap_or_default());
    (value, probes)
}

fn record(probe: Probe) {
    PROBE_LOG.with(|log| {
        if let Some(probes) = log.borrow_mut().as_mut() {
            probes.push(probe);
        }
    });
}

/// Probe-recording `path.exists()`.
pub(super) fn probe_exists(path: &Path) -> bool {
    let existed = path.exists();
    record(Probe::Exists {
        path: path.to_path_buf(),
        existed,
    });
    existed
}

/// Probe-recording `path.is_dir()`.
pub(super) fn probe_is_dir(path: &Path) -> bool {
    let was_dir = path.is_dir();
    record(Probe::IsDir {
        path: path.to_path_buf(),
        was_dir,
    });
    was_dir
}

/// Probe-recording `std::fs::read_to_string`.
pub(super) fn probe_read_to_string(path: &Path) -> io::Result<String> {
    let result = std::fs::read_to_string(path);
    record(Probe::ReadFile {
        path: path.to_path_buf(),
        digest: result.as_deref().ok().map(sha256_hex),
    });
    result
}

/// Record a resolved preflight target's candidate list.
pub(super) fn record_resolve_target(anchor: &Path, target: &str, candidates: &[PathBuf]) {
    record(Probe::ResolveTarget {
        anchor: anchor.to_path_buf(),
        target: target.to_string(),
        candidates: candidates.to_vec(),
    });
}

/// Record a unique-basename walk outcome.
pub(super) fn record_walk_unique(root: &Path, basename: &str, result: Option<&Path>) {
    record(Probe::WalkUnique {
        root: root.to_path_buf(),
        basename: basename.to_string(),
        result: result.map(Path::to_path_buf),
    });
}

/// Re-execute one recorded probe and compare against the recorded outcome.
fn probe_still_valid(probe: &Probe, config: &CheckConfig) -> bool {
    match probe {
        Probe::Exists { path, existed } => path.exists() == *existed,
        Probe::IsDir { path, was_dir } => path.is_dir() == *was_dir,
        Probe::ReadFile { path, digest } => {
            std::fs::read_to_string(path).ok().map(|c| sha256_hex(&c)) == *digest
        }
        Probe::ResolveTarget {
            anchor,
            target,
            candidates,
        } => super::preflight::resolve_preflight_target(anchor, target, config) == *candidates,
        Probe::WalkUnique {
            root,
            basename,
            result,
        } => super::preflight::find_unique_basename(root, basename) == *result,
    }
}

// ── Key derivation ──────────────────────────────────────────────────────────

/// Everything that keys a file's cached check result. The `CacheKey` half is
/// the same content-addressed identity `harn run` trusts; the rest captures
/// check-only inputs.
pub(super) fn result_cache_key(
    file: &Path,
    path_str: &str,
    source: &str,
    config: &CheckConfig,
    check_invariants: bool,
    lint_exemptions: &[String],
) -> [u8; 32] {
    let base = bytecode_cache::CacheKey::from_source(file, source);
    let mut hasher = Sha256::new();
    let mut fold = |label: &str, value: &[u8]| {
        hasher.update(label.as_bytes());
        hasher.update(b"\0");
        hasher.update(value);
        hasher.update(b"\0");
    };
    fold("schema", &RESULT_CACHE_SCHEMA.to_le_bytes());
    fold(
        "check-schema",
        &super::check_cmd::CHECK_SCHEMA_VERSION.to_le_bytes(),
    );
    fold("source-hash", &base.source_hash);
    fold("import-graph-hash", &base.import_graph_hash);
    fold("harn-version", base.harn_version.as_bytes());
    fold("compiler-tag", &[base.compiler_tag]);
    fold("check-fingerprint", CHECK_FINGERPRINT.as_bytes());
    fold("path", path_str.as_bytes());
    fold("invariants", &[u8::from(check_invariants)]);
    for name in lint_exemptions {
        fold("lint-exemption", name.as_bytes());
    }
    fold_config(&mut fold, config);
    hasher.finalize().into()
}

/// Fold every `CheckConfig` field into the key. Exhaustive destructuring
/// makes adding a config field without updating the cache key a compile
/// error rather than a stale-cache bug.
fn fold_config(fold: &mut impl FnMut(&str, &[u8]), config: &CheckConfig) {
    let CheckConfig {
        strict,
        strict_types,
        disable_rules,
        host_capabilities,
        host_capabilities_path,
        bundle_root,
        preflight_severity,
        preflight_allow,
    } = config;
    fold("strict", &[u8::from(*strict)]);
    fold("strict-types", &[u8::from(*strict_types)]);
    let mut disable_rules: Vec<&String> = disable_rules.iter().collect();
    disable_rules.sort();
    for rule in disable_rules {
        fold("disable-rule", rule.as_bytes());
    }
    let mut caps: Vec<(&String, &Vec<String>)> = host_capabilities.iter().collect();
    caps.sort_by_key(|(name, _)| *name);
    for (name, ops) in caps {
        fold("host-capability", name.as_bytes());
        let mut ops: Vec<&String> = ops.iter().collect();
        ops.sort();
        for op in ops {
            fold("host-capability-op", op.as_bytes());
        }
    }
    if let Some(path) = host_capabilities_path {
        fold("host-capabilities-path", path.as_bytes());
        // The file's *content* feeds preflight decisions; fold it here so an
        // edited manifest invalidates without waiting for probe replay.
        let content = std::fs::read_to_string(path).unwrap_or_default();
        fold("host-capabilities-content", content.as_bytes());
    }
    if let Some(root) = bundle_root {
        fold("bundle-root", root.as_bytes());
    }
    if let Some(severity) = preflight_severity {
        fold("preflight-severity", severity.as_bytes());
    }
    let mut allow: Vec<&String> = preflight_allow.iter().collect();
    allow.sort();
    for tag in allow {
        fold("preflight-allow", tag.as_bytes());
    }
}

// ── Artifact layout ─────────────────────────────────────────────────────────

#[derive(Debug, Serialize, Deserialize)]
struct CachedCheckResult {
    schema: u32,
    status: CachedStatus,
    diagnostics: Vec<CachedDiagnostic>,
    stdout_text: String,
    stderr_text: String,
    probes: Vec<Probe>,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum CachedStatus {
    Ok,
    Warning,
    Error,
}

#[derive(Debug, Serialize, Deserialize)]
struct CachedDiagnostic {
    source: String,
    severity: String,
    code: Option<String>,
    message: String,
    span: Option<(usize, usize)>,
    help: Option<String>,
}

/// Map a cached severity/source string back to the `&'static str` the live
/// pipeline uses. Unknown values mean the artifact came from a different
/// (buggy or future) writer: fail closed by rejecting the artifact.
fn intern_diag_str(value: &str) -> Option<&'static str> {
    Some(match value {
        "type" => "type",
        "compile" => "compile",
        "lint" => "lint",
        "preflight" => "preflight",
        "invariant" => "invariant",
        "io" => "io",
        "analysis" => "analysis",
        "lexer" => "lexer",
        "parser" => "parser",
        "error" => "error",
        "warning" => "warning",
        "info" => "info",
        _ => return None,
    })
}

fn cache_path(key: &[u8; 32]) -> PathBuf {
    bytecode_cache::cache_dir()
        .join("check")
        .join(format!("{}.harncheck", hex(key)))
}

// ── Load / store ────────────────────────────────────────────────────────────

/// Try to replay a cached check result. Returns `None` on miss, disabled
/// cache, schema drift, unknown interned strings, or any probe mismatch.
pub(super) fn load(
    key: &[u8; 32],
    path_str: &str,
    config: &CheckConfig,
    want_text: bool,
) -> Option<CheckedFile> {
    if !enabled() {
        return None;
    }
    let bytes = std::fs::read(cache_path(key)).ok()?;
    let cached: CachedCheckResult = serde_json::from_slice(&bytes).ok()?;
    if cached.schema != RESULT_CACHE_SCHEMA {
        return None;
    }
    for probe in &cached.probes {
        if !probe_still_valid(probe, config) {
            return None;
        }
    }
    let mut diagnostics = Vec::with_capacity(cached.diagnostics.len());
    for diag in cached.diagnostics {
        diagnostics.push(CheckDiagnostic {
            source: intern_diag_str(&diag.source)?,
            severity: intern_diag_str(&diag.severity)?,
            code: diag.code,
            message: diag.message,
            span: diag.span.map(|(start, end)| CheckSpan { start, end }),
            help: diag.help,
        });
    }
    let status = match cached.status {
        CachedStatus::Ok => CheckFileStatus::Ok,
        CachedStatus::Warning => CheckFileStatus::Warning,
        CachedStatus::Error => CheckFileStatus::Error,
    };
    let mut text = super::check_cmd::CheckTextOutput::default();
    if want_text {
        text.stdout = cached.stdout_text;
        text.stderr = cached.stderr_text;
    }
    Some(CheckedFile {
        report: CheckFileReport {
            path: path_str.to_string(),
            status,
            diagnostics,
        },
        strict: config.strict,
        text,
    })
}

/// Persist a finished check result. Errors are ignored (cache is advisory);
/// writes are atomic (tmp + rename) so racing invocations stay consistent.
pub(super) fn store(key: &[u8; 32], checked: &CheckedFile, probes: Vec<Probe>) {
    if !enabled() {
        return;
    }
    let cached = CachedCheckResult {
        schema: RESULT_CACHE_SCHEMA,
        status: match checked.report.status {
            CheckFileStatus::Ok => CachedStatus::Ok,
            CheckFileStatus::Warning => CachedStatus::Warning,
            CheckFileStatus::Error => CachedStatus::Error,
        },
        diagnostics: checked
            .report
            .diagnostics
            .iter()
            .map(|diag| CachedDiagnostic {
                source: diag.source.to_string(),
                severity: diag.severity.to_string(),
                code: diag.code.clone(),
                message: diag.message.clone(),
                span: diag.span.map(|span| (span.start, span.end)),
                help: diag.help.clone(),
            })
            .collect(),
        stdout_text: checked.text.stdout.clone(),
        stderr_text: checked.text.stderr.clone(),
        probes,
    };
    let path = cache_path(key);
    let Some(parent) = path.parent() else {
        return;
    };
    if std::fs::create_dir_all(parent).is_err() {
        return;
    }
    let Ok(bytes) = serde_json::to_vec(&cached) else {
        return;
    };
    let tmp = path.with_extension(format!("tmp{}", std::process::id()));
    if std::fs::write(&tmp, bytes).is_ok() {
        let _ = std::fs::rename(&tmp, &path);
    }
}

fn sha256_hex(content: &str) -> String {
    hex(&Sha256::digest(content.as_bytes()).into())
}

fn hex(bytes: &[u8; 32]) -> String {
    let mut out = String::with_capacity(64);
    for byte in bytes {
        out.push_str(&format!("{byte:02x}"));
    }
    out
}