Skip to content

Commit 40eff78

Browse files
montfortclaude
andauthored
feat(architecture): language/structure-agnostic, configurable seed scanner (#279) (#282)
The `architecture generate` seed was tuned to Go/Rust/JS: a 17-extension allowlist (Ruby/Elixir/Scala/… → empty map) and a fixed container-dir set (Java/Maven `src/main/java/…` collapsed the whole project into one `main` box). Move the source-file scan + component-dir shaping into one `straymark-core::architecture::scan` module driven by a `ScanConfig`, resolved from an optional `architecture:` section in `.straymark/config.yml` (additive over built-in defaults): - source_extensions — recognize ~30 languages by default (Ruby, Elixir, Scala, Dart, Clojure, Vue, Svelte, Lua, Julia, Haskell, Erlang, OCaml, …); extend per project. - scaffolding_prefixes — skip build scaffolding (src/main/java, src/main/kotlin, …) so a Maven/Gradle module is attributed to its MODULE dir, not collapsed to `main` (multi-module → one box per module). Verified: billing/src/main/java/… → `billing`. - container_dirs / excluded_dirs — extend the descent/skip sets per project. Defaults unchanged for Go/Rust/JS (Sentinel still seeds 14 components). The status overlay was already language-agnostic; this only shapes the seed, which the human/agent then refines. core → 0.7.0 (additive public `scan` API), documented in CLI-REFERENCE (EN/es/zh-CN). Closes #279. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent eea2cf6 commit 40eff78

11 files changed

Lines changed: 386 additions & 84 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,28 @@ and this project uses [independent versioning](README.md#versioning) for Framewo
77

88
---
99

10+
## [Unreleased]
11+
12+
### Changed (architecture scanner — language/structure-agnostic, #279, EXPERIMENTAL)
13+
14+
- **`architecture generate` is now configurable and ecosystem-aware.** The source-file scan and
15+
the component-dir shaping moved to a single `straymark-core::architecture::scan` module driven
16+
by a `ScanConfig`, resolved from an optional **`architecture:` section in
17+
`.straymark/config.yml`** (additive over built-in defaults):
18+
- `source_extensions` — the seed now recognizes ~30 languages out of the box (added Ruby,
19+
Elixir, Scala, Dart, Clojure, Vue, Svelte, Lua, Julia, Haskell, Erlang, OCaml, …); a project
20+
in a non-default language is no longer an empty map, and adopters can add more.
21+
- `scaffolding_prefixes` — build scaffolding like `src/main/java` / `src/main/kotlin` is skipped,
22+
so a **Maven/Gradle** module is attributed to its module directory instead of collapsing the
23+
whole project into a single `main` box (multi-module → one component per module).
24+
- `container_dirs` / `excluded_dirs` — extend the descent/skip sets per project.
25+
- Defaults are unchanged for existing Go/Rust/JS layouts (Sentinel still seeds the same 14
26+
components); the status overlay was already language-agnostic — this only shapes the seed.
27+
- `straymark-core`**0.7.0** (additive: new public `scan` API). Publishes with the next `cli-`
28+
release.
29+
30+
---
31+
1032
## Framework 4.27.0 / CLI 3.26.0 / Core 0.6.0 — 2026-06-16
1133

1234
Audit-prompt hardening (#261), architecture-model DX + a meaningful `has-debt` overlay (#273),

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ name = "straymark"
1717
path = "src/main.rs"
1818

1919
[dependencies]
20-
straymark-core = { version = "0.6.0", path = "../core" }
20+
straymark-core = { version = "0.7.0", path = "../core" }
2121
clap = { version = "4", features = ["derive"] }
2222
reqwest = { version = "0.12", features = ["blocking", "rustls-tls", "json"] }
2323
serde = { version = "1", features = ["derive"] }

cli/src/commands/architecture/common.rs

Lines changed: 19 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,20 @@
77
use std::collections::{BTreeMap, BTreeSet};
88
use std::path::{Path, PathBuf};
99

10-
use straymark_core::architecture::{ArchModel, Component, Layer};
10+
use straymark_core::architecture::{
11+
collect_source_files_with, component_dir_for, resolve_scan_config, ArchModel, Component, Layer,
12+
};
1113
use straymark_core::document::{detect_doc_type, discover_documents, DocType};
1214
use straymark_core::drift::glob_match;
1315

1416
use super::adr_mining;
1517
use crate::utils;
1618

17-
// The source-file walker (`collect_source_files` + its `EXCLUDED_DIRS`/
18-
// `SOURCE_EXTENSIONS`) moved to `core::architecture::gather` in Loom A2.0 so the
19-
// CLI generator and the Loom server share one inventory scan. Re-exported so the
20-
// `common::collect_source_files` call sites (generate/sync/validate) are
21-
// unchanged.
19+
// The source-file walker + the component-dir shaping (extensions, excluded dirs,
20+
// container descent, build-scaffolding) live in `core::architecture::{gather,
21+
// scan}` (Loom A2.0; made config-driven in #279) so the CLI generator and the
22+
// Loom server share one inventory scan. Re-exported so the
23+
// `common::collect_source_files` call sites (validate/sync) are unchanged.
2224
pub(crate) use straymark_core::architecture::collect_source_files;
2325

2426
/// The placeholder layer code-derived components land in until the human
@@ -65,49 +67,23 @@ pub(crate) fn artifact_paths(root: &Path, out: Option<&str>) -> (PathBuf, PathBu
6567
(out_dir, model, drawio)
6668
}
6769

68-
/// Organizational directories that hold *other* components rather than being
69-
/// one themselves — the seed descends through them one level so an `internal/`
70-
/// or `src/` tree breaks out into real components instead of one giant blob
71-
/// (#273). A leaf like `cmd/` or `db/` is not a container and stays whole.
72-
pub(crate) const CONTAINER_DIRS: &[&str] =
73-
&["internal", "src", "pkg", "lib", "libs", "app", "apps", "modules", "packages"];
74-
75-
/// Distinct component directories under `root` that contain ≥1 source file. A
76-
/// component dir is the path up to and including the first **non-container**
77-
/// segment, so `internal/modules/agents/x.go` → `internal/modules/agents`,
78-
/// `internal/core/bus.go` → `internal/core`, and `cmd/main.go` → `cmd`.
70+
/// Distinct component directories under `root` that contain ≥1 source file,
71+
/// shaped by the project's [`ScanConfig`] (#273, #279): the seed descends
72+
/// through *container* dirs and skips build *scaffolding* (e.g. Maven
73+
/// `src/main/java`), so `internal/modules/agents/x.go` → `internal/modules/agents`,
74+
/// `cmd/main.go` → `cmd`, and `billing/src/main/java/…` → `billing`. The config
75+
/// is resolved once and reused for the file walk.
7976
pub(crate) fn source_component_dirs(root: &Path) -> Vec<String> {
77+
let cfg = resolve_scan_config(root);
8078
let mut dirs: BTreeSet<String> = BTreeSet::new();
81-
for rel in collect_source_files(root) {
82-
if let Some(dir) = component_dir_for(&rel) {
79+
for rel in collect_source_files_with(root, &cfg) {
80+
if let Some(dir) = component_dir_for(&rel, &cfg) {
8381
dirs.insert(dir);
8482
}
8583
}
8684
dirs.into_iter().collect()
8785
}
8886

89-
/// The component directory a source file belongs to: descend through container
90-
/// segments, stop at (and include) the first non-container one. Returns `None`
91-
/// for a root-level file (no directory to attribute it to).
92-
fn component_dir_for(rel: &Path) -> Option<String> {
93-
let segs: Vec<String> = rel
94-
.parent()?
95-
.components()
96-
.map(|c| c.as_os_str().to_string_lossy().to_string())
97-
.collect();
98-
if segs.is_empty() {
99-
return None;
100-
}
101-
let mut taken: Vec<&str> = Vec::new();
102-
for seg in &segs {
103-
taken.push(seg);
104-
if !CONTAINER_DIRS.contains(&seg.as_str()) {
105-
break;
106-
}
107-
}
108-
Some(taken.join("/"))
109-
}
110-
11187
/// A component proposed from a source dir (`dir/**`, `unassigned`). The id is
11288
/// the kebab of the full path (`internal/core` → `internal-core`, unique across
11389
/// containers); the label is the title-cased leaf segment.
@@ -456,22 +432,8 @@ mod tests {
456432
assert_eq!(c.globs, vec!["internal/modules/agents/**"]);
457433
}
458434

459-
#[test]
460-
fn component_dir_descends_through_container_dirs() {
461-
// #273: a container tree breaks out; a leaf dir stays whole.
462-
let cases = [
463-
("internal/modules/agents/runner.go", Some("internal/modules/agents")),
464-
("internal/core/eventbus/bus.go", Some("internal/core")),
465-
("cmd/server/main.go", Some("cmd")),
466-
("db/seeds/seeds.go", Some("db")),
467-
("src/index.ts", Some("src")), // files directly under a container → the container
468-
("main.go", None), // root-level file: no dir to attribute
469-
];
470-
for (path, want) in cases {
471-
let got = component_dir_for(Path::new(path));
472-
assert_eq!(got.as_deref(), want, "for {path}");
473-
}
474-
}
435+
// (component-dir descent + Maven scaffolding are tested in
436+
// `core::architecture::scan` — the logic moved there in #279.)
475437

476438
#[test]
477439
fn generated_model_passes_core_validation() {

core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "straymark-core"
3-
version = "0.6.0"
3+
version = "0.7.0"
44
edition = "2021"
55
description = "Shared document model and knowledge graph for StrayMark — parses governance documents and builds their typed traceability graph"
66
license = "MIT"

core/src/architecture/gather.rs

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,10 @@ use crate::ailog;
1919
use crate::architecture::projection::GovernanceState;
2020
use crate::charter::{self, Charter, CharterStatus};
2121
use crate::charter_files::parse_files_to_modify;
22+
use crate::architecture::scan::{resolve_scan_config, ScanConfig};
2223
use crate::document::{detect_doc_type, discover_documents, parse_document, DocType};
2324
use crate::drift::compute_drift;
2425

25-
/// Directories never scanned for source (build output, VCS, deps, docs).
26-
pub const EXCLUDED_DIRS: &[&str] = &[
27-
".straymark", ".git", "node_modules", "target", "vendor", "dist", "build",
28-
".venv", "__pycache__", ".github", "docs",
29-
];
30-
31-
/// Extensions that mark a file as source worth counting in the inventory.
32-
pub const SOURCE_EXTENSIONS: &[&str] = &[
33-
"rs", "py", "js", "ts", "jsx", "tsx", "java", "go", "cs", "cpp", "cc",
34-
"cxx", "c", "h", "php", "kt", "swift",
35-
];
36-
3726
/// Gather the governance-derived file sets (Spec 002 §4) the pure projection
3827
/// consumes. All impure work (git, fs walk, document parsing) lives here; the
3928
/// projection itself ([`super::projection::project`]) stays pure.
@@ -84,27 +73,34 @@ pub fn build_governance_state(root: &Path) -> GovernanceState {
8473
}
8574
}
8675

87-
/// Source files (paths relative to `root`), skipping [`EXCLUDED_DIRS`]. Used for
88-
/// the on-disk inventory (the `uncharted` signal) and by the CLI generator to
89-
/// discover top-level component directories.
76+
/// Source files (paths relative to `root`), filtered by the project's
77+
/// [`ScanConfig`] (source extensions + excluded dirs, defaults ∪ the
78+
/// `architecture:` config section, #279). Used for the on-disk inventory (the
79+
/// `uncharted` signal) and by the CLI generator to discover component dirs.
9080
pub fn collect_source_files(root: &Path) -> Vec<PathBuf> {
91-
fn walk(dir: &Path, root: &Path, out: &mut Vec<PathBuf>) {
81+
collect_source_files_with(root, &resolve_scan_config(root))
82+
}
83+
84+
/// [`collect_source_files`] with an explicit config (so a caller that already
85+
/// resolved one — e.g. the generator computing component dirs — reuses it).
86+
pub fn collect_source_files_with(root: &Path, cfg: &ScanConfig) -> Vec<PathBuf> {
87+
fn walk(dir: &Path, root: &Path, cfg: &ScanConfig, out: &mut Vec<PathBuf>) {
9288
let Ok(entries) = std::fs::read_dir(dir) else {
9389
return;
9490
};
9591
for entry in entries.flatten() {
9692
let path = entry.path();
9793
if path.is_dir() {
9894
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
99-
if !EXCLUDED_DIRS.contains(&name) {
100-
walk(&path, root, out);
95+
if !cfg.is_excluded_dir(name) {
96+
walk(&path, root, cfg, out);
10197
}
10298
}
10399
} else if path.is_file() {
104100
let is_source = path
105101
.extension()
106102
.and_then(|e| e.to_str())
107-
.is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext));
103+
.is_some_and(|ext| cfg.is_source_ext(ext));
108104
if is_source {
109105
if let Ok(rel) = path.strip_prefix(root) {
110106
out.push(rel.to_path_buf());
@@ -114,7 +110,7 @@ pub fn collect_source_files(root: &Path) -> Vec<PathBuf> {
114110
}
115111
}
116112
let mut out = Vec::new();
117-
walk(root, root, &mut out);
113+
walk(root, root, cfg, &mut out);
118114
out
119115
}
120116

core/src/architecture/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
pub mod gather;
1313
pub mod model;
1414
pub mod projection;
15+
pub mod scan;
1516

16-
pub use gather::{build_governance_state, collect_source_files};
17+
pub use gather::{build_governance_state, collect_source_files, collect_source_files_with};
18+
pub use scan::{component_dir_for, resolve_scan_config, ScanConfig};
1719
pub use model::{parse_model, parse_model_str, validate_structure, ArchModel, Component, Layer, ModelIssue};
1820
pub use projection::{
1921
project, validate_model, ComponentProjection, ComponentState, GovernanceState,

0 commit comments

Comments
 (0)