Skip to main content

kranz_engine/
domain_lint.rs

1//! Clean-room domain-vocabulary lint (KRZ-314; the positioning ADR's IP
2//! boundary — `docs/knowledge/decisions/positioning-governance-evidence-layer.md`).
3//!
4//! Kranz core is domain-free: the positioning ADR's protected vocabulary
5//! classes (consumer names, legacy-platform terms, consumer schema
6//! identifiers) must never appear in code, comments, docs, tickets,
7//! fixtures, or example config. Domain knowledge ships in private packs
8//! behind the pack contract (KRZ-313). This module is the mechanical guard:
9//! it scans the scoped tree and fails when a banned term appears, naming
10//! file and line.
11//!
12//! # Why salted hashes, and what the salt does NOT do
13//!
14//! The denylist must not itself leak the vocabulary it bans, so the committed
15//! config (`.kranz/domain-denylist.json`) stores ONLY salted SHA-256 hashes of
16//! normalized terms; the plaintext list lives outside the repo (the gitignored
17//! `.kranz/domain-terms.local`, regenerated into the config by
18//! `kranz domain-lint --seed-config`). The salt is committed and therefore NOT
19//! a secret: it does not make the list unguessable — a motivated reader who
20//! suspects a term can recompute `sha256(salt || NUL || term)` and confirm it.
21//! What the salt buys is that the hashes are not bare `sha256(term)` values a
22//! rainbow table or a search engine resolves with zero per-repo effort. The
23//! real boundary is that plaintext never enters the repo; the hash config is
24//! the auditable shadow of it. The salt is generated once and preserved across
25//! reseeds so waiver fingerprints stay stable.
26//!
27//! # Normalization (the matching contract)
28//!
29//! A term (or a span of scanned text) normalizes to a sequence of TOKENS:
30//! maximal runs of ASCII alphanumeric characters, ASCII-lowercased, joined
31//! with one space. Everything else — whitespace, punctuation, non-ASCII — is
32//! a separator. Consequences, all deliberate:
33//!
34//! - case, spacing, and punctuation variants of a term match (`X y`, `x-y`,
35//!   `x.y`, `x  y` are the same term);
36//! - a phrase may span a line break (prose wraps; scanning is over the whole
37//!   file's token stream, with the hit reported at the first token's line);
38//! - camelCase compounds are ONE token (`zzAcme` → `zzacme`) and do NOT match
39//!   the two-token term `zz acme` — list authors seed both forms when both
40//!   matter;
41//! - terms longer than [`MAX_TERM_TOKENS`] tokens are refused at seed time
42//!   (the scanner never builds longer n-grams, so a longer term could never
43//!   match — failing loudly beats a silently vacuous entry).
44//!
45//! # Scope
46//!
47//! The candidate set is `git ls-files --cached --others --exclude-standard`:
48//! tracked files plus untracked-but-not-ignored files. That is the only
49//! honest way to "respect .gitignore" — git's own ignore engine decides — and
50//! it makes the local command and the CI job agree on the same tree. On top
51//! of that the lint excludes, by path:
52//!
53//! - `.kranz/missions/` — mission runtime artifacts are OPERATOR content
54//!   (ticket text, plans, reports quote whatever the operator's domain
55//!   actually is; the boundary governs kranz core, not what missions were
56//!   about);
57//! - the lint's own files (the denylist config, the allowlist, the plaintext
58//!   terms file) — they contain only hashes and fingerprints, but the guard
59//!   never reads its own policy;
60//! - binary files (a NUL byte in the content) and files over
61//!   [`MAX_FILE_BYTES`], skipped whole — a bounded read keeps the lint's cost
62//!   predictable and a partial scan would be a false sense of coverage (same
63//!   posture as `scrub`'s scan bound).
64//!
65//! # Waivers
66//!
67//! A finding carries a FINGERPRINT:
68//! `sha256(salt || NUL || normalized-term || NUL || repo-relative-path)`,
69//! truncated to 24 hex chars. `.kranz/domain-allowlist` holds one fingerprint
70//! per line — the reviewed-waiver idiom of `.kranz/secret-allowlist`, with the
71//! same rule: comments describe the waived hit, they never quote it. Unlike
72//! the secret allowlist the fingerprint is PATH-SCOPED: a banned term waived
73//! in one file still trips everywhere else, because a global pass on a
74//! vocabulary boundary is a much wider hole than a global pass on one
75//! detector's false positive. A waiver covers every occurrence of that term
76//! in that path, present and future — re-review on any edit that leans on it.
77
78use anyhow::{anyhow, bail, Context, Result};
79use serde::{Deserialize, Serialize};
80use sha2::{Digest, Sha256};
81use std::collections::BTreeSet;
82use std::path::{Path, PathBuf};
83
84/// Committed denylist config: salt + hashed banned terms, never plaintext.
85pub const DENYLIST_PATH: &str = ".kranz/domain-denylist.json";
86
87/// Committed reviewed waivers, one finding fingerprint per line (comments
88/// describe, never quote — the `.kranz/secret-allowlist` idiom).
89pub const ALLOWLIST_PATH: &str = ".kranz/domain-allowlist";
90
91/// Gitignored operator-local plaintext source the denylist is seeded from
92/// (`kranz domain-lint --seed-config`). Never committed: it IS the vocabulary
93/// the boundary protects.
94pub const TERMS_LOCAL_PATH: &str = ".kranz/domain-terms.local";
95
96/// Longest banned phrase the scanner can match, in normalized tokens. Seed
97/// refuses longer terms rather than entering them vacuously.
98pub const MAX_TERM_TOKENS: usize = 8;
99
100/// Files larger than this are skipped whole (see the module docs' scope
101/// section); 8 MiB mirrors `scrub`'s scan bound.
102const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024;
103
104/// Config format version. Bumping lets a future format change fail loudly on
105/// old readers instead of silently linting with a misread policy.
106const CONFIG_VERSION: u32 = 1;
107
108/// One banned-term hit. Carries the path-scoped waiver fingerprint, the
109/// repo-relative path, and the 1-based line of the match's first token —
110/// NEVER the matched text (the text is the thing the boundary protects).
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct DomainFinding {
114    pub fingerprint: String,
115    pub path: String,
116    pub line: usize,
117}
118
119/// The loaded denylist: the salt plus the set of hashed normalized terms.
120#[derive(Debug, Clone)]
121pub struct Denylist {
122    salt: String,
123    hashes: BTreeSet<String>,
124}
125
126impl Denylist {
127    /// Number of banned terms (hashes) — the only thing a report may say
128    /// about the list's contents.
129    pub fn term_count(&self) -> usize {
130        self.hashes.len()
131    }
132}
133
134/// What a lint run found. `files_skipped` counts binary/oversized/unreadable
135/// candidates — visible so a swelling skip count is noticeable, never fatal.
136#[derive(Debug, Clone, Default)]
137pub struct LintReport {
138    pub findings: Vec<DomainFinding>,
139    pub files_scanned: usize,
140    pub files_skipped: usize,
141}
142
143impl LintReport {
144    pub fn is_clean(&self) -> bool {
145        self.findings.is_empty()
146    }
147}
148
149/// The on-disk config shape. `hash` records the construction in words so a
150/// reader of the JSON never has to guess the input layout.
151#[derive(Debug, Serialize, Deserialize)]
152#[serde(rename_all = "camelCase")]
153struct DenylistConfig {
154    version: u32,
155    salt: String,
156    hash: String,
157    terms: BTreeSet<String>,
158}
159
160const HASH_SCHEME: &str = "sha256(salt || NUL || normalized-term)";
161
162/// Normalize a term or text span per the module-docs contract: maximal ASCII
163/// alphanumeric runs, lowercased, joined with one space.
164pub fn normalize_term(text: &str) -> String {
165    tokens(text)
166        .into_iter()
167        .map(|(token, _)| token)
168        .collect::<Vec<_>>()
169        .join(" ")
170}
171
172/// Split `text` into normalized tokens with the byte offset of each token's
173/// start in the ORIGINAL text (offsets feed the line-number lookup; the
174/// normalized form alone cannot locate a hit).
175fn tokens(text: &str) -> Vec<(String, usize)> {
176    let mut out = Vec::new();
177    let bytes = text.as_bytes();
178    let mut i = 0;
179    while i < bytes.len() {
180        if bytes[i].is_ascii_alphanumeric() {
181            let start = i;
182            while i < bytes.len() && bytes[i].is_ascii_alphanumeric() {
183                i += 1;
184            }
185            out.push((text[start..i].to_ascii_lowercase(), start));
186        } else {
187            i += 1;
188        }
189    }
190    out
191}
192
193/// `sha256(salt || NUL || input)` as lowercase hex — the denylist entry form.
194fn salted_hash(salt: &str, input: &str) -> String {
195    let mut hasher = Sha256::new();
196    hasher.update(salt.as_bytes());
197    hasher.update([0]);
198    hasher.update(input.as_bytes());
199    hasher
200        .finalize()
201        .iter()
202        .map(|b| format!("{b:02x}"))
203        .collect()
204}
205
206/// The waiver fingerprint for one normalized term in one repo-relative path:
207/// the salted hash truncated to 24 hex chars, path-scoped (see module docs).
208pub fn waiver_fingerprint(salt: &str, normalized_term: &str, path: &str) -> String {
209    let full = salted_hash(salt, &format!("{normalized_term}\0{path}"));
210    full[..24].to_string()
211}
212
213fn is_64_hex(value: &str) -> bool {
214    value.len() == 64
215        && value
216            .bytes()
217            .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
218}
219
220/// Parse and VALIDATE a denylist config. Every form rule fails loudly: a
221/// config that does not parse exactly is a policy the lint cannot honestly
222/// enforce, and an empty or malformed term set is a silently vacuous guard.
223/// Nothing but 64-hex hashes and a 64-hex salt is accepted — the committed
224/// config must carry no readable vocabulary.
225pub fn load_denylist(text: &str) -> Result<Denylist> {
226    let config: DenylistConfig =
227        serde_json::from_str(text).context("domain denylist config is not valid JSON")?;
228    if config.version != CONFIG_VERSION {
229        bail!(
230            "domain denylist config version {} is unsupported (expected {CONFIG_VERSION})",
231            config.version
232        );
233    }
234    if !is_64_hex(&config.salt) {
235        bail!("domain denylist salt must be 64 lowercase hex characters");
236    }
237    if config.hash != HASH_SCHEME {
238        bail!("domain denylist hash scheme is not the documented one");
239    }
240    if config.terms.is_empty() {
241        bail!("domain denylist has no terms — an empty denylist is a vacuous guard");
242    }
243    for term in &config.terms {
244        if !is_64_hex(term) {
245            bail!("domain denylist entries must be 64 lowercase hex (salted hashes only)");
246        }
247    }
248    Ok(Denylist {
249        salt: config.salt,
250        hashes: config.terms,
251    })
252}
253
254/// Render a denylist config from a plaintext terms file (one term per line,
255/// `#` comment lines allowed), keeping an EXISTING config's salt when one is
256/// passed so waiver fingerprints survive reseeding; a fresh salt otherwise.
257/// The output is hashes only — this function is the one place plaintext and
258/// the committed form meet, and nothing plaintext crosses.
259pub fn seed_config(existing_config: Option<&str>, terms_text: &str) -> Result<String> {
260    let salt = match existing_config {
261        Some(existing) => {
262            load_denylist(existing)
263                .context("existing denylist config cannot be reseeded")?
264                .salt
265        }
266        None => format!(
267            "{}{}",
268            uuid::Uuid::new_v4().simple(),
269            uuid::Uuid::new_v4().simple()
270        ),
271    };
272
273    let mut hashes = BTreeSet::new();
274    for (index, line) in terms_text.lines().enumerate() {
275        let line = line.trim();
276        if line.is_empty() || line.starts_with('#') {
277            continue;
278        }
279        let normalized = normalize_term(line);
280        if normalized.is_empty() {
281            // A line with no ASCII-alphanumeric content carries no term;
282            // skipping it quietly mirrors blank-line handling.
283            continue;
284        }
285        let count = normalized.split(' ').count();
286        if count > MAX_TERM_TOKENS {
287            // Named by line, never by content: the error itself must not leak.
288            bail!(
289                "terms file line {} normalizes to {count} tokens (max {MAX_TERM_TOKENS})",
290                index + 1
291            );
292        }
293        hashes.insert(salted_hash(&salt, &normalized));
294    }
295    if hashes.is_empty() {
296        bail!("terms file yielded no terms — refusing to seed an empty (vacuous) denylist");
297    }
298
299    let config = DenylistConfig {
300        version: CONFIG_VERSION,
301        salt,
302        hash: HASH_SCHEME.to_string(),
303        terms: hashes,
304    };
305    Ok(serde_json::to_string_pretty(&config)? + "\n")
306}
307
308/// True when a repo-relative path is OUT of scope by rule (see the module
309/// docs): mission runtime artifacts are operator content, and the lint never
310/// reads its own policy files.
311pub fn is_excluded_path(path: &Path) -> bool {
312    let path = path.to_string_lossy();
313    let path = path.strip_prefix("./").unwrap_or(&path);
314    path.starts_with(".kranz/missions/")
315        || path == DENYLIST_PATH
316        || path == ALLOWLIST_PATH
317        || path == TERMS_LOCAL_PATH
318}
319
320/// Enumerate lint candidates under `repo_root`: `git ls-files --cached
321/// --others --exclude-standard`, i.e. tracked plus untracked-but-not-ignored
322/// files — git's own ignore engine is the only faithful .gitignore reader,
323/// and it keeps the local command and CI on the same scope. Excluded paths
324/// ([`is_excluded_path`]) are filtered here so they are never even opened.
325pub fn enumerate_scoped_files(repo_root: &Path) -> Result<Vec<PathBuf>> {
326    // The lint runs operator-side on the operator's own checkout (or a CI
327    // checkout of it); plain git with no hook surface is the right posture —
328    // ls-files executes nothing from the tree.
329    let out = std::process::Command::new("git")
330        .args([
331            "ls-files",
332            "-z",
333            "--cached",
334            "--others",
335            "--exclude-standard",
336        ])
337        .current_dir(repo_root)
338        .output()
339        .context("spawn git ls-files")?;
340    if !out.status.success() {
341        return Err(anyhow!(
342            "git ls-files failed: {}",
343            String::from_utf8_lossy(&out.stderr).trim()
344        ));
345    }
346    let mut paths: Vec<PathBuf> = String::from_utf8_lossy(&out.stdout)
347        .split('\0')
348        .filter(|entry| !entry.is_empty())
349        .map(PathBuf::from)
350        .filter(|path| !is_excluded_path(path))
351        .collect();
352    paths.sort();
353    Ok(paths)
354}
355
356/// Byte offsets of every line start in `text` (line 1 starts at 0).
357fn line_starts(text: &str) -> Vec<usize> {
358    let mut starts = vec![0];
359    for (index, byte) in text.bytes().enumerate() {
360        if byte == b'\n' {
361            starts.push(index + 1);
362        }
363    }
364    starts
365}
366
367/// Scan one file's text. Every contiguous token n-gram up to
368/// [`MAX_TERM_TOKENS`] is hashed and compared against the denylist — the
369/// config stores hashes only, so the scanner cannot know each term's length
370/// and must try them all (cost: a handful of short hashes per token).
371fn scan_text(denylist: &Denylist, path: &str, text: &str, findings: &mut Vec<DomainFinding>) {
372    let tokens = tokens(text);
373    let starts = line_starts(text);
374    for start in 0..tokens.len() {
375        let end = (start + MAX_TERM_TOKENS).min(tokens.len());
376        let mut ngram = String::new();
377        for (token, _) in &tokens[start..end] {
378            if !ngram.is_empty() {
379                ngram.push(' ');
380            }
381            ngram.push_str(token);
382            if denylist
383                .hashes
384                .contains(&salted_hash(&denylist.salt, &ngram))
385            {
386                let line = starts.partition_point(|offset| *offset <= tokens[start].1);
387                findings.push(DomainFinding {
388                    fingerprint: waiver_fingerprint(&denylist.salt, &ngram, path),
389                    path: path.to_string(),
390                    line,
391                });
392            }
393        }
394    }
395}
396
397/// Lint explicit repo-relative `paths` against the `denylist`. Waivers are
398/// applied by the caller ([`filter_allowed`]); this is the raw hit stream.
399/// Skips (binary, oversized, unreadable, non-regular) are counted, never
400/// fatal — the operator's own tree is the input, and one bad file must not
401/// silence the rest of it.
402pub fn lint_files(repo_root: &Path, paths: &[PathBuf], denylist: &Denylist) -> LintReport {
403    let mut report = LintReport::default();
404    for path in paths {
405        if is_excluded_path(path) {
406            continue;
407        }
408        let full = repo_root.join(path);
409        // symlink_metadata + is_file: never read through a symlink — a
410        // checked-in link carries no lintable bytes of its own (scrub's
411        // posture, minus the no-follow machinery: this scanner walks the
412        // operator's checkout, not a worker-planted tree).
413        let Ok(metadata) = std::fs::symlink_metadata(&full) else {
414            report.files_skipped += 1;
415            continue;
416        };
417        if !metadata.is_file() || metadata.len() > MAX_FILE_BYTES {
418            report.files_skipped += 1;
419            continue;
420        }
421        let Ok(bytes) = std::fs::read(&full) else {
422            report.files_skipped += 1;
423            continue;
424        };
425        // Binary content is unlintable prose-wise; a NUL byte is the marker.
426        if bytes.contains(&0) {
427            report.files_skipped += 1;
428            continue;
429        }
430        let text = String::from_utf8_lossy(&bytes);
431        let path_str = path.to_string_lossy().replace('\\', "/");
432        scan_text(denylist, &path_str, &text, &mut report.findings);
433        report.files_scanned += 1;
434    }
435    report
436}
437
438/// Drop findings whose fingerprint is waived. The allowlist text format is
439/// the secret allowlist's exactly (`scrub::read_allowlist_text`): one
440/// fingerprint per line, `#` comments, comments describe and never quote.
441pub fn filter_allowed(
442    findings: Vec<DomainFinding>,
443    allowed: &BTreeSet<String>,
444) -> Vec<DomainFinding> {
445    findings
446        .into_iter()
447        .filter(|finding| !allowed.contains(&finding.fingerprint))
448        .collect()
449}
450
451/// The full local/CI flow: enumerate the scoped tree, lint it, apply waivers.
452pub fn lint_tree(
453    repo_root: &Path,
454    denylist: &Denylist,
455    allowed: &BTreeSet<String>,
456) -> Result<LintReport> {
457    let paths = enumerate_scoped_files(repo_root)?;
458    let mut report = lint_files(repo_root, &paths, denylist);
459    report.findings = filter_allowed(report.findings, allowed);
460    // Deterministic output order: the same tree always reports the same way.
461    report
462        .findings
463        .sort_by(|a, b| (&a.path, a.line, &a.fingerprint).cmp(&(&b.path, b.line, &b.fingerprint)));
464    Ok(report)
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    /// All test vocabulary is synthetic (`zz-` convention) — the committed
472    /// fixture vocabulary must never be a real protected term.
473    const TEST_TERMS: &str = "# synthetic fixture vocabulary, never real\n\
474                              zz-acme lint canary\n\
475                              zz other term\n";
476
477    fn test_denylist() -> Denylist {
478        let config = seed_config(None, TEST_TERMS).expect("seed test config");
479        load_denylist(&config).expect("parse seeded config")
480    }
481
482    #[test]
483    fn domain_lint_normalize_collapses_case_spacing_and_punctuation() {
484        assert_eq!(normalize_term("ZZ  Acme—Corp!"), "zz acme corp");
485        assert_eq!(normalize_term("zz-acme"), normalize_term("ZZ ACME"));
486        assert_eq!(normalize_term("  x.y  "), "x y");
487        // CamelCase compounds are one token (documented contract).
488        assert_eq!(normalize_term("zzAcme"), "zzacme");
489        // Non-ASCII is a separator, not a token character.
490        assert_eq!(normalize_term("café"), "caf");
491    }
492
493    #[test]
494    fn domain_lint_seed_config_emits_hashes_only_and_roundtrips() {
495        let config = seed_config(None, TEST_TERMS).expect("seed");
496        // The committed form must not carry the plaintext it was built from.
497        for fragment in ["zz", "acme", "canary", "lint"] {
498            assert!(
499                !config.contains(fragment),
500                "seeded config leaks plaintext fragment {fragment:?}: {config}"
501            );
502        }
503        let value: serde_json::Value = serde_json::from_str(&config).expect("valid json");
504        let keys: Vec<&str> = value
505            .as_object()
506            .expect("object")
507            .keys()
508            .map(String::as_str)
509            .collect();
510        assert_eq!(keys, ["hash", "salt", "terms", "version"]);
511        // And it must work: a lint with this config trips on the seeded terms.
512        let denylist = load_denylist(&config).expect("load");
513        let dir = tempfile::tempdir().unwrap();
514        std::fs::write(
515            dir.path().join("f.md"),
516            "mentions the ZZ-Acme lint canary\n",
517        )
518        .unwrap();
519        let report = lint_files(dir.path(), &[PathBuf::from("f.md")], &denylist);
520        assert_eq!(report.findings.len(), 1, "{report:?}");
521        assert_eq!(report.findings[0].line, 1);
522    }
523
524    #[test]
525    fn domain_lint_seeded_term_trips_naming_file_and_line_then_removal_goes_green() {
526        let denylist = test_denylist();
527        let dir = tempfile::tempdir().unwrap();
528        let path = PathBuf::from("docs/scratch-fixture.md");
529        std::fs::create_dir_all(dir.path().join("docs")).unwrap();
530        std::fs::write(
531            dir.path().join(&path),
532            "line one\nsecond line plants the zz acme lint canary here\nthird\n",
533        )
534        .unwrap();
535
536        let report = lint_files(dir.path(), std::slice::from_ref(&path), &denylist);
537        assert_eq!(report.findings.len(), 1, "{report:?}");
538        assert_eq!(report.findings[0].path, "docs/scratch-fixture.md");
539        assert_eq!(report.findings[0].line, 2, "{report:?}");
540
541        // Removing the term goes green.
542        std::fs::write(dir.path().join(&path), "line one\nsecond line\nthird\n").unwrap();
543        let report = lint_files(dir.path(), &[path], &denylist);
544        assert!(report.is_clean(), "{report:?}");
545    }
546
547    #[test]
548    fn domain_lint_phrase_spanning_a_line_break_matches_at_first_token_line() {
549        let denylist = test_denylist();
550        let dir = tempfile::tempdir().unwrap();
551        // "zz acme lint canary" split across lines 2 and 3.
552        std::fs::write(
553            dir.path().join("wrap.md"),
554            "one\nplants the zz\nacme lint canary here\n",
555        )
556        .unwrap();
557        let report = lint_files(dir.path(), &[PathBuf::from("wrap.md")], &denylist);
558        assert_eq!(report.findings.len(), 1, "{report:?}");
559        assert_eq!(report.findings[0].line, 2, "{report:?}");
560    }
561
562    #[test]
563    fn domain_lint_waiver_suppresses_only_the_waived_path() {
564        let denylist = test_denylist();
565        let dir = tempfile::tempdir().unwrap();
566        std::fs::write(dir.path().join("a.md"), "zz acme lint canary\n").unwrap();
567        std::fs::write(dir.path().join("b.md"), "zz acme lint canary\n").unwrap();
568        let paths = vec![PathBuf::from("a.md"), PathBuf::from("b.md")];
569
570        let report = lint_files(dir.path(), &paths, &denylist);
571        assert_eq!(report.findings.len(), 2);
572        // The finding's own fingerprint is the waiver token.
573        let waiver: BTreeSet<String> = report
574            .findings
575            .iter()
576            .filter(|f| f.path == "a.md")
577            .map(|f| f.fingerprint.clone())
578            .collect();
579        let remaining = filter_allowed(report.findings, &waiver);
580        assert_eq!(remaining.len(), 1, "{remaining:?}");
581        assert_eq!(remaining[0].path, "b.md", "a waiver is path-scoped");
582    }
583
584    #[test]
585    fn domain_lint_load_rejects_non_hash_form_configs() {
586        // A readable entry is precisely the leak the config form forbids.
587        for bad in [
588            r#"{"version":1,"salt":"aaaa","hash":"sha256(salt || NUL || normalized-term)","terms":["ab"]}"#,
589            r#"{"version":1,"salt":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","hash":"sha256(salt || NUL || normalized-term)","terms":["zz acme"]}"#,
590            r#"{"version":2,"salt":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","hash":"sha256(salt || NUL || normalized-term)","terms":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}"#,
591            r#"{"version":1,"salt":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","hash":"sha256(salt || NUL || normalized-term)","terms":[]}"#,
592        ] {
593            assert!(load_denylist(bad).is_err(), "accepted {bad}");
594        }
595    }
596
597    #[test]
598    fn domain_lint_seed_refuses_overlong_terms_and_empty_inputs() {
599        let long = (0..=MAX_TERM_TOKENS)
600            .map(|i| format!("t{i}"))
601            .collect::<Vec<_>>()
602            .join(" ");
603        assert!(seed_config(None, &long).is_err(), "overlong term seeded");
604        assert!(seed_config(None, "# only comments\n\n").is_err());
605    }
606
607    #[test]
608    fn domain_lint_seed_preserves_salt_across_reseeds() {
609        let first = seed_config(None, "zz one\n").unwrap();
610        let second = seed_config(Some(&first), "zz one\nzz two\n").unwrap();
611        let first: serde_json::Value = serde_json::from_str(&first).unwrap();
612        let second: serde_json::Value = serde_json::from_str(&second).unwrap();
613        assert_eq!(first["salt"], second["salt"], "reseed must keep the salt");
614        assert_eq!(second["terms"].as_array().unwrap().len(), 2);
615    }
616
617    #[test]
618    fn domain_lint_excluded_paths_cover_missions_and_own_config() {
619        assert!(is_excluded_path(Path::new(".kranz/missions/m-zz/plan.md")));
620        assert!(is_excluded_path(Path::new(DENYLIST_PATH)));
621        assert!(is_excluded_path(Path::new(ALLOWLIST_PATH)));
622        assert!(is_excluded_path(Path::new(TERMS_LOCAL_PATH)));
623        assert!(!is_excluded_path(Path::new("crates/engine/src/lib.rs")));
624        assert!(!is_excluded_path(Path::new(
625            ".kranz/tickets/some-ticket.md"
626        )));
627    }
628
629    /// The COMMITTED config is the boundary's standing policy: it must parse,
630    /// carry hash-form entries only (no readable vocabulary can hide in a
631    /// 64-hex set with a fixed key allow-list), and never contain the
632    /// synthetic test vocabulary — the one direction of contamination a test
633    /// can actually assert.
634    #[test]
635    fn domain_lint_committed_config_is_hash_form_only() {
636        let committed = concat!(
637            env!("CARGO_MANIFEST_DIR"),
638            "/../../.kranz/domain-denylist.json"
639        );
640        let text = std::fs::read_to_string(committed)
641            .unwrap_or_else(|err| panic!("read {committed}: {err}"));
642        let value: serde_json::Value =
643            serde_json::from_str(&text).expect("committed config parses");
644        let object = value.as_object().expect("object");
645        for key in object.keys() {
646            assert!(
647                matches!(key.as_str(), "version" | "salt" | "hash" | "terms"),
648                "unexpected key {key:?} — nowhere for readable terms to hide"
649            );
650        }
651        let denylist = load_denylist(&text).expect("committed config validates");
652        assert!(denylist.hashes.len() >= 3, "committed denylist is seeded");
653        let synthetic = salted_hash(&denylist.salt, "zz acme lint canary");
654        assert!(
655            !denylist.hashes.contains(&synthetic),
656            "synthetic test vocabulary must never seed the real config"
657        );
658    }
659}