Skip to content

Commit 805e445

Browse files
author
Mark
committed
auto-detect project type and stage from repo signals
Scanner extracts richer signals: CI, tags, tests, licence, framework deps, game engine files, notebooks. Autoscorer uses these to infer project type (only upgrades from default oss) and auto-promote stage based on repo maturity. Webapp detection limited to frontend frameworks to avoid false positives from HTTP server deps.
1 parent e904f81 commit 805e445

3 files changed

Lines changed: 275 additions & 38 deletions

File tree

src/autoscore.rs

Lines changed: 107 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::adapters;
2-
use crate::domain::Project;
2+
use crate::domain::{Project, ProjectType, RepoSignals};
3+
use crate::scanner;
34
use crate::scoring::{distinctness, leverage, velocity};
45
use crate::store::Store;
56
use std::path::Path;
@@ -18,6 +19,7 @@ fn score_one(store: &Store, project: &Project, all: &[Project], fetch_remote: bo
1819
_ => return Ok(()),
1920
};
2021
let repo = Path::new(&path);
22+
let signals = scanner::scan_signals(repo);
2123

2224
if let Some(vel) = velocity::compute(repo) {
2325
store.update_axis(project.id, "velocity", Some(vel.score))
@@ -41,37 +43,54 @@ fn score_one(store: &Store, project: &Project, all: &[Project], fetch_remote: bo
4143
}
4244
}
4345

44-
if project.stage == 0 {
45-
if let Some(suggested) = suggest_stage(repo) {
46-
if suggested > project.stage {
47-
store.record_stage_event(
48-
project.id,
49-
project.stage,
50-
suggested,
51-
Some("autoscore suggestion"),
52-
).map_err(|e| e.to_string())?;
53-
}
54-
}
46+
let detected_type = detect_project_type(&signals);
47+
if detected_type != ProjectType::Oss && project.project_type == ProjectType::Oss {
48+
store.update_project_type(project.id, &detected_type)
49+
.map_err(|e| e.to_string())?;
50+
}
51+
52+
let suggested = suggest_stage(&signals);
53+
if suggested > project.stage {
54+
store.update_stage(project.id, suggested)
55+
.map_err(|e| e.to_string())?;
56+
store.record_stage_event(
57+
project.id,
58+
project.stage,
59+
suggested,
60+
Some("auto-detected from repo signals"),
61+
).map_err(|e| e.to_string())?;
5562
}
5663

5764
Ok(())
5865
}
5966

