Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
id: AILOG-2026-09-18-001
title: core nested-checkout rule — every project-wide walker skips other checkouts (#434 follow-up)
status: accepted
created: 2026-09-18
agent: claude-opus-5-1m
confidence: high
review_required: false
risk_level: low
eu_ai_act_risk: not_applicable
nist_genai_risks: [information_integrity]
iso_42001_clause: []
files_modified:
- core/src/walk.rs
- core/src/lib.rs
- core/src/architecture/gather.rs
- cli/src/tree_grep.rs
- cli/src/analysis_engine.rs
- experiment-baton/src/scan.rs
- core/Cargo.toml, cli/Cargo.toml, experiment-loom/Cargo.toml, experiment-baton/Cargo.toml, Cargo.lock
- CHANGELOG.md, experiment-loom/CHANGELOG.md, version tables x6
observability_scope: none
tags: [core, architecture, loom, baton, adopter-feedback]
related:
- 07-ai-audit/agent-logs/AILOG-2026-09-17-002-baton-inventory-hygiene.md
---

# AILOG: core nested-checkout rule (#434 follow-up)

## Summary

#435 taught Baton's walkers to skip another checkout nested in the project. The same blind spot
existed in `straymark-core`'s source scanner and in two CLI walkers. The rule now lives once, in
`core::walk::is_nested_checkout`, and all of them use it. Released as core 0.10.1, CLI 3.49.2 and
Loom 0.7.1.

## Context

Demonstrated on an adopter repository with one linked worktree under `.worktrees/`. `straymark
architecture generate`, writing to a scratch `--out`, produced 15 components, one of them
`.worktrees` (glob `.worktrees/**`): a full copy of the repo, 5606 source files against 5235 in the
real tree. With this change the same command produces 14 components and no `.worktrees`. The
repository was read-only in both runs (git status unchanged).

## Actions Performed

1. `core/src/walk.rs`: `pub fn is_nested_checkout(dir)`. A sub-directory with its own `.git` (a
file for a linked worktree or submodule, a directory for a nested clone) is another checkout.
2. `core::architecture::collect_source_files_with` skips such directories. This covers
`architecture generate | sync | validate`, `status --where`, Loom's architecture view and
Intent plane, and Baton's overlay.
3. CLI: `tree_grep::walk` (used by `followups verify --claims` and `analyze declared-vs-wired`) and
`analysis_engine::walk_recursive` (`analyze`) use the same rule.
4. Baton: `scan::is_nested_checkout` is now a re-export of the `core` rule. Its behavior is
unchanged.
5. Tests: `core::walk` itself, and one per walker. Each walker test uses a directory name that is
not already excluded (the first `analyze` fixture used `vendor/`, which `analyze` already
excludes, so it passed without the fix; it was moved to `third_party/`). All three walker tests
fail with the guard removed.
6. Versions move atomically: core 0.10.0 → 0.10.1, and the `straymark-core` bound moves in the
CLI, Loom **and** Baton Cargo.toml, per the lesson from #282. CLI 3.49.2, Loom 0.7.1, CHANGELOG
and Loom CHANGELOG.

## Decisions Made

- **One rule, in `core`.** Walkers keep their own name lists, because those differ by purpose, but
the "another checkout" rule is shared so that no tool can disagree with another about what the
project contains.
- **Patch versions.** The change adds an API to core and fixes behavior; no public API breaks.
- Baton is not re-released: its behavior is identical, and the next Baton release will pick up the
new dependency bound.

## Impact

- **Functionality**: architecture models, `status --where`, Loom and `analyze` / `verify --claims`
no longer read another checkout as the project's code.
- **Performance**: one `stat` per visited directory. Negligible.
- **Security / Privacy / Environmental**: N/A.

## Verification

- [x] `cargo test --workspace`: 1041 passed
- [x] Clippy: core and Baton clean with `-D warnings`; Loom clean; CLI unchanged from the baseline
(18/24)
- [x] Before/after `architecture generate` on the adopter repository, read-only
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project uses [independent versioning](README.md#versioning) for Framewo

---

## CLI 3.49.2 / core 0.10.1 / Loom 0.7.1 — 2026-09-18

Follow-up to #434, which fixed the Baton half. The shared source scanner in `straymark-core` and two CLI walkers also descended into **another checkout nested in the project**: a linked git worktree such as `.worktrees/<name>/`, a submodule or a nested clone. Each read that copy as the project's own code. On an adopter's repository with one worktree inside, `straymark architecture generate` invented a `.worktrees` component holding a full copy of the repo.

### Fixed (CLI / core / Loom)

- **`straymark-core` 0.10.1**: new `walk::is_nested_checkout`, the one rule every project-wide walker shares. A sub-directory with its own `.git` (file or directory) is another checkout and is never walked. It is structural, so no gitignore parsing and nothing to configure. `architecture::collect_source_files` uses it, which covers:
- `straymark architecture generate | sync | validate` and `status --where`;
- Loom's architecture view and Intent plane (**Loom 0.7.1**, rebuilt);
- Baton's overlay.
- **CLI 3.49.2**: `tree_grep` (behind `followups verify --claims` and `analyze declared-vs-wired`) and the `analyze` complexity walker use the same rule. A worktree copy no longer counts as "callers" of a symbol, and its functions are no longer analyzed twice.
- Baton now reuses the `core` rule instead of its own copy. Its behavior is unchanged.

Until upgraded, the workaround is `architecture: { excluded_dirs: [".worktrees"] }` in `.straymark/config.yml`, which covers only the architecture scanner and only that directory name.

## Baton 0.3.0 — 2026-09-17

First Baton release driven by an adopter's Track C work (Estoa, Discussion #426). Before it, the declared-verb router (#332) classified only units with their own slot: every SpecKit task and every batch was `undeclared`, and the inventory counted work that was not live. Experimental; distributed as `baton-*` GitHub releases (`--latest=false`).
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ StrayMark uses independent version tags for each component:
| Component | Tag prefix | Example | Includes |
| --- | --- | --- | --- |
| Framework | `fw-` | `fw-4.45.0` | Templates (12 types), governance, directives, Charter template + schema |
| CLI | `cli-` | `cli-3.49.1` | The `straymark` binary |
| CLI | `cli-` | `cli-3.49.2` | The `straymark` binary |
| Loom (EXPERIMENTAL) | `loom-` | `loom-0.4.2` | The `straymark-loom` visualization server, downloaded on demand by `straymark loom serve` |

Check installed versions with `straymark status` or `straymark about`.
Expand Down
4 changes: 2 additions & 2 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "straymark-cli"
version = "3.49.1"
version = "3.49.2"
edition = "2021"
description = "CLI for StrayMark — the cognitive discipline your AI-assisted projects need"
license = "MIT"
Expand All @@ -25,7 +25,7 @@ path = "src/bin/gen_minimal_skills.rs"
required-features = ["dev-tools"]

[dependencies]
straymark-core = { version = "0.10.0", path = "../core" }
straymark-core = { version = "0.10.1", path = "../core" }
clap = { version = "4", features = ["derive"] }
reqwest = { version = "0.12", features = ["blocking", "rustls-tls", "json"] }
serde = { version = "1", features = ["derive"] }
Expand Down
18 changes: 17 additions & 1 deletion cli/src/analysis_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ fn walk_recursive(dir: &Path, files: &mut Vec<PathBuf>) {
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if !EXCLUDED_DIRS.contains(&name) {
if !EXCLUDED_DIRS.contains(&name)
&& !straymark_core::walk::is_nested_checkout(&path)
{
walk_recursive(&path, files);
}
}
Expand Down Expand Up @@ -206,6 +208,20 @@ mod tests {
assert!(files[0].ends_with("main.rs"));
}

#[test]
fn test_walk_skips_nested_checkouts() {
// #434: functions in a worktree copy are not this project's functions.
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("main.rs"), "fn included() {}").unwrap();
let clone = dir.path().join("third_party/clone");
fs::create_dir_all(clone.join(".git")).unwrap();
fs::write(clone.join("lib.rs"), "fn duplicated() {}").unwrap();

let files = walk_source_files(dir.path());
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("main.rs"));
}

#[test]
fn test_single_rust_file() {
let dir = TempDir::new().unwrap();
Expand Down
18 changes: 17 additions & 1 deletion cli/src/tree_grep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ fn walk(base: &Path, dir: &Path, out: &mut Vec<TextFile>) {
let path = entry.path();
if file_type.is_dir() {
let name = entry.file_name();
if EXCLUDED_DIRS.contains(&name.to_string_lossy().as_ref()) {
if EXCLUDED_DIRS.contains(&name.to_string_lossy().as_ref())
|| straymark_core::walk::is_nested_checkout(&path)
{
continue;
}
walk(base, &path, out);
Expand Down Expand Up @@ -156,6 +158,20 @@ mod tests {
assert_eq!(tree[0].rel_path, "main.rs");
}

#[test]
fn read_text_tree_skips_nested_checkouts() {
// #434: a worktree copy would count its symbols as the project's own.
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("main.rs"), "fn main() {}\n").unwrap();
let worktree = tmp.path().join(".worktrees/feature");
std::fs::create_dir_all(&worktree).unwrap();
std::fs::write(worktree.join(".git"), "gitdir: /elsewhere\n").unwrap();
std::fs::write(worktree.join("main.rs"), "fn main() {}\n").unwrap();
let tree = read_text_tree(tmp.path());
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].rel_path, "main.rs");
}

#[test]
fn symbol_occurrences_respects_word_boundaries() {
let tmp = tempfile::TempDir::new().unwrap();
Expand Down
2 changes: 1 addition & 1 deletion core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "straymark-core"
version = "0.10.0"
version = "0.10.1"
edition = "2021"
description = "Shared document model and knowledge graph for StrayMark — parses governance documents and builds their typed traceability graph"
license = "MIT"
Expand Down
25 changes: 23 additions & 2 deletions core/src/architecture/gather.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ pub fn build_governance_state(root: &Path) -> GovernanceState {

/// Source files (paths relative to `root`), filtered by the project's
/// [`ScanConfig`] (source extensions + excluded dirs, defaults ∪ the
/// `architecture:` config section, #279). Used for the on-disk inventory (the
/// `architecture:` config section, #279). Never descends into another
/// checkout nested in the project ([`crate::walk::is_nested_checkout`], #434). Used for the on-disk inventory (the
/// `uncharted` signal) and by the CLI generator to discover component dirs.
pub fn collect_source_files(root: &Path) -> Vec<PathBuf> {
collect_source_files_with(root, &resolve_scan_config(root))
Expand All @@ -94,7 +95,7 @@ pub fn collect_source_files_with(root: &Path, cfg: &ScanConfig) -> Vec<PathBuf>
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if !cfg.is_excluded_dir(name) {
if !cfg.is_excluded_dir(name) && !crate::walk::is_nested_checkout(&path) {
walk(&path, root, cfg, out);
}
}
Expand Down Expand Up @@ -309,6 +310,26 @@ mod tests {
assert_eq!(files, vec!["src/main.rs"]); // README.md (not source), target/, node_modules/ excluded
}

#[test]
fn collect_source_files_skips_nested_checkouts() {
// #434: a linked worktree (`.git` file) and a nested clone (`.git`
// dir) are other checkouts, not this project's source.
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
for d in ["src", ".worktrees/feature/src", "vendor/clone/src"] {
std::fs::create_dir_all(root.join(d)).unwrap();
std::fs::write(root.join(d).join("main.rs"), "fn main() {}\n").unwrap();
}
std::fs::write(root.join(".worktrees/feature/.git"), "gitdir: /elsewhere\n").unwrap();
std::fs::create_dir(root.join("vendor/clone/.git")).unwrap();

let files: Vec<String> = collect_source_files(root)
.iter()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.collect();
assert_eq!(files, vec!["src/main.rs"]);
}

#[test]
fn build_state_empty_on_bare_dir() {
// No .straymark, no git, no charters → every set empty, no panic.
Expand Down
1 change: 1 addition & 0 deletions core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ pub mod drift;
pub mod entities;
pub mod graph;
pub mod utils;
pub mod walk;
38 changes: 38 additions & 0 deletions core/src/walk.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//! The one rule every project-wide directory walker shares.
//!
//! Walkers skip a fixed list of names (`target`, `node_modules`, …), but a
//! project can also hold *another checkout of itself*: a linked git worktree
//! under `.worktrees/`, a submodule, a nested clone. Reading it duplicates the
//! project — `architecture generate` invented a `.worktrees` component holding
//! a full copy of an adopter's repo (#434). The marker is structural, so no
//! gitignore parsing and nothing for an adopter to configure.

use std::path::Path;

/// True when `dir` is the root of another checkout: a linked worktree or a
/// submodule (a `.git` *file*) or a nested clone (a `.git` directory). Call it
/// on sub-directories only — the project root itself has a `.git`.
pub fn is_nested_checkout(dir: &Path) -> bool {
dir.join(".git").exists()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn git_file_or_dir_marks_a_nested_checkout() {
let tmp = tempfile::tempdir().unwrap();
let worktree = tmp.path().join(".worktrees/feature");
let clone = tmp.path().join("vendor/clone");
let plain = tmp.path().join("src");
for d in [&worktree, &clone, &plain] {
std::fs::create_dir_all(d).unwrap();
}
std::fs::write(worktree.join(".git"), "gitdir: /elsewhere\n").unwrap();
std::fs::create_dir(clone.join(".git")).unwrap();
assert!(is_nested_checkout(&worktree));
assert!(is_nested_checkout(&clone));
assert!(!is_nested_checkout(&plain));
}
}
2 changes: 1 addition & 1 deletion docs/adopters/CLI-REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ StrayMark uses **independent version tags** for each component:
| Component | Tag prefix | Example | What it includes |
|-----------|-----------|---------|------------------|
| Framework | `fw-` | `fw-4.45.0` | Templates (12 types), governance docs, directives, Charter template + schema |
| CLI | `cli-` | `cli-3.49.1` | The `straymark` binary |
| CLI | `cli-` | `cli-3.49.2` | The `straymark` binary |
| Loom (EXPERIMENTAL) | `loom-` | `loom-0.4.2` | The `straymark-loom` visualization server, downloaded on demand by `straymark loom serve` |

Framework and CLI are released independently. A framework update does not require a CLI update, and vice versa.
Expand Down
2 changes: 1 addition & 1 deletion docs/i18n/es/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ StrayMark usa tags de versión independientes para cada componente:
| Componente | Prefijo de tag | Ejemplo | Incluye |
|------------|---------------|---------|---------|
| Framework | `fw-` | `fw-4.45.0` | Plantillas (12 tipos), gobernanza, directivas, plantilla + schema de Charter |
| CLI | `cli-` | `cli-3.49.1` | El binario `straymark` |
| CLI | `cli-` | `cli-3.49.2` | El binario `straymark` |
| Loom (EXPERIMENTAL) | `loom-` | `loom-0.4.2` | El servidor de visualización `straymark-loom`, descargado bajo demanda por `straymark loom serve` |

Verifica las versiones instaladas con `straymark status` o `straymark about`.
Expand Down
2 changes: 1 addition & 1 deletion docs/i18n/es/adopters/CLI-REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ StrayMark usa **tags de versión independientes** para cada componente:
| Componente | Prefijo de tag | Ejemplo | Qué incluye |
|------------|---------------|---------|-------------|
| Framework | `fw-` | `fw-4.45.0` | Plantillas (12 tipos), docs de gobernanza, directivas |
| CLI | `cli-` | `cli-3.49.1` | El binario `straymark` |
| CLI | `cli-` | `cli-3.49.2` | El binario `straymark` |
| Loom (EXPERIMENTAL) | `loom-` | `loom-0.4.2` | El servidor de visualización `straymark-loom`, descargado bajo demanda por `straymark loom serve` |

Framework y CLI se publican de forma independiente. Una actualización del framework no requiere actualización del CLI, y viceversa.
Expand Down
2 changes: 1 addition & 1 deletion docs/i18n/zh-CN/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ StrayMark 为每个组件使用独立的版本标签:
| 组件 | 标签前缀 | 示例 | 包含内容 |
|------|----------|------|----------|
| Framework | `fw-` | `fw-4.45.0` | 模板(12 种类型)、治理文档、指令、Charter 模板 + schema |
| CLI | `cli-` | `cli-3.49.1` | `straymark` 二进制文件 |
| CLI | `cli-` | `cli-3.49.2` | `straymark` 二进制文件 |
| Loom(实验性) | `loom-` | `loom-0.4.2` | `straymark-loom` 可视化服务器,由 `straymark loom serve` 按需下载 |

使用 `straymark status` 或 `straymark about` 查看已安装的版本。
Expand Down
2 changes: 1 addition & 1 deletion docs/i18n/zh-CN/adopters/CLI-REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ StrayMark 为每个组件使用**独立的版本标签**:
| 组件 | 标签前缀 | 示例 | 包含内容 |
|------|----------|------|----------|
| Framework | `fw-` | `fw-4.45.0` | 模板(12 种类型)、治理文档、指令 |
| CLI | `cli-` | `cli-3.49.1` | `straymark` 二进制文件 |
| CLI | `cli-` | `cli-3.49.2` | `straymark` 二进制文件 |
| Loom(实验性) | `loom-` | `loom-0.4.2` | `straymark-loom` 可视化服务器,由 `straymark loom serve` 按需下载 |

Framework 和 CLI 独立发布。Framework 更新不需要 CLI 更新,反之亦然。
Expand Down
2 changes: 1 addition & 1 deletion experiment-baton/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ name = "straymark-baton"
path = "src/main.rs"

[dependencies]
straymark-core = { version = "0.10.0", path = "../core" }
straymark-core = { version = "0.10.1", path = "../core" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
Expand Down
12 changes: 3 additions & 9 deletions experiment-baton/src/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,10 @@
//! Also the one rule every directory walker shares: [`is_nested_checkout`].

use std::collections::HashSet;
use std::path::Path;

/// True when `dir` is the root of *another* checkout — a linked git worktree
/// or a submodule (a `.git` file) or a nested clone (a `.git` directory).
/// Walkers never descend into one: its artifacts belong to another working
/// copy, and reading them duplicates units and contracts (#434). Structural on
/// purpose — no gitignore parsing, nothing for an adopter to configure.
pub(crate) fn is_nested_checkout(dir: &Path) -> bool {
dir.join(".git").exists()
}
/// Never descend into another checkout nested in the project (#434) — the
/// rule lives in `core` so Baton, the CLI and Loom's scanner agree on it.
pub(crate) use straymark_core::walk::is_nested_checkout;

/// Scan `text` for all identifiers shaped `<prefix><body>` where `body` is made
/// of ASCII alphanumerics / hyphens and contains at least one digit. Returns
Expand Down
Loading