@lamentis/naome 1.2.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +2 -2
- package/bin/naome-node.js +2 -1579
- package/bin/naome.js +19 -5
- package/crates/naome-cli/Cargo.toml +1 -1
- package/crates/naome-cli/src/dispatcher.rs +2 -1
- package/crates/naome-cli/src/main.rs +3 -0
- package/crates/naome-cli/src/quality_commands.rs +90 -2
- package/crates/naome-core/Cargo.toml +1 -1
- package/crates/naome-core/src/decision/checks.rs +64 -0
- package/crates/naome-core/src/decision/idle.rs +67 -0
- package/crates/naome-core/src/decision/json.rs +36 -0
- package/crates/naome-core/src/decision/states.rs +165 -0
- package/crates/naome-core/src/decision.rs +131 -353
- package/crates/naome-core/src/install_plan.rs +2 -0
- package/crates/naome-core/src/lib.rs +5 -3
- package/crates/naome-core/src/paths.rs +3 -1
- package/crates/naome-core/src/quality/adapter_support.rs +89 -0
- package/crates/naome-core/src/quality/adapters.rs +20 -67
- package/crates/naome-core/src/quality/cleanup.rs +13 -1
- package/crates/naome-core/src/quality/config.rs +8 -15
- package/crates/naome-core/src/quality/config_support.rs +24 -0
- package/crates/naome-core/src/quality/mod.rs +18 -0
- package/crates/naome-core/src/quality/scanner.rs +20 -8
- package/crates/naome-core/src/quality/structure/adapters.rs +84 -0
- package/crates/naome-core/src/quality/structure/checks/basic.rs +153 -0
- package/crates/naome-core/src/quality/structure/checks/directory.rs +144 -0
- package/crates/naome-core/src/quality/structure/checks/pairing.rs +63 -0
- package/crates/naome-core/src/quality/structure/checks.rs +124 -0
- package/crates/naome-core/src/quality/structure/classify/roles.rs +188 -0
- package/crates/naome-core/src/quality/structure/classify.rs +94 -0
- package/crates/naome-core/src/quality/structure/config.rs +89 -0
- package/crates/naome-core/src/quality/structure/defaults.rs +107 -0
- package/crates/naome-core/src/quality/structure/mod.rs +77 -0
- package/crates/naome-core/src/quality/structure/model.rs +124 -0
- package/crates/naome-core/src/quality/types.rs +3 -0
- package/crates/naome-core/src/route/builtin_checks.rs +155 -0
- package/crates/naome-core/src/route/builtin_context.rs +73 -0
- package/crates/naome-core/src/route/builtin_integrity.rs +49 -0
- package/crates/naome-core/src/route/builtin_require.rs +40 -0
- package/crates/naome-core/src/route/context.rs +180 -0
- package/crates/naome-core/src/route/execution.rs +96 -0
- package/crates/naome-core/src/route/execution_baselines.rs +146 -0
- package/crates/naome-core/src/route/execution_support.rs +57 -0
- package/crates/naome-core/src/route/execution_tasks.rs +71 -0
- package/crates/naome-core/src/route/git_ops.rs +72 -0
- package/crates/naome-core/src/route/quality_gate.rs +73 -0
- package/crates/naome-core/src/route/quality_gate_config.rs +126 -0
- package/crates/naome-core/src/route/quality_gate_snapshot.rs +69 -0
- package/crates/naome-core/src/route/worktree.rs +75 -0
- package/crates/naome-core/src/route/worktree_files.rs +32 -0
- package/crates/naome-core/src/route/worktree_plan.rs +131 -0
- package/crates/naome-core/src/route.rs +44 -1217
- package/crates/naome-core/src/verification.rs +1 -0
- package/crates/naome-core/tests/decision.rs +24 -118
- package/crates/naome-core/tests/harness_health.rs +2 -0
- package/crates/naome-core/tests/quality.rs +12 -118
- package/crates/naome-core/tests/quality_structure.rs +116 -0
- package/crates/naome-core/tests/quality_structure_adapters.rs +98 -0
- package/crates/naome-core/tests/quality_structure_policy.rs +125 -0
- package/crates/naome-core/tests/quality_structure_support/mod.rs +249 -0
- package/crates/naome-core/tests/repo_support/mod.rs +16 -0
- package/crates/naome-core/tests/repo_support/repo.rs +113 -0
- package/crates/naome-core/tests/repo_support/repo_factories.rs +99 -0
- package/crates/naome-core/tests/repo_support/repo_helpers.rs +123 -0
- package/crates/naome-core/tests/repo_support/routes.rs +81 -0
- package/crates/naome-core/tests/repo_support/verification.rs +168 -0
- package/crates/naome-core/tests/repo_support/verification_values.rs +135 -0
- package/crates/naome-core/tests/route.rs +1 -1376
- package/crates/naome-core/tests/route_baseline.rs +86 -0
- package/crates/naome-core/tests/route_completion.rs +141 -0
- package/crates/naome-core/tests/route_harness_refresh.rs +135 -0
- package/crates/naome-core/tests/route_user_diff.rs +198 -0
- package/crates/naome-core/tests/route_worktree.rs +54 -0
- package/crates/naome-core/tests/task_state.rs +60 -432
- package/crates/naome-core/tests/task_state_compact_support/repo.rs +1 -1
- package/crates/naome-core/tests/task_state_support/mod.rs +163 -0
- package/crates/naome-core/tests/task_state_support/states.rs +84 -0
- package/crates/naome-core/tests/verification.rs +4 -45
- package/crates/naome-core/tests/verification_contract.rs +22 -78
- package/crates/naome-core/tests/workflow_support/mod.rs +1 -1
- package/installer/agents.js +90 -0
- package/installer/context.js +67 -0
- package/installer/filesystem.js +166 -0
- package/installer/flows.js +84 -0
- package/installer/git-boundary.js +170 -0
- package/installer/git-hook-content.js +36 -0
- package/installer/git-hooks.js +134 -0
- package/installer/git-local.js +2 -0
- package/installer/git-shared.js +35 -0
- package/installer/harness-file-ops.js +140 -0
- package/installer/harness-files.js +56 -0
- package/installer/harness-verification.js +123 -0
- package/installer/install-plan.js +66 -0
- package/installer/main.js +25 -0
- package/installer/manifest-state.js +167 -0
- package/installer/native-build.js +24 -0
- package/installer/native-format.js +6 -0
- package/installer/native.js +162 -0
- package/installer/output.js +131 -0
- package/installer/version.js +32 -0
- package/native/darwin-arm64/naome +0 -0
- package/native/linux-x64/naome +0 -0
- package/package.json +2 -1
- package/templates/naome-root/.naome/bin/check-harness-health.js +2 -2
- package/templates/naome-root/.naome/bin/check-task-state.js +2 -2
- package/templates/naome-root/.naome/bin/naome.js +25 -21
- package/templates/naome-root/.naome/manifest.json +4 -2
- package/templates/naome-root/.naome/repository-structure.json +90 -0
- package/templates/naome-root/.naome/verification.json +1 -0
- package/templates/naome-root/docs/naome/index.md +4 -3
- package/templates/naome-root/docs/naome/repository-quality.md +3 -0
- package/templates/naome-root/docs/naome/repository-structure.md +51 -0
- package/templates/naome-root/docs/naome/testing.md +2 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
use std::collections::BTreeMap;
|
|
2
|
+
|
|
3
|
+
use crate::quality::structure::model::{RepositoryStructureModel, StructurePath};
|
|
4
|
+
use crate::quality::types::{QualityMode, QualityViolation};
|
|
5
|
+
|
|
6
|
+
use super::{applies, push, push_with_limit};
|
|
7
|
+
|
|
8
|
+
const DUMPING_GROUND_NAMES: &[&str] = &["utils", "helpers", "common", "shared", "misc", "lib"];
|
|
9
|
+
|
|
10
|
+
pub(super) fn dumping_ground_directories(
|
|
11
|
+
model: &RepositoryStructureModel,
|
|
12
|
+
mode: QualityMode,
|
|
13
|
+
violations: &mut Vec<QualityViolation>,
|
|
14
|
+
) {
|
|
15
|
+
for path in model.paths.iter().filter(|path| applies(path, mode)) {
|
|
16
|
+
if path.explanation.role != "source" || !is_dumping_ground_path(path) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
push(
|
|
20
|
+
"dumping-ground-directory",
|
|
21
|
+
&path.explanation.path,
|
|
22
|
+
format!(
|
|
23
|
+
"{} adds source logic under a generic dumping-ground directory; prefer a named module directory.",
|
|
24
|
+
path.explanation.path
|
|
25
|
+
),
|
|
26
|
+
related_module_paths(model, path),
|
|
27
|
+
violations,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
pub(super) fn directory_size(
|
|
33
|
+
model: &RepositoryStructureModel,
|
|
34
|
+
mode: QualityMode,
|
|
35
|
+
violations: &mut Vec<QualityViolation>,
|
|
36
|
+
) {
|
|
37
|
+
for directory in &model.directories {
|
|
38
|
+
if directory.file_count <= model.config.limits.max_directory_files {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if mode == QualityMode::Changed && directory.direct_changed_paths.is_empty() {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
push_with_limit(
|
|
45
|
+
"directory-size",
|
|
46
|
+
&directory.path,
|
|
47
|
+
format!(
|
|
48
|
+
"{} contains {} direct files, exceeding the configured limit of {}.",
|
|
49
|
+
directory.path, directory.file_count, model.config.limits.max_directory_files
|
|
50
|
+
),
|
|
51
|
+
directory.file_count as f64,
|
|
52
|
+
model.config.limits.max_directory_files as f64,
|
|
53
|
+
directory.direct_changed_paths.clone(),
|
|
54
|
+
violations,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
pub(super) fn path_depth(
|
|
60
|
+
model: &RepositoryStructureModel,
|
|
61
|
+
mode: QualityMode,
|
|
62
|
+
violations: &mut Vec<QualityViolation>,
|
|
63
|
+
) {
|
|
64
|
+
for path in model.paths.iter().filter(|path| applies(path, mode)) {
|
|
65
|
+
let depth = path.segments.len();
|
|
66
|
+
if depth > model.config.limits.max_path_depth {
|
|
67
|
+
push_with_limit(
|
|
68
|
+
"path-depth",
|
|
69
|
+
&path.explanation.path,
|
|
70
|
+
format!(
|
|
71
|
+
"{} has path depth {}, exceeding the configured limit of {}.",
|
|
72
|
+
path.explanation.path, depth, model.config.limits.max_path_depth
|
|
73
|
+
),
|
|
74
|
+
depth as f64,
|
|
75
|
+
model.config.limits.max_path_depth as f64,
|
|
76
|
+
vec![],
|
|
77
|
+
violations,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
pub(super) fn case_collisions(
|
|
84
|
+
model: &RepositoryStructureModel,
|
|
85
|
+
mode: QualityMode,
|
|
86
|
+
violations: &mut Vec<QualityViolation>,
|
|
87
|
+
) {
|
|
88
|
+
let mut groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
|
89
|
+
for path in &model.paths {
|
|
90
|
+
groups
|
|
91
|
+
.entry(path.explanation.path.to_ascii_lowercase())
|
|
92
|
+
.or_default()
|
|
93
|
+
.push(path.explanation.path.clone());
|
|
94
|
+
}
|
|
95
|
+
for group in groups.values().filter(|group| group.len() > 1) {
|
|
96
|
+
let changed_group = group.iter().any(|path| {
|
|
97
|
+
model.paths.iter().any(|candidate| {
|
|
98
|
+
candidate.explanation.path == *path && candidate.explanation.changed
|
|
99
|
+
})
|
|
100
|
+
});
|
|
101
|
+
if mode == QualityMode::Changed && !changed_group {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
for path in group {
|
|
105
|
+
push(
|
|
106
|
+
"case-collision",
|
|
107
|
+
path,
|
|
108
|
+
format!(
|
|
109
|
+
"{} collides with another path on case-insensitive filesystems.",
|
|
110
|
+
path
|
|
111
|
+
),
|
|
112
|
+
group
|
|
113
|
+
.iter()
|
|
114
|
+
.filter(|other| *other != path)
|
|
115
|
+
.cloned()
|
|
116
|
+
.collect(),
|
|
117
|
+
violations,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
fn is_dumping_ground_path(path: &StructurePath) -> bool {
|
|
124
|
+
path.segments.iter().enumerate().any(|(index, segment)| {
|
|
125
|
+
index > 0
|
|
126
|
+
&& DUMPING_GROUND_NAMES
|
|
127
|
+
.iter()
|
|
128
|
+
.any(|name| segment.eq_ignore_ascii_case(name))
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fn related_module_paths(model: &RepositoryStructureModel, path: &StructurePath) -> Vec<String> {
|
|
133
|
+
let Some(module) = &path.explanation.module else {
|
|
134
|
+
return Vec::new();
|
|
135
|
+
};
|
|
136
|
+
model
|
|
137
|
+
.paths
|
|
138
|
+
.iter()
|
|
139
|
+
.filter(|candidate| candidate.explanation.module.as_ref() == Some(module))
|
|
140
|
+
.map(|candidate| candidate.explanation.path.clone())
|
|
141
|
+
.filter(|candidate| candidate != &path.explanation.path)
|
|
142
|
+
.take(10)
|
|
143
|
+
.collect()
|
|
144
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
use std::collections::BTreeSet;
|
|
2
|
+
|
|
3
|
+
use crate::quality::structure::model::{RepositoryStructureModel, StructurePath};
|
|
4
|
+
use crate::quality::types::{QualityMode, QualityViolation};
|
|
5
|
+
|
|
6
|
+
use super::{applies, push};
|
|
7
|
+
|
|
8
|
+
pub(super) fn test_source_pairing(
|
|
9
|
+
model: &RepositoryStructureModel,
|
|
10
|
+
mode: QualityMode,
|
|
11
|
+
violations: &mut Vec<QualityViolation>,
|
|
12
|
+
) {
|
|
13
|
+
for source in model.paths.iter().filter(|path| {
|
|
14
|
+
path.explanation.role == "source" && applies(path, mode) && !is_entrypoint(path)
|
|
15
|
+
}) {
|
|
16
|
+
if has_related_test(model, source) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
push(
|
|
20
|
+
"test-source-pairing",
|
|
21
|
+
&source.explanation.path,
|
|
22
|
+
format!(
|
|
23
|
+
"{} changed without a nearby or module-matched test file.",
|
|
24
|
+
source.explanation.path
|
|
25
|
+
),
|
|
26
|
+
vec![],
|
|
27
|
+
violations,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fn has_related_test(model: &RepositoryStructureModel, source: &StructurePath) -> bool {
|
|
33
|
+
let tokens = source_tokens(source);
|
|
34
|
+
model.paths.iter().any(|candidate| {
|
|
35
|
+
candidate.explanation.role == "test"
|
|
36
|
+
&& tokens.iter().any(|token| {
|
|
37
|
+
candidate
|
|
38
|
+
.explanation
|
|
39
|
+
.path
|
|
40
|
+
.to_ascii_lowercase()
|
|
41
|
+
.contains(token)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fn source_tokens(source: &StructurePath) -> BTreeSet<String> {
|
|
47
|
+
source
|
|
48
|
+
.segments
|
|
49
|
+
.iter()
|
|
50
|
+
.flat_map(|segment| segment.split(['.', '-', '_']))
|
|
51
|
+
.map(str::to_ascii_lowercase)
|
|
52
|
+
.filter(|token| token.len() > 2 && token != "src" && token != "mod")
|
|
53
|
+
.collect()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fn is_entrypoint(path: &StructurePath) -> bool {
|
|
57
|
+
path.segments.last().is_some_and(|file| {
|
|
58
|
+
matches!(
|
|
59
|
+
file.as_str(),
|
|
60
|
+
"main.rs" | "main.go" | "index.js" | "index.ts"
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
mod basic;
|
|
2
|
+
mod directory;
|
|
3
|
+
mod pairing;
|
|
4
|
+
|
|
5
|
+
use super::model::{RepositoryStructureModel, StructurePath};
|
|
6
|
+
use crate::quality::scanner::stable_fingerprint;
|
|
7
|
+
use crate::quality::types::{QualityMode, QualityViolation};
|
|
8
|
+
|
|
9
|
+
pub fn run_structure_checks(
|
|
10
|
+
model: &RepositoryStructureModel,
|
|
11
|
+
mode: QualityMode,
|
|
12
|
+
) -> Vec<QualityViolation> {
|
|
13
|
+
let mut violations = Vec::new();
|
|
14
|
+
if check_enabled(model, "root-file-sprawl") {
|
|
15
|
+
basic::root_file_sprawl(model, mode, &mut violations);
|
|
16
|
+
}
|
|
17
|
+
if check_enabled(model, "misplaced-file-role") {
|
|
18
|
+
basic::misplaced_files(model, mode, &mut violations);
|
|
19
|
+
}
|
|
20
|
+
if check_enabled(model, "directory-role-mixing") {
|
|
21
|
+
basic::directory_role_mixing(model, mode, &mut violations);
|
|
22
|
+
}
|
|
23
|
+
if check_enabled(model, "dumping-ground-directory") {
|
|
24
|
+
directory::dumping_ground_directories(model, mode, &mut violations);
|
|
25
|
+
}
|
|
26
|
+
if check_enabled(model, "directory-size") {
|
|
27
|
+
directory::directory_size(model, mode, &mut violations);
|
|
28
|
+
}
|
|
29
|
+
if check_enabled(model, "path-depth") {
|
|
30
|
+
directory::path_depth(model, mode, &mut violations);
|
|
31
|
+
}
|
|
32
|
+
if check_enabled(model, "case-collision") {
|
|
33
|
+
directory::case_collisions(model, mode, &mut violations);
|
|
34
|
+
}
|
|
35
|
+
if check_enabled(model, "test-source-pairing") {
|
|
36
|
+
pairing::test_source_pairing(model, mode, &mut violations);
|
|
37
|
+
}
|
|
38
|
+
violations.sort_by(|left, right| {
|
|
39
|
+
left.path
|
|
40
|
+
.cmp(&right.path)
|
|
41
|
+
.then(left.check_id.cmp(&right.check_id))
|
|
42
|
+
.then(left.fingerprint.cmp(&right.fingerprint))
|
|
43
|
+
});
|
|
44
|
+
violations
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
fn check_enabled(model: &RepositoryStructureModel, check_id: &str) -> bool {
|
|
48
|
+
!model
|
|
49
|
+
.config
|
|
50
|
+
.disabled_checks
|
|
51
|
+
.iter()
|
|
52
|
+
.any(|disabled| disabled == check_id)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fn applies(path: &StructurePath, mode: QualityMode) -> bool {
|
|
56
|
+
mode == QualityMode::Report || path.explanation.changed
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fn push(
|
|
60
|
+
check_id: &str,
|
|
61
|
+
path: &str,
|
|
62
|
+
message: String,
|
|
63
|
+
related_paths: Vec<String>,
|
|
64
|
+
violations: &mut Vec<QualityViolation>,
|
|
65
|
+
) {
|
|
66
|
+
push_with_optional_limit(
|
|
67
|
+
check_id,
|
|
68
|
+
path,
|
|
69
|
+
message,
|
|
70
|
+
None,
|
|
71
|
+
None,
|
|
72
|
+
related_paths,
|
|
73
|
+
violations,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fn push_with_limit(
|
|
78
|
+
check_id: &str,
|
|
79
|
+
path: &str,
|
|
80
|
+
message: String,
|
|
81
|
+
value: f64,
|
|
82
|
+
limit: f64,
|
|
83
|
+
related_paths: Vec<String>,
|
|
84
|
+
violations: &mut Vec<QualityViolation>,
|
|
85
|
+
) {
|
|
86
|
+
push_with_optional_limit(
|
|
87
|
+
check_id,
|
|
88
|
+
path,
|
|
89
|
+
message,
|
|
90
|
+
Some(value),
|
|
91
|
+
Some(limit),
|
|
92
|
+
related_paths,
|
|
93
|
+
violations,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fn push_with_optional_limit(
|
|
98
|
+
check_id: &str,
|
|
99
|
+
path: &str,
|
|
100
|
+
message: String,
|
|
101
|
+
value: Option<f64>,
|
|
102
|
+
limit: Option<f64>,
|
|
103
|
+
related_paths: Vec<String>,
|
|
104
|
+
violations: &mut Vec<QualityViolation>,
|
|
105
|
+
) {
|
|
106
|
+
if violations
|
|
107
|
+
.iter()
|
|
108
|
+
.any(|existing| existing.check_id == check_id && existing.path == path)
|
|
109
|
+
{
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
violations.push(QualityViolation {
|
|
113
|
+
check_id: check_id.to_string(),
|
|
114
|
+
severity: "blocking".to_string(),
|
|
115
|
+
path: path.to_string(),
|
|
116
|
+
line: None,
|
|
117
|
+
message: message.clone(),
|
|
118
|
+
value,
|
|
119
|
+
limit,
|
|
120
|
+
fingerprint: stable_fingerprint(&[check_id, path, &message]),
|
|
121
|
+
related_paths,
|
|
122
|
+
baseline: false,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
use crate::paths;
|
|
2
|
+
|
|
3
|
+
use crate::quality::structure::model::RepositoryStructureConfig;
|
|
4
|
+
|
|
5
|
+
pub fn role_for(path: &str, segments: &[String], config: &RepositoryStructureConfig) -> String {
|
|
6
|
+
let lower = path.to_ascii_lowercase();
|
|
7
|
+
if has_segment(segments, &["node_modules", "vendor", "third_party"]) {
|
|
8
|
+
return "dependency/vendor".to_string();
|
|
9
|
+
}
|
|
10
|
+
if paths::matches_any(path, &config.generated_roots)
|
|
11
|
+
|| has_segment(segments, &["generated", "__generated__", "codegen"])
|
|
12
|
+
{
|
|
13
|
+
return "generated".to_string();
|
|
14
|
+
}
|
|
15
|
+
if paths::matches_any(path, &config.artifact_roots) || is_artifact_path(&lower) {
|
|
16
|
+
return "artifact".to_string();
|
|
17
|
+
}
|
|
18
|
+
if paths::matches_any(path, &config.test_roots) || is_test_path(&lower, segments) {
|
|
19
|
+
return "test".to_string();
|
|
20
|
+
}
|
|
21
|
+
if paths::matches_any(path, &config.docs_roots) || is_docs_path(&lower, segments) {
|
|
22
|
+
return "docs".to_string();
|
|
23
|
+
}
|
|
24
|
+
if is_config_path(path, &lower, segments, config) {
|
|
25
|
+
return "config".to_string();
|
|
26
|
+
}
|
|
27
|
+
if is_script_path(&lower, segments) {
|
|
28
|
+
return "script".to_string();
|
|
29
|
+
}
|
|
30
|
+
if paths::matches_any(path, &config.source_roots) || language_for(path).is_some() {
|
|
31
|
+
return "source".to_string();
|
|
32
|
+
}
|
|
33
|
+
"unknown".to_string()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pub fn module_for(
|
|
37
|
+
path: &str,
|
|
38
|
+
segments: &[String],
|
|
39
|
+
config: &RepositoryStructureConfig,
|
|
40
|
+
) -> Option<String> {
|
|
41
|
+
for root in &config.module_roots {
|
|
42
|
+
if !paths::matches_any(path, &[root.clone()]) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if let Some(module) = module_from_wildcard_root(segments, root) {
|
|
46
|
+
return Some(module);
|
|
47
|
+
}
|
|
48
|
+
let root_prefix = root.trim_end_matches("/**");
|
|
49
|
+
let after_root = path.strip_prefix(root_prefix).unwrap_or(path);
|
|
50
|
+
let candidate = after_root.trim_start_matches('/').split('/').next()?;
|
|
51
|
+
if !candidate.is_empty() && candidate.contains('.') {
|
|
52
|
+
return stem(candidate);
|
|
53
|
+
}
|
|
54
|
+
if !candidate.is_empty() {
|
|
55
|
+
return Some(candidate.to_string());
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if segments.len() >= 3 && (segments[0] == "packages" || segments[0] == "crates") {
|
|
59
|
+
return Some(segments[1].clone());
|
|
60
|
+
}
|
|
61
|
+
segments
|
|
62
|
+
.iter()
|
|
63
|
+
.rev()
|
|
64
|
+
.nth(1)
|
|
65
|
+
.filter(|segment| !is_role_segment(segment))
|
|
66
|
+
.cloned()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fn module_from_wildcard_root(segments: &[String], root: &str) -> Option<String> {
|
|
70
|
+
let root_segments = root.trim_end_matches("/**").split('/').collect::<Vec<_>>();
|
|
71
|
+
for (index, segment) in root_segments.iter().enumerate() {
|
|
72
|
+
if *segment == "*" {
|
|
73
|
+
return segments
|
|
74
|
+
.get(index)
|
|
75
|
+
.filter(|segment| !segment.is_empty())
|
|
76
|
+
.cloned();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if root_segments.first() == Some(&"**") {
|
|
81
|
+
let anchor = root_segments
|
|
82
|
+
.iter()
|
|
83
|
+
.skip(1)
|
|
84
|
+
.find(|segment| !segment.contains('*'))?;
|
|
85
|
+
if let Some(src_index) = segments.iter().position(|segment| segment == anchor) {
|
|
86
|
+
return src_index
|
|
87
|
+
.checked_sub(1)
|
|
88
|
+
.and_then(|module_index| segments.get(module_index))
|
|
89
|
+
.filter(|segment| !segment.is_empty())
|
|
90
|
+
.cloned();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
None
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
pub fn layer_for(path: &str, role: &str, config: &RepositoryStructureConfig) -> String {
|
|
98
|
+
config
|
|
99
|
+
.layer_rules
|
|
100
|
+
.iter()
|
|
101
|
+
.find(|rule| paths::matches_any(path, &rule.paths))
|
|
102
|
+
.map(|rule| rule.layer.clone())
|
|
103
|
+
.unwrap_or_else(|| role.to_string())
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
pub fn language_for(path: &str) -> Option<String> {
|
|
107
|
+
let lower = path.to_ascii_lowercase();
|
|
108
|
+
let language = if lower.ends_with(".rs") {
|
|
109
|
+
"rust"
|
|
110
|
+
} else if lower.ends_with(".ts") || lower.ends_with(".tsx") {
|
|
111
|
+
"typescript"
|
|
112
|
+
} else if lower.ends_with(".js")
|
|
113
|
+
|| lower.ends_with(".jsx")
|
|
114
|
+
|| lower.ends_with(".mjs")
|
|
115
|
+
|| lower.ends_with(".cjs")
|
|
116
|
+
{
|
|
117
|
+
"javascript"
|
|
118
|
+
} else if lower.ends_with(".py") {
|
|
119
|
+
"python"
|
|
120
|
+
} else if lower.ends_with(".go") {
|
|
121
|
+
"go"
|
|
122
|
+
} else if lower.ends_with(".swift") {
|
|
123
|
+
"swift"
|
|
124
|
+
} else {
|
|
125
|
+
return None;
|
|
126
|
+
};
|
|
127
|
+
Some(language.to_string())
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
fn is_test_path(lower: &str, segments: &[String]) -> bool {
|
|
131
|
+
lower.contains(".test.")
|
|
132
|
+
|| lower.contains(".spec.")
|
|
133
|
+
|| lower.ends_with("_test.rs")
|
|
134
|
+
|| has_segment(segments, &["test", "tests", "__tests__"])
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fn is_docs_path(lower: &str, segments: &[String]) -> bool {
|
|
138
|
+
lower.ends_with(".md")
|
|
139
|
+
|| lower.ends_with(".mdx")
|
|
140
|
+
|| lower.ends_with(".rst")
|
|
141
|
+
|| has_segment(segments, &["docs", "doc"])
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fn is_config_path(
|
|
145
|
+
path: &str,
|
|
146
|
+
lower: &str,
|
|
147
|
+
segments: &[String],
|
|
148
|
+
config: &RepositoryStructureConfig,
|
|
149
|
+
) -> bool {
|
|
150
|
+
paths::matches_any(path, &config.allowed_root_files)
|
|
151
|
+
|| segments.len() == 1 && (lower.starts_with('.') || lower.ends_with(".toml"))
|
|
152
|
+
|| lower.ends_with(".json")
|
|
153
|
+
|| lower.ends_with(".yaml")
|
|
154
|
+
|| lower.ends_with(".yml")
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
fn is_script_path(lower: &str, segments: &[String]) -> bool {
|
|
158
|
+
has_segment(segments, &["scripts", "bin"]) || lower.ends_with(".sh") || lower.ends_with(".zsh")
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
fn is_artifact_path(lower: &str) -> bool {
|
|
162
|
+
lower.ends_with(".tmp")
|
|
163
|
+
|| lower.ends_with(".log")
|
|
164
|
+
|| lower.ends_with(".map")
|
|
165
|
+
|| lower.ends_with(".min.js")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
fn has_segment(segments: &[String], expected: &[&str]) -> bool {
|
|
169
|
+
segments.iter().any(|segment| {
|
|
170
|
+
let lower = segment.to_ascii_lowercase();
|
|
171
|
+
expected.iter().any(|value| *value == lower)
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
fn is_role_segment(segment: &str) -> bool {
|
|
176
|
+
matches!(
|
|
177
|
+
segment,
|
|
178
|
+
"src" | "test" | "tests" | "docs" | "doc" | "scripts" | "generated"
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
fn stem(file_name: &str) -> Option<String> {
|
|
183
|
+
file_name
|
|
184
|
+
.split('.')
|
|
185
|
+
.next()
|
|
186
|
+
.filter(|value| !value.is_empty())
|
|
187
|
+
.map(ToString::to_string)
|
|
188
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
mod roles;
|
|
2
|
+
|
|
3
|
+
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
|
4
|
+
|
|
5
|
+
use crate::paths;
|
|
6
|
+
|
|
7
|
+
use super::model::{
|
|
8
|
+
RepositoryStructureConfig, RepositoryStructureModel, StructureDirectory, StructurePath,
|
|
9
|
+
StructurePathExplanation,
|
|
10
|
+
};
|
|
11
|
+
use roles::{language_for, layer_for, module_for, role_for};
|
|
12
|
+
|
|
13
|
+
pub fn build_structure_model(
|
|
14
|
+
config: RepositoryStructureConfig,
|
|
15
|
+
repo_paths: &[String],
|
|
16
|
+
changed_paths: &[String],
|
|
17
|
+
baseline_fingerprints: &HashSet<String>,
|
|
18
|
+
) -> RepositoryStructureModel {
|
|
19
|
+
let changed = changed_paths.iter().cloned().collect::<HashSet<_>>();
|
|
20
|
+
let mut paths = repo_paths
|
|
21
|
+
.iter()
|
|
22
|
+
.filter(|path| !paths::matches_any(path, &config.ignored_paths))
|
|
23
|
+
.map(|path| classify_path(path, &config, changed.contains(path), baseline_fingerprints))
|
|
24
|
+
.collect::<Vec<_>>();
|
|
25
|
+
paths.sort_by(|left, right| left.explanation.path.cmp(&right.explanation.path));
|
|
26
|
+
let directories = directories_for(&paths);
|
|
27
|
+
RepositoryStructureModel {
|
|
28
|
+
config,
|
|
29
|
+
paths,
|
|
30
|
+
directories,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fn classify_path(
|
|
35
|
+
path: &str,
|
|
36
|
+
config: &RepositoryStructureConfig,
|
|
37
|
+
changed: bool,
|
|
38
|
+
_baseline_fingerprints: &HashSet<String>,
|
|
39
|
+
) -> StructurePath {
|
|
40
|
+
let segments = path.split('/').map(ToString::to_string).collect::<Vec<_>>();
|
|
41
|
+
let directory = directory_of(path);
|
|
42
|
+
let role = role_for(path, &segments, config);
|
|
43
|
+
let module = module_for(path, &segments, config);
|
|
44
|
+
let language = language_for(path);
|
|
45
|
+
let layer = layer_for(path, &role, config);
|
|
46
|
+
let generated = role == "generated";
|
|
47
|
+
StructurePath {
|
|
48
|
+
explanation: StructurePathExplanation {
|
|
49
|
+
path: path.to_string(),
|
|
50
|
+
role,
|
|
51
|
+
module,
|
|
52
|
+
layer,
|
|
53
|
+
language,
|
|
54
|
+
generated,
|
|
55
|
+
debt: false,
|
|
56
|
+
changed,
|
|
57
|
+
directory,
|
|
58
|
+
},
|
|
59
|
+
segments,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
fn directories_for(paths: &[StructurePath]) -> Vec<StructureDirectory> {
|
|
64
|
+
let mut dirs: BTreeMap<String, StructureDirectory> = BTreeMap::new();
|
|
65
|
+
for path in paths {
|
|
66
|
+
let dir = path.explanation.directory.clone();
|
|
67
|
+
let entry = dirs
|
|
68
|
+
.entry(dir.clone())
|
|
69
|
+
.or_insert_with(|| StructureDirectory {
|
|
70
|
+
path: dir,
|
|
71
|
+
roles: BTreeSet::new(),
|
|
72
|
+
modules: BTreeSet::new(),
|
|
73
|
+
file_count: 0,
|
|
74
|
+
direct_changed_paths: Vec::new(),
|
|
75
|
+
});
|
|
76
|
+
entry.file_count += 1;
|
|
77
|
+
entry.roles.insert(path.explanation.role.clone());
|
|
78
|
+
if let Some(module) = &path.explanation.module {
|
|
79
|
+
entry.modules.insert(module.clone());
|
|
80
|
+
}
|
|
81
|
+
if path.explanation.changed {
|
|
82
|
+
entry
|
|
83
|
+
.direct_changed_paths
|
|
84
|
+
.push(path.explanation.path.clone());
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
dirs.into_values().collect()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fn directory_of(path: &str) -> String {
|
|
91
|
+
path.rsplit_once('/')
|
|
92
|
+
.map(|(directory, _)| directory.to_string())
|
|
93
|
+
.unwrap_or_else(|| ".".to_string())
|
|
94
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
use std::fs;
|
|
2
|
+
use std::path::Path;
|
|
3
|
+
|
|
4
|
+
use crate::models::NaomeError;
|
|
5
|
+
|
|
6
|
+
use super::adapters::{
|
|
7
|
+
apply_structure_adapters, detected_structure_adapter_ids, validate_structure_adapter_ids,
|
|
8
|
+
};
|
|
9
|
+
use crate::quality::config_support::validate_ready_schema;
|
|
10
|
+
|
|
11
|
+
use super::model::RepositoryStructureConfig;
|
|
12
|
+
|
|
13
|
+
const CONFIG_RELATIVE_PATH: &str = ".naome/repository-structure.json";
|
|
14
|
+
|
|
15
|
+
pub fn structure_config_relative_path() -> &'static str {
|
|
16
|
+
CONFIG_RELATIVE_PATH
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
pub fn read_structure_config(
|
|
20
|
+
root: &Path,
|
|
21
|
+
paths: &[String],
|
|
22
|
+
) -> Result<RepositoryStructureConfig, NaomeError> {
|
|
23
|
+
let path = root.join(CONFIG_RELATIVE_PATH);
|
|
24
|
+
let config = if path.is_file() {
|
|
25
|
+
serde_json::from_str(&fs::read_to_string(path)?)?
|
|
26
|
+
} else {
|
|
27
|
+
generated_structure_config(paths)
|
|
28
|
+
};
|
|
29
|
+
validate_structure_config(&config)?;
|
|
30
|
+
apply_structure_adapters(config)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
pub fn write_default_structure_config_if_missing(
|
|
34
|
+
root: &Path,
|
|
35
|
+
paths: &[String],
|
|
36
|
+
) -> Result<bool, NaomeError> {
|
|
37
|
+
let path = root.join(CONFIG_RELATIVE_PATH);
|
|
38
|
+
if path.exists() {
|
|
39
|
+
return Ok(false);
|
|
40
|
+
}
|
|
41
|
+
if let Some(parent) = path.parent() {
|
|
42
|
+
fs::create_dir_all(parent)?;
|
|
43
|
+
}
|
|
44
|
+
let content = serde_json::to_string_pretty(&generated_structure_config(paths))?;
|
|
45
|
+
fs::write(path, format!("{content}\n"))?;
|
|
46
|
+
Ok(true)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fn generated_structure_config(paths: &[String]) -> RepositoryStructureConfig {
|
|
50
|
+
let mut config = RepositoryStructureConfig::default();
|
|
51
|
+
config.enabled_adapters = detected_structure_adapter_ids(paths);
|
|
52
|
+
config
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fn validate_structure_config(config: &RepositoryStructureConfig) -> Result<(), NaomeError> {
|
|
56
|
+
validate_ready_schema(
|
|
57
|
+
CONFIG_RELATIVE_PATH,
|
|
58
|
+
&config.schema,
|
|
59
|
+
"naome.repository-structure.v1",
|
|
60
|
+
config.version,
|
|
61
|
+
&config.status,
|
|
62
|
+
)?;
|
|
63
|
+
validate_structure_adapter_ids(&config.enabled_adapters)?;
|
|
64
|
+
if config.limits.max_directory_files == 0 {
|
|
65
|
+
return Err(NaomeError::new(
|
|
66
|
+
".naome/repository-structure.json maxDirectoryFiles must be greater than 0.",
|
|
67
|
+
));
|
|
68
|
+
}
|
|
69
|
+
if config.limits.max_path_depth == 0 {
|
|
70
|
+
return Err(NaomeError::new(
|
|
71
|
+
".naome/repository-structure.json maxPathDepth must be greater than 0.",
|
|
72
|
+
));
|
|
73
|
+
}
|
|
74
|
+
for rule in &config.directory_role_rules {
|
|
75
|
+
if rule.id.trim().is_empty() || rule.paths.is_empty() {
|
|
76
|
+
return Err(NaomeError::new(
|
|
77
|
+
".naome/repository-structure.json directoryRoleRules require id and paths.",
|
|
78
|
+
));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for rule in &config.layer_rules {
|
|
82
|
+
if rule.id.trim().is_empty() || rule.paths.is_empty() || rule.layer.trim().is_empty() {
|
|
83
|
+
return Err(NaomeError::new(
|
|
84
|
+
".naome/repository-structure.json layerRules require id, paths, and layer.",
|
|
85
|
+
));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
Ok(())
|
|
89
|
+
}
|