60-
fn suggest_stage(path: &Path) -> Option<u8> {
61-
let has_readme = path.join("README.md").exists();
62-
let has_src = path.join("src").is_dir();
63-
let has_tests = path.join("tests").is_dir();
67+
fn detect_project_type(signals: &RepoSignals) -> ProjectType {
68+
if signals.has_game_engine {
69+
return ProjectType::Game;
70+
}
71+
if signals.has_notebooks {
72+
return ProjectType::Research;
73+
}
74+
if signals.has_webapp_framework {
75+
return ProjectType::Webapp;
76+
}
77+
ProjectType::Oss
78+
}
6479

65-
if !has_src && !has_readme {
66-
return Some(0);
80+
fn suggest_stage(signals: &RepoSignals) -> u8 {
81+
if signals.has_tags && signals.has_ci && signals.has_readme {
82+
return 4;
6783
}
68-
if has_src && has_readme && has_tests {
69-
return Some(2);
84+
if signals.has_ci || (signals.has_tags && signals.has_tests) {
85+
return 3;
7086
}
71-
if has_src || has_readme {
72-
return Some(1);
87+
if signals.has_src && signals.has_readme && signals.has_tests {
88+
return 2;
7389
}
74-
Some(0)
90+
if signals.has_src || signals.has_readme {
91+
return 1;
92+
}
93+
0
7594
}
7695

7796
#[cfg(test)]
@@ -106,6 +125,12 @@ mod tests {
106125
}
107126
}
108127

128+
fn signals(f: impl FnOnce(&mut RepoSignals)) -> RepoSignals {
129+
let mut s = RepoSignals::default();
130+
f(&mut s);
131+
s
132+
}
133+
109134
#[test]
110135
fn score_one_skips_no_path() {
111136
let store = Store::open_in_memory().unwrap();
@@ -114,25 +139,70 @@ mod tests {
114139
}
115140

116141
#[test]
117-
fn suggest_stage_empty_dir_is_zero() {
118-
let tmp = tempfile::tempdir().unwrap();
119-
assert_eq!(suggest_stage(tmp.path()), Some(0));
142+
fn stage_empty_is_zero() {
143+
assert_eq!(suggest_stage(&RepoSignals::default()), 0);
144+
}
145+
146+
#[test]
147+
fn stage_src_only_is_one() {
148+
assert_eq!(suggest_stage(&signals(|s| s.has_src = true)), 1);
149+
}
150+
151+
#[test]
152+
fn stage_src_readme_tests_is_two() {
153+
assert_eq!(suggest_stage(&signals(|s| {
154+
s.has_src = true;
155+
s.has_readme = true;
156+
s.has_tests = true;
157+
})), 2);
158+
}
159+
160+
#[test]
161+
fn stage_with_ci_is_three() {
162+
assert_eq!(suggest_stage(&signals(|s| {
163+
s.has_src = true;
164+
s.has_readme = true;
165+
s.has_tests = true;
166+
s.has_ci = true;
167+
})), 3);
168+
}
169+
170+
#[test]
171+
fn stage_tags_ci_readme_is_four() {
172+
assert_eq!(suggest_stage(&signals(|s| {
173+
s.has_src = true;
174+
s.has_readme = true;
175+
s.has_tests = true;
176+
s.has_ci = true;
177+
s.has_tags = true;
178+
})), 4);
179+
}
180+
181+
#[test]
182+
fn type_game_engine_detected() {
183+
assert_eq!(detect_project_type(&signals(|s| s.has_game_engine = true)), ProjectType::Game);
184+
}
185+
186+
#[test]
187+
fn type_notebooks_detected_as_research() {
188+
assert_eq!(detect_project_type(&signals(|s| s.has_notebooks = true)), ProjectType::Research);
189+
}
190+
191+
#[test]
192+
fn type_webapp_framework_detected() {
193+
assert_eq!(detect_project_type(&signals(|s| s.has_webapp_framework = true)), ProjectType::Webapp);
120194
}
121195

122196
#[test]
123-
fn suggest_stage_with_src_and_readme_is_one() {
124-
let tmp = tempfile::tempdir().unwrap();
125-
std::fs::create_dir(tmp.path().join("src")).unwrap();
126-
std::fs::write(tmp.path().join("README.md"), "hello").unwrap();
127-
assert_eq!(suggest_stage(tmp.path()), Some(1));
197+
fn type_default_is_oss() {
198+
assert_eq!(detect_project_type(&RepoSignals::default()), ProjectType::Oss);
128199
}
129200

130201
#[test]
131-
fn suggest_stage_with_tests_is_two() {
132-
let tmp = tempfile::tempdir().unwrap();
133-
std::fs::create_dir(tmp.path().join("src")).unwrap();
134-
std::fs::create_dir(tmp.path().join("tests")).unwrap();
135-
std::fs::write(tmp.path().join("README.md"), "hello").unwrap();
136-
assert_eq!(suggest_stage(tmp.path()), Some(2));
202+
fn game_engine_takes_priority_over_webapp() {
203+
assert_eq!(detect_project_type(&signals(|s| {
204+
s.has_game_engine = true;
205+
s.has_webapp_framework = true;
206+
})), ProjectType::Game);
137207
}
138208
}

src/domain.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,24 @@ pub struct ScanResult {
251251
pub last_commit_date: Option<chrono::NaiveDate>,
252252
}
253253

254+
#[derive(Debug, Clone, Default)]
255+
pub struct RepoSignals {
256+
pub has_src: bool,
257+
pub has_readme: bool,
258+
pub has_tests: bool,
259+
pub has_ci: bool,
260+
pub has_tags: bool,
261+
pub tag_count: usize,
262+
pub has_license: bool,
263+
pub has_changelog: bool,
264+
pub has_cargo_toml: bool,
265+
pub has_package_json: bool,
266+
pub has_game_engine: bool,
267+
pub has_notebooks: bool,
268+
pub has_webapp_framework: bool,
269+
pub contributor_count: usize,
270+
}
271+
254272
#[cfg(test)]
255273
mod tests {
256274
use super::*;

src/scanner.rs

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::domain::ScanResult;
1+
use crate::domain::{RepoSignals, ScanResult};
22
use chrono::NaiveDate;
33
use std::path::Path;
44
use std::process::Command;
@@ -9,6 +9,155 @@ pub fn scan_project(project_path: &str) -> ScanResult {
99
ScanResult { last_commit_date }
1010
}
1111

12+
pub fn scan_signals(path: &Path) -> RepoSignals {
13+
let has_src = path.join("src").is_dir();
14+
let has_readme = has_file_prefix(path, "readme");
15+
let has_tests = path.join("tests").is_dir()
16+
|| path.join("test").is_dir()
17+
|| path.join("__tests__").is_dir()
18+
|| path.join("spec").is_dir();
19+
let has_ci = path.join(".github/workflows").is_dir()
20+
|| path.join(".gitlab-ci.yml").exists()
21+
|| path.join("Jenkinsfile").exists()
22+
|| path.join(".circleci").is_dir();
23+
let has_license = has_file_prefix(path, "licen");
24+
let has_changelog = has_file_prefix(path, "changelog")
25+
|| has_file_prefix(path, "changes");
26+
let has_cargo_toml = path.join("Cargo.toml").exists();
27+
let has_package_json = path.join("package.json").exists();
28+
let has_game_engine = path.join("project.godot").exists()
29+
|| has_bevy_dep(path)
30+
|| path.join("Assets").is_dir() && path.join("ProjectSettings").is_dir();
31+
let has_notebooks = has_extension_in_dir(path, "ipynb");
32+
let has_webapp_framework = detect_webapp_framework(path);
33+
let (has_tags, tag_count) = count_git_tags(path);
34+
let contributor_count = count_contributors(path);
35+
36+
RepoSignals {
37+
has_src,
38+
has_readme,
39+
has_tests,
40+
has_ci,
41+
has_tags,
42+
tag_count,
43+
has_license,
44+
has_changelog,
45+
has_cargo_toml,
46+
has_package_json,
47+
has_game_engine,
48+
has_notebooks,
49+
has_webapp_framework,
50+
contributor_count,
51+
}
52+
}
53+
54+
fn has_file_prefix(path: &Path, prefix: &str) -> bool {
55+
let entries = match std::fs::read_dir(path) {
56+
Ok(e) => e,
57+
Err(_) => return false,
58+
};
59+
for entry in entries.flatten() {
60+
let name = entry.file_name().to_string_lossy().to_lowercase();
61+
if name.starts_with(prefix) {
62+
return true;
63+
}
64+
}
65+
false
66+
}
67+
68+
fn has_extension_in_dir(path: &Path, ext: &str) -> bool {
69+
let entries = match std::fs::read_dir(path) {
70+
Ok(e) => e,
71+
Err(_) => return false,
72+
};
73+
for entry in entries.flatten() {
74+
if let Some(e) = entry.path().extension() {
75+
if e.to_string_lossy().eq_ignore_ascii_case(ext) {
76+
return true;
77+
}
78+
}
79+
}
80+
false
81+
}
82+
83+
fn has_bevy_dep(path: &Path) -> bool {
84+
let cargo = path.join("Cargo.toml");
85+
if let Ok(content) = std::fs::read_to_string(cargo) {
86+
return content.contains("bevy");
87+
}
88+
false
89+
}
90+
91+
fn detect_webapp_framework(path: &Path) -> bool {
92+
let pkg = path.join("package.json");
93+
if let Ok(content) = std::fs::read_to_string(pkg) {
94+
let frameworks = ["react", "vue", "svelte", "next", "nuxt", "angular", "express", "fastify"];
95+
let lower = content.to_lowercase();
96+
if frameworks.iter().any(|f| lower.contains(f)) {
97+
return true;
98+
}
99+
}
100+
let cargo = path.join("Cargo.toml");
101+
if let Ok(content) = std::fs::read_to_string(cargo) {
102+
let frameworks = ["leptos", "yew", "dioxus"];
103+
let in_deps = extract_deps_section(&content);
104+
if frameworks.iter().any(|f| in_deps.contains(f)) {
105+
return true;
106+
}
107+
}
108+
false
109+
}
110+
111+
fn extract_deps_section(toml: &str) -> String {
112+
let mut in_deps = false;
113+
let mut result = String::new();
114+
for line in toml.lines() {
115+
let trimmed = line.trim();
116+
if trimmed.starts_with('[') {
117+
in_deps = trimmed == "[dependencies]";
118+
continue;
119+
}
120+
if in_deps {
121+
result.push_str(&trimmed.to_lowercase());
122+
result.push('\n');
123+
}
124+
}
125+
result
126+
}
127+
128+
fn count_git_tags(path: &Path) -> (bool, usize) {
129+
let output = Command::new("git")
130+
.args(["tag", "--list"])
131+
.current_dir(path)
132+
.output();
133+
match output {
134+
Ok(o) if o.status.success() => {
135+
let count = String::from_utf8_lossy(&o.stdout)
136+
.lines()
137+
.filter(|l| !l.trim().is_empty())
138+
.count();
139+
(count > 0, count)
140+
}
141+
_ => (false, 0),
142+
}
143+
}
144+
145+
fn count_contributors(path: &Path) -> usize {
146+
let output = Command::new("git")
147+
.args(["shortlog", "-sn", "--no-merges", "HEAD"])
148+
.current_dir(path)
149+
.output();
150+
match output {
151+
Ok(o) if o.status.success() => {
152+
String::from_utf8_lossy(&o.stdout)
153+
.lines()
154+
.filter(|l| !l.trim().is_empty())
155+
.count()
156+
}
157+
_ => 0,
158+
}
159+
}
160+
12161
pub fn get_last_commit_date(path: &Path) -> Option<NaiveDate> {
13162
let output = Command::new("git")
14163
.args(["log", "-1", "--format=%ad", "--date=format:%Y-%m-%d"])

0 commit comments

Comments
 (0)