use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeSet;
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 manifests: Vec<ProjectManifest>,
pub paths: DiscoveryPaths,
pub commands: DiscoveryCommands,
pub architecture: ArchitectureReport,
pub quality: QualityReport,
pub operations: OperationsReport,
pub data_contracts: DataContractReport,
pub integrations: IntegrationReport,
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 name: String,
pub lifecycle: String,
pub stack: Vec<String>,
pub project_type: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct ProjectManifest {
pub path: String,
pub kind: String,
pub name: Option<String>,
pub version: Option<String>,
pub package_manager: Option<String>,
pub workspace: bool,
pub scripts: Vec<String>,
pub dependencies: Vec<String>,
pub signals: Vec<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 ArchitectureReport {
pub summary: Vec<String>,
pub entrypoints: Vec<ArchitectureEvidence>,
pub components: Vec<ArchitectureEvidence>,
pub layers: Vec<ArchitectureEvidence>,
pub routing: Vec<ArchitectureEvidence>,
pub conventions: Vec<String>,
pub confidence: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct ArchitectureEvidence {
pub path: String,
pub kind: String,
pub role: String,
pub evidence: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct QualityReport {
pub test_frameworks: Vec<String>,
pub test_paths: Vec<String>,
pub commands: Vec<String>,
pub coverage: Vec<String>,
pub gaps: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct OperationsReport {
pub ci: Vec<String>,
pub deploy: Vec<String>,
pub containers: Vec<String>,
pub env_files: Vec<String>,
pub env_vars: Vec<String>,
pub observability: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct DataContractReport {
pub migrations: Vec<String>,
pub schemas: Vec<String>,
pub api_contracts: Vec<String>,
pub orm: Vec<String>,
pub seeds_fixtures: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct IntegrationReport {
pub external_services: Vec<String>,
pub auth: Vec<String>,
pub webhooks: Vec<String>,
pub queues_jobs: Vec<String>,
pub storage: 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);
let manifests = detect_manifests(&root);
let paths = detect_paths(&root);
let commands = detect_commands(&root);
let data_contracts = detect_data_contracts(&root);
let integrations = detect_integrations(&root);
let architecture = detect_architecture(&root, &stack, &manifests, &paths, &data_contracts);
let quality = detect_quality(&root, &commands, &manifests, &paths);
let operations = detect_operations(&root);
DiscoveryReport {
project_root: crate::runtime::platform::display_path(&root).replace('\\', "/"),
identity: ProjectIdentity {
name: detect_project_name(&root, &manifests),
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,
},
manifests,
paths,
commands,
architecture,
quality,
operations,
data_contracts,
integrations,
domain_language: detect_domain_language(&root),
code_intelligence: detect_code_intelligence(&root),
capabilities: capability_recommendations(&root),
risks: detect_risks(&root),
}
}
fn detect_project_name(root: &Path, manifests: &[ProjectManifest]) -> String {
manifests
.iter()
.find_map(|manifest| manifest.name.clone())
.or_else(|| {
root.file_name()
.and_then(|item| item.to_str())
.map(str::to_string)
})
.unwrap_or_else(|| "projeto".to_string())
}
fn detect_manifests(root: &Path) -> Vec<ProjectManifest> {
let mut manifests = Vec::new();
if root.join("package.json").exists() {
let text = read_optional(root.join("package.json"));
let json = serde_json::from_str::<Value>(&text).ok();
let scripts = json
.as_ref()
.and_then(|value| value.get("scripts"))
.and_then(Value::as_object)
.map(|scripts| {
scripts
.iter()
.map(|(name, command)| {
format!("{name}: {}", command.as_str().unwrap_or("<não textual>"))
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let dependencies = package_dependencies(json.as_ref());
let workspace = json
.as_ref()
.and_then(|value| value.get("workspaces"))
.is_some()
|| root.join("pnpm-workspace.yaml").exists()
|| root.join("turbo.json").exists()
|| root.join("nx.json").exists();
let mut signals = Vec::new();
if workspace {
signals.push("workspace/monorepo".to_string());
}
if dependencies.iter().any(|dep| dep.contains("react")) {
signals.push("React".to_string());
}
if dependencies.iter().any(|dep| dep == "next") || root.join("next.config.js").exists() {
signals.push("Next.js".to_string());
}
if dependencies.iter().any(|dep| dep == "vite") || root.join("vite.config.ts").exists() {
signals.push("Vite".to_string());
}
if dependencies.iter().any(|dep| {
["express", "fastify", "hono", "@nestjs/core", "koa"].contains(&dep.as_str())
}) {
signals.push("Node API".to_string());
}
manifests.push(ProjectManifest {
path: "package.json".to_string(),
kind: "node".to_string(),
name: json
.as_ref()
.and_then(|value| value.get("name"))
.and_then(Value::as_str)
.map(str::to_string),
version: json
.as_ref()
.and_then(|value| value.get("version"))
.and_then(Value::as_str)
.map(str::to_string),
package_manager: Some(node_runner(root)),
workspace,
scripts,
dependencies,
signals,
});
}
if root.join("Cargo.toml").exists() {
let text = read_optional(root.join("Cargo.toml"));
manifests.push(ProjectManifest {
path: "Cargo.toml".to_string(),
kind: "rust".to_string(),
name: toml_string_value(&text, "name"),
version: toml_string_value(&text, "version"),
package_manager: Some("cargo".to_string()),
workspace: text.contains("[workspace]"),
scripts: Vec::new(),
dependencies: cargo_dependencies(&text),
signals: cargo_signals(root, &text),
});
}
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
let pyproject = read_optional(root.join("pyproject.toml"));
let requirements = read_optional(root.join("requirements.txt"));
let combined = format!("{pyproject}\n{requirements}");
manifests.push(ProjectManifest {
path: if root.join("pyproject.toml").exists() {
"pyproject.toml".to_string()
} else {
"requirements.txt".to_string()
},
kind: "python".to_string(),
name: toml_string_value(&pyproject, "name"),
version: toml_string_value(&pyproject, "version"),
package_manager: python_package_manager(root),
workspace: root.join("uv.lock").exists(),
scripts: python_scripts(&pyproject),
dependencies: python_dependencies(&combined),
signals: python_signals(root, &combined),
});
}
if root.join("go.mod").exists() {
let text = read_optional(root.join("go.mod"));
manifests.push(ProjectManifest {
path: "go.mod".to_string(),
kind: "go".to_string(),
name: text
.lines()
.find_map(|line| line.trim().strip_prefix("module "))
.map(str::to_string),
version: None,
package_manager: Some("go".to_string()),
workspace: root.join("go.work").exists(),
scripts: Vec::new(),
dependencies: go_dependencies(&text),
signals: go_signals(root, &text),
});
}
for (path, kind) in [
("pom.xml", "jvm"),
("build.gradle", "jvm"),
("build.gradle.kts", "jvm"),
("Gemfile", "ruby"),
("composer.json", "php"),
("pubspec.yaml", "dart/flutter"),
("Package.swift", "swift"),
] {
if root.join(path).exists() {
manifests.push(ProjectManifest {
path: path.to_string(),
kind: kind.to_string(),
name: None,
version: None,
package_manager: None,
workspace: false,
scripts: Vec::new(),
dependencies: Vec::new(),
signals: vec![format!("manifest {path}")],
});
}
}
manifests
}
fn package_dependencies(json: Option<&Value>) -> Vec<String> {
let mut dependencies = BTreeSet::new();
for section in [
"dependencies",
"devDependencies",
"peerDependencies",
"optionalDependencies",
] {
if let Some(map) = json
.and_then(|value| value.get(section))
.and_then(Value::as_object)
{
dependencies.extend(map.keys().cloned());
}
}
dependencies.into_iter().collect()
}
fn toml_string_value(text: &str, key: &str) -> Option<String> {
text.lines().find_map(|line| {
let trimmed = line.trim();
let value = trimmed.strip_prefix(&format!("{key} = "))?;
Some(value.trim_matches('"').trim_matches('\'').to_string())
})
}
fn cargo_dependencies(text: &str) -> Vec<String> {
let mut dependencies = BTreeSet::new();
let mut in_dependencies = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_dependencies = matches!(
trimmed,
"[dependencies]"
| "[dev-dependencies]"
| "[build-dependencies]"
| "[workspace.dependencies]"
);
continue;
}
if in_dependencies {
if let Some((name, _)) = trimmed.split_once('=') {
let name = name.trim();
if !name.is_empty() {
dependencies.insert(name.to_string());
}
}
}
}
dependencies.into_iter().collect()
}
fn cargo_signals(root: &Path, text: &str) -> Vec<String> {
let lower = text.to_lowercase();
let mut signals = Vec::new();
if root.join("src/main.rs").exists() {
signals.push("binário Rust".to_string());
}
if root.join("src/lib.rs").exists() {
signals.push("biblioteca Rust".to_string());
}
for (needle, label) in [
("axum", "Axum API"),
("actix-web", "Actix Web API"),
("rocket", "Rocket API"),
("tonic", "gRPC/Tonic"),
("ratatui", "TUI Ratatui"),
("clap", "CLI Clap"),
("serde", "serialização Serde"),
("sqlx", "SQLx"),
("diesel", "Diesel ORM"),
] {
if lower.contains(needle) {
signals.push(label.to_string());
}
}
signals
}
fn python_package_manager(root: &Path) -> Option<String> {
if root.join("uv.lock").exists() {
Some("uv".to_string())
} else if root.join("poetry.lock").exists() {
Some("poetry".to_string())
} else if root.join("requirements.txt").exists() {
Some("pip".to_string())
} else if root.join("pyproject.toml").exists() {
Some("python".to_string())
} else {
None
}
}
fn python_scripts(pyproject: &str) -> Vec<String> {
let mut scripts = Vec::new();
let mut in_scripts = false;
for line in pyproject.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_scripts = matches!(
trimmed,
"[project.scripts]" | "[tool.poetry.scripts]" | "[tool.hatch.build.targets.wheel]"
);
continue;
}
if in_scripts && trimmed.contains('=') {
scripts.push(trimmed.to_string());
}
}
scripts
}
fn python_dependencies(text: &str) -> Vec<String> {
let mut dependencies = BTreeSet::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('[') {
continue;
}
let normalized = trimmed
.split(['=', '<', '>', '~', ';', '['])
.next()
.unwrap_or(trimmed)
.trim()
.trim_matches('"')
.trim_matches('\'');
if normalized
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '.')
&& normalized.len() > 1
{
dependencies.insert(normalized.to_string());
}
}
dependencies.into_iter().collect()
}
fn python_signals(root: &Path, text: &str) -> Vec<String> {
let lower = text.to_lowercase();
let mut signals = Vec::new();
for (needle, label) in [
("fastapi", "FastAPI"),
("django", "Django"),
("flask", "Flask"),
("pytest", "pytest"),
("pydantic", "Pydantic"),
("sqlalchemy", "SQLAlchemy"),
("pandas", "Data/analytics"),
] {
if lower.contains(needle) {
signals.push(label.to_string());
}
}
if root.join("manage.py").exists() {
signals.push("Django manage.py".to_string());
}
signals
}
fn go_dependencies(text: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("require ") {
trimmed
.trim_start_matches("require ")
.split_whitespace()
.next()
.map(str::to_string)
} else if trimmed.contains('/') && !trimmed.starts_with("//") {
trimmed.split_whitespace().next().map(str::to_string)
} else {
None
}
})
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn go_signals(root: &Path, text: &str) -> Vec<String> {
let lower = text.to_lowercase();
let mut signals = Vec::new();
if root.join("cmd").is_dir() {
signals.push("cmd/ entrypoints".to_string());
}
for (needle, label) in [
("gin-gonic/gin", "Gin API"),
("gofiber/fiber", "Fiber API"),
("grpc", "gRPC"),
("cobra", "CLI Cobra"),
] {
if lower.contains(needle) {
signals.push(label.to_string());
}
}
signals
}
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_architecture(
root: &Path,
stack: &[String],
manifests: &[ProjectManifest],
paths: &DiscoveryPaths,
data_contracts: &DataContractReport,
) -> ArchitectureReport {
let entrypoints = detect_entrypoints(root, manifests);
let components = detect_components(root);
let layers = detect_layers(root);
let routing = detect_routing(root, manifests);
let conventions = detect_conventions(root);
let mut summary = Vec::new();
if !entrypoints.is_empty() {
summary.push(format!(
"Entrypoints reais detectados: {}.",
entrypoints
.iter()
.map(|item| format!("`{}` ({})", item.path, item.kind))
.collect::<Vec<_>>()
.join(", ")
));
} else {
summary.push(
"Nenhum entrypoint canônico foi encontrado no inventário estático; confirmar comando de inicialização no próximo checkpoint.".to_string(),
);
}
if stack.iter().any(|item| item == "monorepo") {
summary.push(
"Projeto com sinais de monorepo/workspaces; mudanças devem declarar app/package alvo e testes afetados.".to_string(),
);
}
if stack.iter().any(|item| item == "rust") && root.join("src/main.rs").exists() {
summary.push(
"Aplicação Rust com binário em `src/main.rs`; contratos e comandos derivam de `Cargo.toml`.".to_string(),
);
}
if stack.iter().any(|item| item == "frontend") {
summary.push(
"Interface frontend detectada; revisar rotas, componentes, design system e validação visual quando houver mudança de UI.".to_string(),
);
}
if stack.iter().any(|item| item.contains("api")) {
summary.push(
"Serviço/API detectado; revisar contratos, handlers/rotas, autenticação, erros e observabilidade na Tech Spec.".to_string(),
);
}
if !data_contracts.migrations.is_empty() || !data_contracts.schemas.is_empty() {
summary.push(
"Contratos de dados presentes; mudanças em persistência precisam de plano de migração, compatibilidade e rollback.".to_string(),
);
}
if !paths.artifacts.is_empty() {
summary.push(format!(
"Artefatos SDD detectados em {}; usar traceability map como fonte local de contexto aprovado.",
paths.artifacts.join(", ")
));
}
if summary.len() == 1 && entrypoints.is_empty() {
summary.push(
"A arquitetura ainda não possui sinais suficientes para inferência de alto nível; trate o discovery como pendência de onboarding.".to_string(),
);
}
let confidence = if !entrypoints.is_empty() && !manifests.is_empty() && !components.is_empty() {
"alta: manifests, entrypoints e componentes foram encontrados".to_string()
} else if !manifests.is_empty() || !entrypoints.is_empty() {
"média: há manifests ou entrypoints, mas faltam componentes ou convenções claras"
.to_string()
} else {
"baixa: sem manifest/entrypoint suficiente no inventário estático".to_string()
};
ArchitectureReport {
summary,
entrypoints,
components,
layers,
routing,
conventions,
confidence,
}
}
fn detect_entrypoints(root: &Path, manifests: &[ProjectManifest]) -> Vec<ArchitectureEvidence> {
let mut out = Vec::new();
for (path, kind, role) in [
("src/main.rs", "rust-bin", "Binário principal Rust"),
("src/lib.rs", "rust-lib", "Biblioteca Rust compartilhada"),
("src/bin", "rust-bin-dir", "Binários Rust adicionais"),
("src/main.ts", "node-entry", "Entrypoint TypeScript"),
("src/main.tsx", "frontend-entry", "Entrypoint frontend"),
("src/index.ts", "node-entry", "Entrypoint Node/Worker"),
("src/server.ts", "node-server", "Servidor Node"),
("server.ts", "node-server", "Servidor Node"),
("worker.ts", "worker", "Worker"),
("src/App.tsx", "react-app", "Componente raiz React"),
(
"app/page.tsx",
"next-app-router",
"Página raiz Next App Router",
),
(
"pages/_app.tsx",
"next-pages-router",
"Bootstrap Next Pages Router",
),
("main.py", "python-entry", "Entrypoint Python"),
("app.py", "python-entry", "Entrypoint Python"),
("manage.py", "django-entry", "Entrypoint Django"),
("cmd", "go-cmd", "Entrypoints Go por comando"),
("main.go", "go-entry", "Entrypoint Go"),
(
"wrangler.toml",
"cloudflare-worker",
"Configuração Cloudflare Worker",
),
] {
push_arch_if_exists(
root,
&mut out,
path,
kind,
role,
&[format!("path `{path}` existe")],
);
}
for file in collect_files_with_name(root, "main.go", 4, 8) {
if file != "main.go" {
push_arch_path(
&mut out,
file.clone(),
"go-entry",
"Entrypoint Go aninhado",
vec![format!("arquivo `{file}` encontrado")],
);
}
}
for manifest in manifests {
if manifest.kind == "node" {
for script in &manifest.scripts {
if script.starts_with("dev:")
|| script.starts_with("start:")
|| script.starts_with("serve:")
{
push_arch_path(
&mut out,
manifest.path.clone(),
"script",
"Script de execução declarado no manifest",
vec![script.clone()],
);
}
}
}
}
dedup_arch(out)
}
fn detect_components(root: &Path) -> Vec<ArchitectureEvidence> {
let mut out = Vec::new();
for (path, kind, role) in [
("apps", "workspace", "Aplicações do monorepo"),
("packages", "workspace", "Pacotes compartilhados"),
("crates", "rust-workspace", "Crates Rust"),
("src", "source", "Código fonte principal"),
("src/domain", "domain", "Domínio/regras centrais"),
(
"src/runtime",
"runtime",
"Runtime e integrações operacionais",
),
("src/tui", "interface", "Interface TUI"),
("src/components", "ui", "Componentes de UI"),
("app", "frontend-router", "App router/frontend"),
("pages", "frontend-router", "Pages router/frontend"),
("routes", "routing", "Rotas HTTP"),
("controllers", "routing", "Controllers"),
("services", "application", "Serviços de aplicação"),
("repositories", "data", "Acesso a dados"),
("bot/src", "bot", "Bot/integração conversacional"),
("schemas", "contracts", "Schemas e contratos"),
("templates", "templates", "Templates de artefatos"),
(
"clients",
"client-surfaces",
"Superfícies geradas para clientes",
),
("plugins", "plugins", "Plugins e skills distribuíveis"),
(
"adapters",
"integrations",
"Adapters de ferramentas externas",
),
("presets", "configuration", "Presets de instalação"),
("docs", "documentation", "Documentação e artefatos locais"),
] {
push_arch_if_exists(
root,
&mut out,
path,
kind,
role,
&[format!("path `{path}` existe")],
);
}
dedup_arch(out)
}
fn detect_layers(root: &Path) -> Vec<ArchitectureEvidence> {
let mut out = Vec::new();
for (path, kind, role) in [
("src/domain", "domain", "Domínio isolado"),
("src/runtime", "runtime", "Runtime/plataforma"),
("src/tui", "presentation", "Apresentação TUI"),
(
"src/main.rs",
"composition-root",
"CLI/composição principal",
),
("schemas", "contracts", "Contratos versionados"),
("templates", "artifact-templates", "Templates de artefatos"),
("bot/src", "integration", "Integração externa/bot"),
("tests", "verification", "Testes automatizados"),
] {
push_arch_if_exists(
root,
&mut out,
path,
kind,
role,
&[format!("path `{path}` existe")],
);
}
dedup_arch(out)
}
fn detect_routing(root: &Path, manifests: &[ProjectManifest]) -> Vec<ArchitectureEvidence> {
let mut out = Vec::new();
for (path, kind, role) in [
("app/api", "next-api", "Rotas API Next App Router"),
("pages/api", "next-api", "Rotas API Next Pages Router"),
("src/routes", "routes", "Rotas de aplicação"),
("routes", "routes", "Rotas de aplicação"),
("controllers", "controllers", "Controllers HTTP"),
("src/server.ts", "node-server", "Servidor Node"),
("src/main.rs", "cli-router", "Despacho de comandos CLI"),
(".claude/commands", "agent-commands", "Comandos Claude"),
(".opencode/commands", "agent-commands", "Comandos OpenCode"),
(".trae/commands", "agent-commands", "Comandos Trae"),
] {
push_arch_if_exists(
root,
&mut out,
path,
kind,
role,
&[format!("path `{path}` existe")],
);
}
let dep_text = dependency_text(manifests).to_lowercase();
for (needle, kind, role) in [
("hono", "hono", "Roteamento Hono provável por dependência"),
(
"express",
"express",
"Roteamento Express provável por dependência",
),
(
"fastify",
"fastify",
"Roteamento Fastify provável por dependência",
),
(
"@nestjs/core",
"nestjs",
"Módulos/controllers NestJS prováveis por dependência",
),
("axum", "axum", "Rotas Axum prováveis por dependência"),
(
"actix-web",
"actix",
"Rotas Actix Web prováveis por dependência",
),
("clap", "clap", "Roteamento de CLI via Clap"),
] {
if dep_text.contains(needle) {
push_arch_path(
&mut out,
"manifest".to_string(),
kind,
role,
vec![format!("dependência `{needle}` detectada")],
);
}
}
dedup_arch(out)
}
fn detect_conventions(root: &Path) -> Vec<String> {
let mut conventions = Vec::new();
for (path, description) in [
("AGENTS.md", "Instruções portáveis para agentes"),
("CLAUDE.md", "Instruções específicas para Claude Code"),
("sdd.config.yaml", "Configuração SDD do projeto"),
(
"schemas/sdd-contract.yaml",
"Contrato canônico do fluxo SDD",
),
(
"schemas/artifact-sections.json",
"Schema de validação de artefatos",
),
(
"templates/traceability-map.yaml",
"Template de rastreabilidade local",
),
(
"templates/artifact-index.yaml",
"Template do índice de artefatos",
),
(".github/workflows", "CI GitHub Actions"),
("README.md", "Documentação inicial do projeto"),
("CONTEXT.md", "Glossário/contexto de domínio"),
("CONTEXT-MAP.md", "Mapa de contexto/domínio"),
] {
if root.join(path).exists() {
conventions.push(format!("{description}: `{path}`"));
}
}
if conventions.is_empty() {
conventions.push(
"não encontrado no inventário; registrar convenções no Project Discovery".to_string(),
);
}
conventions
}
fn detect_quality(
root: &Path,
commands: &DiscoveryCommands,
manifests: &[ProjectManifest],
paths: &DiscoveryPaths,
) -> QualityReport {
let dep_text = dependency_text(manifests).to_lowercase();
let mut test_frameworks = Vec::new();
for (needle, label) in [
("vitest", "Vitest"),
("jest", "Jest"),
("playwright", "Playwright"),
("cypress", "Cypress"),
("pytest", "pytest"),
("rstest", "rstest"),
("cargo", "cargo test"),
("go test", "go test"),
] {
if dep_text.contains(needle)
|| commands.test.iter().any(|command| command.contains(needle))
|| (needle == "cargo" && root.join("Cargo.toml").exists())
{
push_unique(&mut test_frameworks, label.to_string());
}
}
let test_paths = paths.tests.clone();
let commands = [
commands.lint.clone(),
commands.typecheck.clone(),
commands.test.clone(),
commands.build.clone(),
]
.concat();
let coverage = collect_existing(
root,
&[
"coverage",
"coverage/lcov.info",
".nyc_output",
"tarpaulin-report.html",
"target/llvm-cov",
],
);
let mut gaps = Vec::new();
if test_paths.is_empty() {
gaps.push("paths de teste não encontrados no inventário".to_string());
}
if commands.is_empty() {
gaps.push("comandos de qualidade não derivados dos manifests".to_string());
}
if coverage.is_empty() {
gaps.push("coverage não encontrado; tratar como não verificado".to_string());
}
QualityReport {
test_frameworks,
test_paths,
commands,
coverage,
gaps,
}
}
fn detect_operations(root: &Path) -> OperationsReport {
let ci = collect_existing(
root,
&[
".github/workflows",
".gitlab-ci.yml",
"bitbucket-pipelines.yml",
".circleci",
"Jenkinsfile",
],
);
let deploy = collect_existing(
root,
&[
"vercel.json",
"netlify.toml",
"wrangler.toml",
"fly.toml",
"render.yaml",
"railway.toml",
"terraform",
"infra",
"k8s",
],
);
let containers = collect_existing(root, &["Dockerfile", "docker-compose.yml", "compose.yaml"]);
let env_files = collect_existing(
root,
&[
".env.example",
".env.sample",
".env.local.example",
"appsettings.json",
"wrangler.toml",
],
);
let env_vars = collect_env_var_names(root, &env_files);
let observability = detect_observability(root, &env_vars);
OperationsReport {
ci,
deploy,
containers,
env_files,
env_vars,
observability,
}
}
fn detect_data_contracts(root: &Path) -> DataContractReport {
let migrations = collect_existing(
root,
&[
"migrations",
"db/migrate",
"prisma/migrations",
"alembic",
"drizzle",
],
);
let mut schemas = collect_existing(
root,
&[
"schemas",
"schema",
"prisma/schema.prisma",
"drizzle",
"src/schema.ts",
"src/db/schema.ts",
],
);
schemas.extend(collect_files_by_extensions(
root,
&["graphql", "proto"],
5,
40,
));
schemas.sort();
schemas.dedup();
let mut api_contracts = collect_files_named(
root,
&[
"openapi.yaml",
"openapi.yml",
"openapi.json",
"swagger.yaml",
"schema.graphql",
],
6,
40,
);
api_contracts.extend(collect_files_by_extensions(root, &["proto"], 6, 40));
api_contracts.sort();
api_contracts.dedup();
let all_text = project_signal_text(root).to_lowercase();
let mut orm = Vec::new();
for (needle, label) in [
("prisma", "Prisma"),
("drizzle", "Drizzle"),
("typeorm", "TypeORM"),
("sequelize", "Sequelize"),
("knex", "Knex"),
("sqlx", "SQLx"),
("diesel", "Diesel"),
("sqlalchemy", "SQLAlchemy"),
("mongoose", "Mongoose"),
] {
if all_text.contains(needle) {
orm.push(label.to_string());
}
}
let seeds_fixtures = collect_existing(
root,
&[
"seeds",
"seed",
"fixtures",
"tests/fixtures",
"test/fixtures",
"prisma/seed.ts",
],
);
DataContractReport {
migrations,
schemas,
api_contracts,
orm,
seeds_fixtures,
}
}
fn detect_integrations(root: &Path) -> IntegrationReport {
let signal = project_signal_text(root).to_lowercase();
let env_files = collect_existing(
root,
&[
".env.example",
".env.sample",
".env.local.example",
"appsettings.json",
"wrangler.toml",
],
);
let env_vars = collect_env_var_names(root, &env_files)
.join("\n")
.to_lowercase();
let combined = format!("{signal}\n{env_vars}");
let external_services = detect_named_signals(
&combined,
&[
("openai", "OpenAI"),
("anthropic", "Anthropic"),
("slack", "Slack"),
("github", "GitHub"),
("gitlab", "GitLab"),
("bitbucket", "Bitbucket"),
("jira", "Jira"),
("confluence", "Confluence"),
("stripe", "Stripe"),
("sendgrid", "SendGrid"),
("resend", "Resend"),
("twilio", "Twilio"),
("firebase", "Firebase"),
("supabase", "Supabase"),
("cloudflare", "Cloudflare"),
("aws", "AWS"),
("s3", "S3"),
("r2", "Cloudflare R2"),
("sentry", "Sentry"),
("datadog", "Datadog"),
],
);
let auth = detect_named_signals(
&combined,
&[
("auth0", "Auth0"),
("clerk", "Clerk"),
("nextauth", "NextAuth"),
("jwt", "JWT"),
("oauth", "OAuth"),
("session", "Sessões"),
("api_key", "API keys"),
("token", "Tokens"),
],
);
let webhooks = detect_named_signals(
&combined,
&[
("webhook", "Webhooks"),
("stripe_webhook", "Stripe webhook"),
("github_webhook", "GitHub webhook"),
("slack_signing", "Slack signing secret"),
],
);
let queues_jobs = detect_named_signals(
&combined,
&[
("bullmq", "BullMQ"),
("bull", "Bull"),
("queue", "Filas"),
("cron", "Cron"),
("worker", "Workers/jobs"),
("celery", "Celery"),
("sidekiq", "Sidekiq"),
],
);
let storage = detect_named_signals(
&combined,
&[
("postgres", "PostgreSQL"),
("mysql", "MySQL"),
("sqlite", "SQLite"),
("redis", "Redis"),
("mongodb", "MongoDB"),
("s3", "S3"),
("r2", "Cloudflare R2"),
("bucket", "Object storage"),
],
);
IntegrationReport {
external_services,
auth,
webhooks,
queues_jobs,
storage,
}
}
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 dependency_text(manifests: &[ProjectManifest]) -> String {
manifests
.iter()
.flat_map(|manifest| {
[
manifest.dependencies.join("\n"),
manifest.signals.join("\n"),
manifest.scripts.join("\n"),
]
})
.collect::<Vec<_>>()
.join("\n")
}
fn project_signal_text(root: &Path) -> String {
[
read_optional(root.join("package.json")),
read_optional(root.join("Cargo.toml")),
read_optional(root.join("pyproject.toml")),
read_optional(root.join("requirements.txt")),
read_optional(root.join("go.mod")),
read_optional(root.join("wrangler.toml")),
read_optional(root.join("sdd.config.yaml")),
]
.join("\n")
}
fn collect_existing(root: &Path, candidates: &[&str]) -> Vec<String> {
existing_paths(root, candidates)
}
fn collect_files_with_name(root: &Path, name: &str, max_depth: usize, limit: usize) -> Vec<String> {
collect_files_named(root, &[name], max_depth, limit)
}
fn collect_files_named(root: &Path, names: &[&str], max_depth: usize, limit: usize) -> Vec<String> {
let mut out = Vec::new();
for entry in WalkDir::new(root)
.max_depth(max_depth)
.into_iter()
.filter_entry(|entry| !is_ignored_entry(entry.file_name().to_string_lossy().as_ref()))
.flatten()
{
if out.len() >= limit {
break;
}
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.dedup();
out
}
fn collect_files_by_extensions(
root: &Path,
extensions: &[&str],
max_depth: usize,
limit: usize,
) -> Vec<String> {
let mut out = Vec::new();
for entry in WalkDir::new(root)
.max_depth(max_depth)
.into_iter()
.filter_entry(|entry| !is_ignored_entry(entry.file_name().to_string_lossy().as_ref()))
.flatten()
{
if out.len() >= limit {
break;
}
if entry.file_type().is_file() {
let ext = entry
.path()
.extension()
.and_then(|item| item.to_str())
.unwrap_or("");
if extensions.contains(&ext) {
out.push(relative_path(root, entry.path()));
}
}
}
out.sort();
out.dedup();
out
}
fn collect_env_var_names(root: &Path, env_files: &[String]) -> Vec<String> {
let mut vars = BTreeSet::new();
for rel in env_files {
let text = read_optional(root.join(rel));
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some((name, _)) = trimmed.split_once('=') {
let name = name.trim();
if name
.chars()
.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
{
vars.insert(name.to_string());
}
}
}
}
vars.into_iter().collect()
}
fn detect_observability(root: &Path, env_vars: &[String]) -> Vec<String> {
let signal = format!(
"{}\n{}",
project_signal_text(root).to_lowercase(),
env_vars.join("\n").to_lowercase()
);
detect_named_signals(
&signal,
&[
("sentry", "Sentry"),
("opentelemetry", "OpenTelemetry"),
("otel", "OpenTelemetry"),
("datadog", "Datadog"),
("prometheus", "Prometheus"),
("analytics", "Analytics"),
("logfire", "Logfire"),
("new_relic", "New Relic"),
("grafana", "Grafana"),
],
)
}
fn detect_named_signals(text: &str, signals: &[(&str, &str)]) -> Vec<String> {
let mut out = Vec::new();
for (needle, label) in signals {
if text.contains(needle) {
push_unique(&mut out, (*label).to_string());
}
}
out
}
fn push_arch_if_exists(
root: &Path,
out: &mut Vec<ArchitectureEvidence>,
path: &str,
kind: &str,
role: &str,
evidence: &[String],
) {
if root.join(path).exists() {
push_arch_path(out, path.to_string(), kind, role, evidence.to_vec());
}
}
fn push_arch_path(
out: &mut Vec<ArchitectureEvidence>,
path: String,
kind: &str,
role: &str,
evidence: Vec<String>,
) {
out.push(ArchitectureEvidence {
path,
kind: kind.to_string(),
role: role.to_string(),
evidence,
});
}
fn dedup_arch(items: Vec<ArchitectureEvidence>) -> Vec<ArchitectureEvidence> {
let mut seen = BTreeSet::new();
let mut out = Vec::new();
for item in items {
let key = format!("{}::{}::{}", item.path, item.kind, item.role);
if seen.insert(key) {
out.push(item);
}
}
out
}
fn push_unique(out: &mut Vec<String>, value: String) {
if !out.iter().any(|item| item == &value) {
out.push(value);
}
}
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();
}
crate::runtime::platform::executable_exists(command)
}
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 exe = crate::runtime::platform::find_executable(command)
.unwrap_or_else(|| PathBuf::from(command));
let output = Command::new(exe).args(args).output().ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}