use anyhow::{Context as _, Result};
use pushkin_core::waivers::WaiverSet;
use serde_json::Value;
use crate::agents::Agent;
use super::init::install_claude;
use super::{CLAUDE_SETTINGS, PUSHKIN_MARKER};
pub fn run_with_repair(repair: bool) -> Result<i32> {
if repair {
super::init::migrate_legacy_footprint()?;
}
println!("pushkin doctor: {}", super::daemon::health_line());
for finding in waiver_lint_findings() {
println!("pushkin doctor: {finding}");
}
let reports = collect_reports(host_os());
let git_plane_disabled = super::features::git_hooks_disabled().unwrap_or(false);
let (floor, shim) = if git_plane_disabled {
(check_lefthook_disabled(), check_git_shim_disabled())
} else {
(check_lefthook(), check_git_shim())
};
let resolvability = check_adapter_resolvability(&reports);
let manifest_skew = check_manifest_skew();
let nested_manifests = check_nested_manifests();
let resolution = check_manifest_resolution();
for (_, check) in &reports {
if let Some(info) = &check.info {
println!("pushkin doctor: {info}");
}
}
if let Some(info) = &floor.info {
println!("pushkin doctor: {info}");
}
if let Some(info) = &shim.info {
println!("pushkin doctor: {info}");
}
if let Some(info) = &manifest_skew.info {
println!("pushkin doctor: {info}");
}
if let Some(info) = &resolution.info {
println!("pushkin doctor: {info}");
}
let healthy = reports.iter().all(|(_, check)| check.findings.is_empty())
&& floor.findings.is_empty()
&& shim.findings.is_empty()
&& resolvability.findings.is_empty()
&& manifest_skew.findings.is_empty()
&& nested_manifests.findings.is_empty();
if healthy {
println!("pushkin doctor: hooks healthy.");
return Ok(0);
}
for (_, check) in &reports {
for finding in &check.findings {
println!("pushkin doctor: {finding}");
}
}
for finding in &floor.findings {
println!("pushkin doctor: {finding}");
}
for finding in &shim.findings {
println!("pushkin doctor: {finding}");
}
for finding in &resolvability.findings {
println!("pushkin doctor: {finding}");
}
for finding in &manifest_skew.findings {
println!("pushkin doctor: {finding}");
}
for finding in &nested_manifests.findings {
println!("pushkin doctor: {finding}");
}
if !repair {
println!("pushkin doctor: run `pushkin doctor --repair` to fix.");
return Ok(1);
}
if !manifest_skew.findings.is_empty() {
println!(
"pushkin doctor: not repairing — resolve the manifest schema skew above first \
(cargo install --path crates/pushkin-cli). Nothing was modified."
);
return Ok(1);
}
if reports.iter().any(|(_, check)| check.unreadable) || floor.unreadable || shim.unreadable {
println!(
"pushkin doctor: refusing to repair unreadable state — fix or delete \
the file named above yourself, then re-run. Nothing was modified."
);
return Ok(1);
}
if git_plane_disabled {
repair_git_plane_disabled(&floor, &shim)?;
} else {
repair_floor(&floor)?;
repair_git_shim(&shim)?;
}
repair_reports(&reports)
}
fn check_lefthook_disabled() -> AgentCheck {
let mut check = AgentCheck {
info: Some(
"lefthook: git hooks disabled ([features] git_hooks = false) — not checked".to_owned(),
),
..AgentCheck::default()
};
if let Ok(text) = std::fs::read_to_string(super::init::LEFTHOOK_FILE) {
if floor_generation(&text, super::init::LEFTHOOK_MARKER).is_some() {
check.findings.push(format!(
"lefthook: git hooks are disabled but the pushkin floor block is still \
installed in {}; repair removes it (as `pushkin disable git-hooks` \
would have)",
super::init::LEFTHOOK_FILE
));
}
}
check
}
fn check_git_shim_disabled() -> AgentCheck {
let mut check = AgentCheck {
info: Some(
"git shim: git hooks disabled ([features] git_hooks = false) — not checked".to_owned(),
),
..AgentCheck::default()
};
let Some(git_dir) = super::git::git_dir() else {
return check;
};
let target = git_dir.join("hooks").join("pre-commit");
if let Ok(text) = std::fs::read_to_string(&target) {
if super::init::is_pushkin_shim(&text) {
check.findings.push(
"git shim: git hooks are disabled but pushkin's pre-commit shim is still \
installed; repair removes it (as `pushkin disable git-hooks` would have)"
.to_owned(),
);
}
}
check
}
fn repair_git_plane_disabled(floor: &AgentCheck, shim: &AgentCheck) -> Result<()> {
if !floor.findings.is_empty() {
super::init::remove_lefthook()?;
println!("pushkin doctor: repaired — lefthook floor removed (git hooks disabled).");
}
if !shim.findings.is_empty() {
super::init::remove_git_shim()?;
println!("pushkin doctor: repaired — native git shim removed (git hooks disabled).");
}
Ok(())
}
fn check_git_shim() -> AgentCheck {
let mut check = AgentCheck::default();
let Some(git_dir) = super::git::git_dir() else {
check.info = Some("git shim: not a git repository — not checked".to_owned());
return check;
};
let target = git_dir.join("hooks").join("pre-commit");
let text = match std::fs::read_to_string(&target) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
check.info = Some("git shim: not installed — not checked".to_owned());
return check;
}
Err(error) => {
check.unreadable = true;
check.findings.push(format!(
"git shim: cannot read {}: {error}",
target.display()
));
return check;
}
Ok(text) => text,
};
if !super::init::is_pushkin_shim(&text) {
check.info = Some("git shim: present but not pushkin's — not checked".to_owned());
return check;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(&target) {
if meta.permissions().mode() & 0o111 == 0 {
check.findings.push(
"git shim: the pre-commit hook is not executable, so git never runs \
it; repair rewrites the shim with the executable bit restored"
.to_owned(),
);
}
}
}
if !pushkin_on_path() {
check.findings.push(
"git shim: the hook runs `pushkin` from PATH but no `pushkin` is resolvable \
there — the pre-commit gate will fail open until the binary is installed \
(`cargo install --path crates/pushkin-cli`)"
.to_owned(),
);
check.path_advisory = true;
}
check
}
fn repair_git_shim(shim: &AgentCheck) -> Result<()> {
let repairable = shim.findings.len() > usize::from(shim.path_advisory);
if !repairable {
return Ok(());
}
super::init::install_git_shim()?;
println!("pushkin doctor: repaired — native git shim regenerated.");
Ok(())
}
fn repair_floor(floor: &AgentCheck) -> Result<()> {
let repairable = floor.findings.len() > usize::from(floor.path_advisory);
if !repairable {
if floor.path_advisory {
println!(
"pushkin doctor: the lefthook floor file is current; the PATH \
finding is advisory — install pushkin (`cargo install --path \
crates/pushkin-cli`). Nothing was rewritten."
);
}
return Ok(());
}
let path = std::path::Path::new(super::init::LEFTHOOK_FILE);
if !path.exists() {
return Ok(());
}
let text = std::fs::read_to_string(path).context("cannot read lefthook.yml")?;
if floor_generation(&text, super::init::LEFTHOOK_MARKER).is_some() {
super::init::install_lefthook()?;
println!("pushkin doctor: repaired — lefthook floor regenerated.");
} else {
let scrubbed = super::init::without_pushkin_comment_lines(&text, true);
std::fs::write(path, scrubbed).context("cannot write lefthook.yml")?;
println!(
"pushkin doctor: repaired — orphaned pushkin marker comments removed; \
the file carries no pushkin block, so nothing was installed."
);
}
Ok(())
}
fn check_adapter_resolvability(reports: &[(Agent, AgentCheck)]) -> AgentCheck {
let mut check = AgentCheck::default();
let any_installed = reports.iter().any(|(_, report)| report.info.is_none());
if any_installed && !pushkin_on_path() {
check.findings.push(unresolvable_binary_finding());
}
check
}
#[must_use]
pub fn sweep_findings() -> Vec<String> {
let reports = collect_reports(host_os());
let resolvability = check_adapter_resolvability(&reports);
reports
.into_iter()
.flat_map(|(_, check)| check.findings)
.chain(check_lefthook().findings)
.chain(check_git_shim().findings)
.chain(resolvability.findings)
.collect()
}
fn waiver_lint_findings() -> Vec<String> {
match WaiverSet::load(std::path::Path::new(super::waive::WAIVERS_FILE)) {
Ok(set) => set.lint_now(),
Err(error) => vec![format!(
"waivers file rejected ({error}) — no waivers are being honored \
until it parses"
)],
}
}
#[derive(Default)]
struct AgentCheck {
findings: Vec<String>,
info: Option<String>,
unreadable: bool,
path_advisory: bool,
}
fn collect_reports(os: HostOs) -> Vec<(Agent, AgentCheck)> {
Agent::ALL
.into_iter()
.map(|agent| {
let check = match agent {
Agent::Claude => check_claude(),
Agent::Codex => check_codex(os),
Agent::Auggie => check_auggie(),
Agent::Hermes => check_hermes(),
Agent::Opencode => check_opencode(),
};
(agent, check)
})
.collect()
}
fn repair_reports(reports: &[(Agent, AgentCheck)]) -> Result<i32> {
for (agent, check) in reports {
if check.findings.is_empty() {
continue;
}
match agent {
Agent::Claude => {
install_claude()?;
println!("pushkin doctor: repaired — pushkin entries reinstalled.");
}
Agent::Codex | Agent::Auggie | Agent::Hermes | Agent::Opencode => {
println!(
"pushkin doctor: repair not implemented for {name} in Phase 2 — \
rerun `pushkin init --agent {name}`.",
name = agent.as_str()
);
}
}
}
Ok(0)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HostOs {
Windows,
Other,
}
impl HostOs {
fn parse(name: &str) -> Self {
match name {
"windows" => HostOs::Windows,
_ => HostOs::Other,
}
}
}
fn host_os() -> HostOs {
let name =
std::env::var("PUSHKIN_DOCTOR_OS").unwrap_or_else(|_| std::env::consts::OS.to_owned());
HostOs::parse(&name)
}
fn check_claude() -> AgentCheck {
let mut check = AgentCheck::default();
match std::fs::read_to_string(CLAUDE_SETTINGS) {
Err(_) => check
.findings
.push(format!("{CLAUDE_SETTINGS} missing — no hook installed")),
Ok(text) => match serde_json::from_str::<Value>(&text) {
Err(error) => {
check.unreadable = true;
check
.findings
.push(format!("{CLAUDE_SETTINGS} is not valid JSON: {error}"));
}
Ok(settings) => {
for event in ["PreToolUse", "Stop"] {
match pushkin_entry(&settings, event) {
None => check
.findings
.push(claude_missing_finding(&settings, event)),
Some(entry) => {
if !command_is_current(entry) {
let detail = if entry_has_absolute_command(entry) {
" — it embeds an absolute binary path, which goes \
stale when the binary moves"
} else {
""
};
check.findings.push(format!(
"{event} hook command is stale (does not invoke \
`hook claude` via the portable `pushkin` command){detail}"
));
}
}
}
}
}
},
}
check
}
fn claude_missing_finding(settings: &Value, event: &str) -> String {
if legacy_entry(settings, event).is_some() {
format!(
"{event} hook entry carries a legacy pushkin marker — \
repair migrates it to {PUSHKIN_MARKER}"
)
} else {
format!("{event} hook entry missing or not pushkin's")
}
}
fn check_codex(os: HostOs) -> AgentCheck {
let mut check = AgentCheck::default();
let rules_path = ".codex/rules/pushkin.rules";
let rules_present = std::path::Path::new(rules_path).exists();
let entry = marked_entry_state(".codex/hooks.json", "hook codex");
if !rules_present && matches!(entry, EntryState::FileAbsent | EntryState::Missing) {
check.info = Some("codex: not installed — not checked".to_owned());
return check;
}
match entry {
EntryState::Unreadable(ref finding) => {
check.unreadable = true;
check.findings.push(finding.clone());
}
EntryState::FileAbsent | EntryState::Missing => check.findings.push(
"codex: PreToolUse hook entry missing or not pushkin's (.codex/hooks.json)".to_owned(),
),
EntryState::Stale => check.findings.push(
"codex: PreToolUse hook command is stale (does not invoke `hook codex` \
on this binary)"
.to_owned(),
),
EntryState::Current => {}
}
if !rules_present {
check.findings.push(format!(
"codex: {rules_path} missing — execpolicy floor absent"
));
}
if os == HostOs::Windows {
check.findings.push(
"codex: hooks are unavailable on Windows (openai/codex#17478) — the \
execpolicy rules floor still applies; the PreToolUse gate will not fire"
.to_owned(),
);
}
if matches!(entry, EntryState::Current) {
check.info = Some(
"codex: hook entry is current, but codex requires a persisted HOOK TRUST \
grant. pushkin cannot verify that grant from here — until it is given, the \
hook does not run and this repo is UNGATED for codex (F56). Grant it by \
running codex once interactively and approving, or pass \
--dangerously-bypass-hook-trust for vetted automation. Project trust \
(`trust_level`) is a different setting and does not cover this."
.to_owned(),
);
}
check
}
fn check_auggie() -> AgentCheck {
let mut check = AgentCheck::default();
let script_path = ".augment/hooks/pushkin.sh";
let script_present = std::path::Path::new(script_path).exists();
let entry = marked_entry_state(".augment/settings.json", "pushkin.sh");
if !script_present && matches!(entry, EntryState::FileAbsent | EntryState::Missing) {
check.info = Some("auggie: not installed — not checked".to_owned());
return check;
}
match entry {
EntryState::Unreadable(finding) => {
check.unreadable = true;
check.findings.push(finding);
}
EntryState::FileAbsent | EntryState::Missing => check.findings.push(
"auggie: PreToolUse hook entry missing or not pushkin's (.augment/settings.json)"
.to_owned(),
),
EntryState::Stale => check
.findings
.push("auggie: hook command does not invoke the pushkin.sh wrapper — stale".to_owned()),
EntryState::Current => {}
}
if !script_present {
check.findings.push(format!(
"auggie: {script_path} missing — hook script absent"
));
} else if let Ok(script) = std::fs::read_to_string(script_path) {
if script.lines().any(|line| {
line.starts_with("exec ") && command_has_absolute_path(&line["exec ".len()..])
}) {
check.findings.push(format!(
"auggie: {script_path} is stale — it execs an absolute binary \
path, which breaks when the binary moves; repair regenerates it"
));
}
}
check
}
fn check_hermes() -> AgentCheck {
let mut check = AgentCheck::default();
let hermes_home = std::env::var("HERMES_HOME")
.unwrap_or_else(|_| format!("{}/.hermes", std::env::var("HOME").unwrap_or_default()));
let plugin_dir = format!("{hermes_home}/plugins/pushkin-gate");
if !std::path::Path::new(&plugin_dir).exists() {
check.info = Some("hermes: not installed — not checked".to_owned());
return check;
}
for file in ["plugin.yaml", "__init__.py"] {
if !std::path::Path::new(&format!("{plugin_dir}/{file}")).exists() {
check
.findings
.push(format!("hermes: {file} missing from {plugin_dir}"));
}
}
check
}
fn check_opencode() -> AgentCheck {
let mut check = AgentCheck::default();
let path = ".opencode/plugin/pushkin.ts";
match std::fs::read_to_string(path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
check.info = Some("opencode: not installed — not checked".to_owned());
}
Err(error) => {
check.unreadable = true;
check
.findings
.push(format!("opencode: cannot read {path}: {error}"));
}
Ok(text) => {
if !text.contains(PUSHKIN_MARKER) || !text.contains("hook opencode") {
check.findings.push(format!(
"opencode: {path} is not pushkin's current plugin (marker or \
hook relay missing) — hand-edited or stale"
));
}
}
}
check
}
#[derive(Clone, Copy)]
enum FloorGeneration {
V1,
V2,
Current,
}
impl FloorGeneration {
fn stale_finding(self, file: &str) -> Option<String> {
match self {
Self::V1 => Some(format!(
"lefthook: {file} carries a stale v1 pushkin floor (an absolute binary path \
and/or a whole-repo Stop sweep); repair regenerates it and preserves \
other commands"
)),
Self::V2 => Some(format!(
"lefthook: {file} carries a stale v2 pushkin floor — the command is clean, \
but it has no fail-open guard, so it blocks teammates who have not \
installed pushkin; repair regenerates it and preserves other commands"
)),
Self::Current => None,
}
}
}
fn floor_generation(text: &str, marker: &str) -> Option<FloorGeneration> {
if text.contains(marker) {
return Some(FloorGeneration::Current);
}
if (text.contains("pushkin:begin pushkin-v2") || text.contains("marker: pushkin-v2"))
&& has_pushkin_entry(text)
{
return Some(FloorGeneration::V2);
}
has_pushkin_entry(text).then_some(FloorGeneration::V1)
}
fn has_pushkin_entry(text: &str) -> bool {
let mut lines = text.lines().skip_while(|line| line.trim() != "pushkin:");
if lines.next().is_none() {
return false;
}
lines
.take_while(|line| line.starts_with(' ') || line.trim().is_empty())
.any(|line| {
let trimmed = line.trim();
trimmed.starts_with("run:") && trimmed.contains("pushkin")
})
}
fn check_lefthook() -> AgentCheck {
use super::init::{LEFTHOOK_FILE, LEFTHOOK_MARKER};
let mut check = AgentCheck::default();
let text = match std::fs::read_to_string(LEFTHOOK_FILE) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
check.info = Some("lefthook: not installed — not checked".to_owned());
return check;
}
Err(error) => {
check.unreadable = true;
check
.findings
.push(format!("lefthook: cannot read {LEFTHOOK_FILE}: {error}"));
return check;
}
Ok(text) => text,
};
let Some(generation) = floor_generation(&text, LEFTHOOK_MARKER) else {
check.info = Some("lefthook: present but no pushkin block — not checked".to_owned());
if super::init::has_stale_pushkin_comments(&text) {
check.findings.push(format!(
"lefthook: {LEFTHOOK_FILE} carries orphaned pushkin marker comments but \
no pushkin block; repair removes the orphans, preserves other \
commands, and installs nothing"
));
}
return check;
};
if let Some(finding) = generation.stale_finding(LEFTHOOK_FILE) {
check.findings.push(finding);
} else if super::init::has_stale_pushkin_comments(&text) {
check.findings.push(format!(
"lefthook: {LEFTHOOK_FILE} carries orphaned pushkin marker comments outside \
the current block (litter from an upgrade by a pre-fix binary); repair \
removes them and preserves other commands"
));
}
if !pushkin_on_path() {
check.findings.push(
"lefthook: the floor runs `pushkin` from PATH but no `pushkin` is resolvable \
there — the pre-commit gate will fail to launch. Install it on PATH \
(`cargo install --path crates/pushkin-cli`) or add the binary's directory \
to PATH"
.to_owned(),
);
check.path_advisory = true;
}
check
}
fn pushkin_on_path() -> bool {
let Ok(path) = std::env::var("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| {
let candidate = dir.join("pushkin");
candidate.is_file() || candidate.with_extension("exe").is_file()
})
}
fn command_has_absolute_path(command: &str) -> bool {
let absolute = command.starts_with('/') || command.starts_with('\\') || command.contains(":\\");
absolute && !command.contains("pushkin.sh")
}
fn unresolvable_binary_finding() -> String {
"adapter hooks are installed but no `pushkin` is resolvable on PATH — \
the gate will fail to launch and writes will go ungated. Install it on \
PATH (`cargo install --path crates/pushkin-cli`) or add the binary's \
directory to PATH"
.to_owned()
}
enum EntryState {
FileAbsent,
Unreadable(String),
Missing,
Stale,
Current,
}
fn marked_entry_state(path: &str, needle: &str) -> EntryState {
match std::fs::read_to_string(path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => EntryState::FileAbsent,
Err(error) => EntryState::Unreadable(format!("cannot read {path}: {error}")),
Ok(text) => match serde_json::from_str::<Value>(&text) {
Err(error) => EntryState::Unreadable(format!("{path} is not valid JSON: {error}")),
Ok(settings) => match pushkin_entry(&settings, "PreToolUse") {
None => EntryState::Missing,
Some(entry) if entry_has_absolute_command(entry) => EntryState::Stale,
Some(entry) if entry_invokes(entry, needle) => EntryState::Current,
Some(_) => EntryState::Stale,
},
},
}
}
fn pushkin_entry<'a>(settings: &'a Value, event: &str) -> Option<&'a Value> {
settings
.get("hooks")
.and_then(|hooks| hooks.get(event))
.and_then(Value::as_array)
.and_then(|entries| {
entries
.iter()
.find(|entry| entry.get("_pushkin").and_then(Value::as_str) == Some(PUSHKIN_MARKER))
})
}
fn legacy_entry<'a>(settings: &'a Value, event: &str) -> Option<&'a Value> {
settings
.get("hooks")
.and_then(|hooks| hooks.get(event))
.and_then(Value::as_array)
.and_then(|entries| entries.iter().find(|entry| marker_is_legacy(entry)))
}
fn marker_is_legacy(entry: &Value) -> bool {
entry
.get("_pushkin")
.is_some_and(|marker| marker.as_str() != Some(PUSHKIN_MARKER))
|| entry.get(pushkin_core::legacy::MARKER_KEY).is_some()
}
fn command_is_current(entry: &Value) -> bool {
entry_invokes(entry, "hook claude") && !entry_has_absolute_command(entry)
}
fn entry_has_absolute_command(entry: &Value) -> bool {
entry
.get("hooks")
.and_then(Value::as_array)
.is_some_and(|hooks| {
hooks.iter().any(|hook| {
hook.get("command")
.and_then(Value::as_str)
.is_some_and(command_has_absolute_path)
})
})
}
fn entry_invokes(entry: &Value, needle: &str) -> bool {
entry
.get("hooks")
.and_then(Value::as_array)
.is_some_and(|hooks| {
hooks.iter().all(|hook| {
hook.get("command")
.and_then(Value::as_str)
.is_some_and(|command| command.contains(needle))
})
})
}
fn check_manifest_skew() -> AgentCheck {
let path = super::MANIFEST_FILE;
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return AgentCheck {
findings: Vec::new(),
info: Some(format!("{path}: not present — not checked")),
unreadable: false,
path_advisory: false,
};
}
Err(error) => {
return AgentCheck {
findings: vec![format!(
"{path} could not be read ({error}) — this binary cannot tell whether the \
manifest matches its schema, so the write-time gate's state is unknown. \
Fix the file's permissions and re-run; `doctor --repair` will not touch it."
)],
info: None,
unreadable: true,
path_advisory: false,
};
}
};
match pushkin_core::manifest::Manifest::parse(&text) {
Ok(_) => AgentCheck {
findings: Vec::new(),
info: None,
unreadable: false,
path_advisory: false,
},
Err(error) => AgentCheck {
findings: vec![format!(
"manifest schema skew: this binary cannot parse {path} — {error}\n \
The write-time gate is NOT running while this is true: every manifest-reading \
verb exits 1 before it reads a payload, and a hook exiting 1 is non-blocking, \
so writes proceed ungated.\n \
Remedy: cargo install --path crates/pushkin-cli (a binary older than the \
manifest schema is the usual cause). `doctor --repair` does not own this."
)],
info: None,
unreadable: false,
path_advisory: false,
},
}
}
fn check_manifest_resolution() -> AgentCheck {
let clean = AgentCheck {
findings: Vec::new(),
info: None,
unreadable: false,
path_advisory: false,
};
let Ok(resolved) = super::resolve_manifest() else {
return clean;
};
let super::ManifestSource::WorkingDirectory {
git_unavailable: Some(reason),
} = resolved.source
else {
return clean;
};
AgentCheck {
findings: Vec::new(),
info: Some(format!(
"manifest resolution: not pinned to a repository root — {reason} Falling back to \
the working directory, so running pushkin from a subdirectory can pick up a \
different manifest, or none (F73 is fixed where git is available). Put git on \
PATH, or set PUSHKIN_MANIFEST to the manifest you mean."
)),
unreadable: false,
path_advisory: false,
}
}
fn check_nested_manifests() -> AgentCheck {
let clean = AgentCheck {
findings: Vec::new(),
info: None,
unreadable: false,
path_advisory: false,
};
if !std::path::Path::new(super::MANIFEST_FILE).exists() {
return clean;
}
let mut nested = Vec::new();
collect_nested_manifests(std::path::Path::new("."), &mut nested);
if nested.is_empty() {
return clean;
}
nested.sort();
AgentCheck {
findings: nested
.into_iter()
.map(|path| {
format!(
"nested pushkin.toml at {path} — it governs verdicts for any pushkin \
invocation made from its directory today, because the manifest is resolved \
from the current directory. That is F73, and it is being fixed: after the \
resolution change it will NOT govern.\n \
If that is deliberate, set PUSHKIN_MANIFEST to point at it explicitly, or \
give it its own repo. `doctor --repair` will not touch it — it is your file."
)
})
.collect(),
info: None,
unreadable: false,
path_advisory: false,
}
}
const NESTED_SCAN_SKIP: &[&str] = &["target", "node_modules", ".git"];
fn collect_nested_manifests(root: &std::path::Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if path.is_dir() {
if NESTED_SCAN_SKIP.contains(&name.as_ref()) {
continue;
}
collect_nested_manifests(&path, out);
} else if name == super::MANIFEST_FILE {
let display = path
.strip_prefix("./")
.unwrap_or(&path)
.display()
.to_string();
if display != super::MANIFEST_FILE {
out.push(display);
}
}
}
}
#[cfg(test)]
mod tests {
use super::{marker_is_legacy, HostOs};
use serde_json::json;
#[test]
fn host_os_parses_the_consts_os_domain() {
assert_eq!(HostOs::parse("windows"), HostOs::Windows);
assert_eq!(HostOs::parse("macos"), HostOs::Other);
assert_eq!(HostOs::parse("linux"), HostOs::Other);
assert_eq!(HostOs::parse("Windows"), HostOs::Other);
}
#[test]
fn marker_is_legacy_only_for_foreign_pushkin_versions() {
assert!(marker_is_legacy(&json!({ "_pushkin": "pushkin-v0" })));
assert!(!marker_is_legacy(&json!({ "_pushkin": "pushkin-v1" })));
assert!(!marker_is_legacy(&json!({ "matcher": "Bash" })));
}
}