use serde::Serialize;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use walkdir::WalkDir;
#[derive(Clone, Debug, Serialize)]
pub struct DiscoveryReport {
pub project_root: String,
pub identity: ProjectIdentity,
pub paths: DiscoveryPaths,
pub commands: DiscoveryCommands,
pub domain_language: DomainLanguageReport,
pub code_intelligence: Vec<CodeIntelligenceStatus>,
pub capabilities: Vec<CapabilityRecommendation>,
pub risks: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ProjectIdentity {
pub lifecycle: String,
pub stack: Vec<String>,
pub project_type: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct DiscoveryPaths {
pub source: Vec<String>,
pub tests: Vec<String>,
pub docs: Vec<String>,
pub migrations: Vec<String>,
pub infra: Vec<String>,
pub config: Vec<String>,
pub artifacts: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct DiscoveryCommands {
pub install: Vec<String>,
pub lint: Vec<String>,
pub typecheck: Vec<String>,
pub test: Vec<String>,
pub build: Vec<String>,
pub not_verified: Vec<String>,
pub missing: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct DomainLanguageReport {
pub context_files: Vec<String>,
pub context_map: Option<String>,
pub adr_dirs: Vec<String>,
pub recommendation: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct CodeIntelligenceStatus {
pub id: String,
pub available: bool,
pub indexed: bool,
pub command: String,
pub index_marker: String,
pub suggested_commands: Vec<String>,
pub fallback: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct CapabilityRecommendation {
pub id: String,
pub title: String,
pub source: String,
pub source_url: String,
pub trigger: String,
pub stages: Vec<String>,
pub local_skill: String,
pub mode: String,
pub risk: String,
pub fallback: String,
}
pub fn discover_project(root: &Path) -> DiscoveryReport {
let root = crate::runtime::platform::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let stack = detect_stack(&root);
DiscoveryReport {
project_root: root.display().to_string(),
identity: ProjectIdentity {
lifecycle: if root.join(".git").is_dir() || root.join("sdd.config.yaml").exists() {
"brownfield".to_string()
} else {
"greenfield".to_string()
},
project_type: detect_project_type(&root, &stack),
stack,
},
paths: detect_paths(&root),
commands: detect_commands(&root),
domain_language: detect_domain_language(&root),
code_intelligence: detect_code_intelligence(&root),
capabilities: capability_recommendations(&root),
risks: detect_risks(&root),
}
}
fn detect_stack(root: &Path) -> Vec<String> {
let mut stack = Vec::new();
let package = read_optional(root.join("package.json")).to_lowercase();
let pyproject = read_optional(root.join("pyproject.toml")).to_lowercase();
let requirements = read_optional(root.join("requirements.txt")).to_lowercase();
let cargo = read_optional(root.join("Cargo.toml")).to_lowercase();
let go_mod = read_optional(root.join("go.mod")).to_lowercase();
if root.join("Cargo.toml").exists() {
stack.push("rust".to_string());
}
if root.join("package.json").exists() {
stack.push("node".to_string());
}
if contains_any(
&package,
&["react", "next", "vite", "svelte", "vue", "angular"],
) {
stack.push("frontend".to_string());
}
if contains_any(&package, &["react-native", "expo"]) {
stack.push("mobile-react-native".to_string());
}
if contains_any(
&package,
&["express", "fastify", "hono", "nestjs", "koa", "trpc"],
) {
stack.push("node-api".to_string());
}
if root.join("pyproject.toml").exists()
|| root.join("requirements.txt").exists()
|| root.join("uv.lock").exists()
{
stack.push("python".to_string());
}
if contains_any(
&format!("{pyproject}\n{requirements}"),
&["fastapi", "django", "flask"],
) {
stack.push("python-api".to_string());
}
if root.join("go.mod").exists() {
stack.push("go".to_string());
}
if contains_any(&go_mod, &["gin-gonic/gin", "gofiber/fiber", "grpc"]) {
stack.push("go-api".to_string());
}
if contains_any(&cargo, &["axum", "actix-web", "rocket", "tonic", "warp"]) {
stack.push("rust-api".to_string());
}
if is_monorepo(root) {
stack.push("monorepo".to_string());
}
if is_infra(root) {
stack.push("infra".to_string());
}
if stack.is_empty() {
stack.push("generic".to_string());
}
stack.sort();
stack.dedup();
stack
}
fn detect_project_type(root: &Path, stack: &[String]) -> String {
if stack.iter().any(|item| item.contains("api")) {
"api/service".to_string()
} else if stack.iter().any(|item| item == "frontend") {
"frontend".to_string()
} else if root.join("src/main.rs").exists() || root.join("src/bin").is_dir() {
"cli/app".to_string()
} else if stack.iter().any(|item| item == "infra") {
"infra".to_string()
} else {
"project".to_string()
}
}
fn detect_paths(root: &Path) -> DiscoveryPaths {
DiscoveryPaths {
source: existing_paths(
root,
&[
"src", "app", "apps", "packages", "lib", "crates", "cmd", "internal", "plugins",
],
),
tests: existing_paths(root, &["tests", "test", "__tests__", "e2e", "spec"]),
docs: existing_paths(root, &["docs", "README.md", "CONTEXT.md", "CONTEXT-MAP.md"]),
migrations: existing_paths(
root,
&["migrations", "db/migrate", "prisma/migrations", "alembic"],
),
infra: existing_paths(
root,
&[
"infra",
"terraform",
"k8s",
".github/workflows",
".gitlab-ci.yml",
"Dockerfile",
],
),
config: existing_paths(
root,
&[
"sdd.config.yaml",
"package.json",
"Cargo.toml",
"pyproject.toml",
"go.mod",
".env.example",
".env.sample",
],
),
artifacts: existing_paths(root, &["docs", ".sdd/intelligence", ".sdd/memory"]),
}
}
fn detect_commands(root: &Path) -> DiscoveryCommands {
let package = read_optional(root.join("package.json"));
let cargo = root.join("Cargo.toml").exists();
let pyproject = read_optional(root.join("pyproject.toml"));
let requirements = root.join("requirements.txt").exists();
let mut commands = DiscoveryCommands {
install: Vec::new(),
lint: Vec::new(),
typecheck: Vec::new(),
test: Vec::new(),
build: Vec::new(),
not_verified: Vec::new(),
missing: Vec::new(),
};
if !package.is_empty() {
let runner = node_runner(root);
push_if_script(&mut commands.lint, &package, &runner, "lint");
push_if_script(&mut commands.typecheck, &package, &runner, "typecheck");
push_if_script(&mut commands.test, &package, &runner, "test");
push_if_script(&mut commands.build, &package, &runner, "build");
commands.install.push(match runner.as_str() {
"pnpm" => "pnpm install".to_string(),
"yarn" => "yarn install".to_string(),
"bun" => "bun install".to_string(),
_ => "npm install".to_string(),
});
}
if cargo {
commands
.lint
.push("cargo clippy --all-targets --all-features -- -D warnings".to_string());
commands.typecheck.push("cargo check".to_string());
commands.test.push("cargo test".to_string());
commands.build.push("cargo build --release".to_string());
}
if !pyproject.is_empty() || requirements {
if pyproject.contains("ruff") || root.join("ruff.toml").exists() {
commands.lint.push("ruff check .".to_string());
}
if pyproject.contains("mypy") || root.join("mypy.ini").exists() {
commands.typecheck.push("mypy .".to_string());
}
commands.test.push("pytest".to_string());
if requirements {
commands
.install
.push("python -m pip install -r requirements.txt".to_string());
}
}
if root.join("go.mod").exists() {
commands.typecheck.push("go test ./...".to_string());
commands.test.push("go test ./...".to_string());
commands.build.push("go build ./...".to_string());
}
for (label, items) in [
("lint", &commands.lint),
("typecheck", &commands.typecheck),
("test", &commands.test),
("build", &commands.build),
] {
if items.is_empty() {
commands.missing.push(label.to_string());
} else {
commands.not_verified.extend(
items
.iter()
.map(|item| format!("{item} (derivado, não executado)")),
);
}
}
commands
}
fn detect_domain_language(root: &Path) -> DomainLanguageReport {
let context_files = collect_named_files(root, &["CONTEXT.md"]);
let context_map = if root.join("CONTEXT-MAP.md").exists() {
Some("CONTEXT-MAP.md".to_string())
} else {
None
};
let adr_dirs = collect_adr_dirs(root);
let recommendation = if context_files.is_empty() && adr_dirs.is_empty() {
"Criar glossário/ADRs apenas quando decisões ou termos de domínio forem resolvidos."
.to_string()
} else {
"Usar linguagem canônica e ADRs existentes antes de PRD, Tech Spec, Execution e Review."
.to_string()
};
DomainLanguageReport {
context_files,
context_map,
adr_dirs,
recommendation,
}
}
fn detect_code_intelligence(root: &Path) -> Vec<CodeIntelligenceStatus> {
let codegraph_indexed = root.join(".codegraph").is_dir();
let mut codegraph_commands = Vec::new();
if !codegraph_indexed {
codegraph_commands.push("codegraph init -i .".to_string());
} else {
codegraph_commands.push("codegraph sync .".to_string());
}
codegraph_commands.extend([
"codegraph status .".to_string(),
"codegraph files --path . --json".to_string(),
"codegraph query \"<symbol>\" --path . --json".to_string(),
"codegraph context \"<task>\" --path . --format markdown".to_string(),
"git diff --name-only | codegraph affected --path . --stdin --quiet".to_string(),
]);
vec![
CodeIntelligenceStatus {
id: "codegraph".to_string(),
available: command_available("codegraph"),
indexed: codegraph_indexed,
command: "codegraph".to_string(),
index_marker: ".codegraph/".to_string(),
suggested_commands: codegraph_commands,
fallback:
"Use `rg --files`, `rg`, leitura focada e testes afetados derivados dos manifests."
.to_string(),
},
CodeIntelligenceStatus {
id: "lexa".to_string(),
available: command_available("lexa"),
indexed: root.join(".lexa").is_dir(),
command: "lexa".to_string(),
index_marker: ".lexa/graph.lexa".to_string(),
suggested_commands: vec![
"lexa status".to_string(),
"lexa files".to_string(),
"lexa brief \"<task>\"".to_string(),
"lexa audit".to_string(),
],
fallback: "Use `rg --files`, `rg`, outlines manuais por arquivo e Context Pack SDD."
.to_string(),
},
]
}
pub fn capability_recommendations(root: &Path) -> Vec<CapabilityRecommendation> {
let mut items = vec![
CapabilityRecommendation {
id: "code-intelligence".to_string(),
title: "Code intelligence opcional".to_string(),
source: "SDD + CodeGraph + Lexa".to_string(),
source_url:
"https://github.com/colbymchenry/codegraph, https://github.com/anvia-hq/lexa"
.to_string(),
trigger: "Discovery, Tech Spec, Execution ou Review em código brownfield."
.to_string(),
stages: vec!["project-discovery", "techspec", "execution", "review"]
.into_iter()
.map(str::to_string)
.collect(),
local_skill: ".agents/skills/code-intelligence/SKILL.md".to_string(),
mode: "optional".to_string(),
risk: "Índice pode estar ausente ou obsoleto; verificar status antes de confiar."
.to_string(),
fallback: "`rg --files`, `rg`, leitura focada e comandos reais do projeto.".to_string(),
},
CapabilityRecommendation {
id: "execution-discipline".to_string(),
title: "Execução disciplinada".to_string(),
source: "Superpowers + Matt Pocock Skills".to_string(),
source_url: "https://github.com/obra/superpowers/tree/main/skills, https://github.com/mattpocock/skills".to_string(),
trigger: "Qualquer task de implementação, bugfix ou performance.".to_string(),
stages: vec!["execution", "review"].into_iter().map(str::to_string).collect(),
local_skill: ".agents/skills/execution-discipline/SKILL.md".to_string(),
mode: "adapted".to_string(),
risk: "Pode virar ritual sem evidência; exigir teste/loop/verificação específicos.".to_string(),
fallback: "Aplicar checklist local de teste, diagnóstico e validação fresca.".to_string(),
},
CapabilityRecommendation {
id: "domain-language".to_string(),
title: "Linguagem de domínio e ADRs leves".to_string(),
source: "Matt Pocock Skills".to_string(),
source_url: "https://github.com/mattpocock/skills".to_string(),
trigger: "Termos ambíguos, domínio rico, PRD/Tech Spec com decisões difíceis.".to_string(),
stages: vec!["idea", "prd", "techspec", "execution", "memory"]
.into_iter()
.map(str::to_string)
.collect(),
local_skill: ".agents/skills/domain-language/SKILL.md".to_string(),
mode: "adapted".to_string(),
risk: "Não transformar glossário em spec nem criar ADR para decisão trivial.".to_string(),
fallback: "Registrar termos no artifact SDD e apontar para ADRs existentes.".to_string(),
},
CapabilityRecommendation {
id: "architecture-deepening".to_string(),
title: "Deepening arquitetural".to_string(),
source: "Matt Pocock Skills".to_string(),
source_url: "https://github.com/mattpocock/skills".to_string(),
trigger: "Acoplamento, módulos rasos, baixa testabilidade ou refactor grande.".to_string(),
stages: vec!["project-discovery", "techspec", "refinement", "review"]
.into_iter()
.map(str::to_string)
.collect(),
local_skill: ".agents/skills/architecture-deepening/SKILL.md".to_string(),
mode: "adapted".to_string(),
risk: "Não refatorar automaticamente; propor candidatos e exigir checkpoint.".to_string(),
fallback: "Documentar fricção e converter em task/ADR futura.".to_string(),
},
];
let package = read_optional(root.join("package.json")).to_lowercase();
let has_frontend = contains_any(
&package,
&[
"react",
"next",
"vite",
"svelte",
"vue",
"astro",
"tailwind",
"storybook",
],
) || root.join("src/components").is_dir();
if has_frontend {
items.push(CapabilityRecommendation {
id: "taste-ui".to_string(),
title: "Qualidade visual contextual".to_string(),
source: "Taste Skill".to_string(),
source_url: "https://github.com/Leonxlnx/taste-skill".to_string(),
trigger: "Landing page, portfolio, redesign ou UI visual detectada.".to_string(),
stages: vec!["prd", "techspec", "execution", "review"]
.into_iter()
.map(str::to_string)
.collect(),
local_skill: ".agents/skills/frontend-design/SKILL.md".to_string(),
mode: "adapted".to_string(),
risk: "Não aplicar estética de marketing em dashboard operacional.".to_string(),
fallback: "Usar design-flow/frontend-design/visual-review do SDD.".to_string(),
});
}
items
}
fn detect_risks(root: &Path) -> Vec<String> {
let mut risks = Vec::new();
if is_monorepo(root) {
risks.push(
"monorepo: roteamento de contexto e testes afetados precisam ser explícitos"
.to_string(),
);
}
if is_infra(root) {
risks.push("infra/deploy: exigir checkpoint antes de mudanças operacionais".to_string());
}
if !collect_adr_dirs(root).is_empty() {
risks.push("arquitetura documentada: respeitar ADRs antes de propor mudanças".to_string());
}
if !existing_paths(
root,
&["migrations", "db/migrate", "prisma/migrations", "alembic"],
)
.is_empty()
{
risks.push("dados/migrações: exigir data-contracts e plano de reversão".to_string());
}
if risks.is_empty() {
risks.push(
"sem riscos críticos detectados no inventário estático; confirmar no PRD/Tech Spec"
.to_string(),
);
}
risks
}
fn existing_paths(root: &Path, candidates: &[&str]) -> Vec<String> {
candidates
.iter()
.filter(|candidate| root.join(candidate).exists())
.map(|candidate| (*candidate).to_string())
.collect()
}
fn collect_named_files(root: &Path, names: &[&str]) -> Vec<String> {
let mut out = Vec::new();
for entry in WalkDir::new(root)
.max_depth(4)
.into_iter()
.filter_entry(|entry| !is_ignored_entry(entry.file_name().to_string_lossy().as_ref()))
.flatten()
{
if entry.file_type().is_file() {
let name = entry.file_name().to_string_lossy();
if names.iter().any(|candidate| name == *candidate) {
out.push(relative_path(root, entry.path()));
}
}
}
out.sort();
out
}
fn collect_adr_dirs(root: &Path) -> Vec<String> {
let mut out = Vec::new();
for candidate in ["docs/adr", "docs/adrs", "adr", "adrs"] {
if root.join(candidate).is_dir() {
out.push(candidate.to_string());
}
}
for entry in WalkDir::new(root)
.max_depth(4)
.into_iter()
.filter_entry(|entry| !is_ignored_entry(entry.file_name().to_string_lossy().as_ref()))
.flatten()
{
if entry.file_type().is_dir() {
let rel = relative_path(root, entry.path());
if rel.ends_with("/docs/adr") || rel.ends_with("/docs/adrs") {
out.push(rel);
}
}
}
out.sort();
out.dedup();
out
}
fn is_ignored_entry(name: &str) -> bool {
crate::runtime::optimization::DEFAULT_IGNORED_PATHS.contains(&name)
}
fn read_optional(path: PathBuf) -> String {
fs::read_to_string(path).unwrap_or_default()
}
fn contains_any(text: &str, terms: &[&str]) -> bool {
terms.iter().any(|term| text.contains(term))
}
fn node_runner(root: &Path) -> String {
if root.join("pnpm-lock.yaml").exists() {
"pnpm".to_string()
} else if root.join("yarn.lock").exists() {
"yarn".to_string()
} else if root.join("bun.lockb").exists() || root.join("bun.lock").exists() {
"bun".to_string()
} else {
"npm".to_string()
}
}
fn push_if_script(target: &mut Vec<String>, package: &str, runner: &str, script: &str) {
if package.contains(&format!("\"{script}\"")) {
target.push(format!("{runner} run {script}"));
}
}
fn command_available(command: &str) -> bool {
if command.contains('/') || command.contains('\\') {
return Path::new(command).is_file();
}
let Some(paths) = env::var_os("PATH") else {
return false;
};
env::split_paths(&paths).any(|path| path.join(command).is_file())
}
fn is_monorepo(root: &Path) -> bool {
root.join("pnpm-workspace.yaml").exists()
|| root.join("turbo.json").exists()
|| root.join("nx.json").exists()
|| read_optional(root.join("package.json")).contains("\"workspaces\"")
|| (root.join("apps").is_dir() && root.join("packages").is_dir())
}
fn is_infra(root: &Path) -> bool {
root.join("infra").is_dir()
|| root.join("terraform").is_dir()
|| root.join("k8s").is_dir()
|| root.join(".github/workflows").is_dir()
}
fn relative_path(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
#[allow(dead_code)]
fn _command_output(command: &str, args: &[&str]) -> Option<String> {
let output = Command::new(command).args(args).output().ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}