From a71028e11cac0d9cf99e457c5dfaf833c1a44dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Villase=C3=B1or=20Montfort?= <195970+montfort@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:59:29 -0600 Subject: [PATCH] =?UTF-8?q?fix(baton):=20inventory=20only=20live=20work=20?= =?UTF-8?q?=E2=80=94=20skip=20nested=20checkouts,=20ignore=20follow-up=20e?= =?UTF-8?q?xamples=20(#434,=20#431)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Every Baton walker (unit inventory, coherence inventory, code scan) now stops at a sub-directory with its own .git entry: a linked worktree, a submodule or a nested clone is another checkout. On Estoa's local checkout one worktree under .worktrees/ inflated the inventory from 79 to 1946 tasks with 1839 duplicated unit ids (#434). - read_followups ignores HTML comments and fenced blocks, requires the canonical FU- id, and attaches metadata lines only to the entry heading right above them, so the shipped registry's commented FU-NNN example is no longer a unit (#431). Closes #434. Fixes the Baton half of #431; the CLI validator half ships with the follow-up writer work (#432). Co-Authored-By: Claude Opus 5 (1M context) --- ...-2026-09-17-002-baton-inventory-hygiene.md | 88 +++++++++ experiment-baton/src/codescan.rs | 4 +- experiment-baton/src/coherence.rs | 4 +- experiment-baton/src/scan.rs | 12 ++ experiment-baton/src/units.rs | 71 ++++++- experiment-baton/tests/inventory_hygiene.rs | 180 ++++++++++++++++++ 6 files changed, 347 insertions(+), 12 deletions(-) create mode 100644 .straymark/07-ai-audit/agent-logs/AILOG-2026-09-17-002-baton-inventory-hygiene.md create mode 100644 experiment-baton/tests/inventory_hygiene.rs diff --git a/.straymark/07-ai-audit/agent-logs/AILOG-2026-09-17-002-baton-inventory-hygiene.md b/.straymark/07-ai-audit/agent-logs/AILOG-2026-09-17-002-baton-inventory-hygiene.md new file mode 100644 index 00000000..64601166 --- /dev/null +++ b/.straymark/07-ai-audit/agent-logs/AILOG-2026-09-17-002-baton-inventory-hygiene.md @@ -0,0 +1,88 @@ +--- +id: AILOG-2026-09-17-002 +title: Baton inventory hygiene — skip nested checkouts, ignore follow-up examples (#434, #431) +status: accepted +created: 2026-09-17 +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: + - experiment-baton/src/scan.rs + - experiment-baton/src/units.rs + - experiment-baton/src/coherence.rs + - experiment-baton/src/codescan.rs + - experiment-baton/tests/inventory_hygiene.rs +observability_scope: none +tags: [baton, adopter-feedback, inventory, track-c] +related: + - 07-ai-audit/agent-logs/AILOG-2026-09-13-001-baton-task-inheritance.md +--- + +# AILOG: Baton inventory hygiene (#434, #431) + +## Summary + +Baton counted work that is not the project's live work. It walked into other git checkouts nested +in the project (#434), and it read the shipped registry's commented `### FU-NNN` example as a +follow-up (#431). Both inflate Track C denominators, and the first one also duplicates unit IDs. + +## Context + +Found while reviewing Estoa's #430. On Estoa's local checkout, one linked worktree under +`.worktrees/` inflated the inventory to 1946 tasks, with 1839 duplicated unit IDs. #431 was +reported by Estoa itself: its 43/50 raw rows were 42/49 real units. + +## Actions Performed + +1. `scan::is_nested_checkout`: a sub-directory with its own `.git` entry is another checkout. A + `.git` file marks a linked worktree or a submodule, and a `.git` directory marks a nested clone. + The check is structural, so there is no gitignore parsing and nothing for adopters to configure. +2. All three Baton walkers use it: `units::find_files` (tasks, batch ledgers), + `coherence::Inventory::scan` and `codescan::walk_code`. +3. `read_followups` inventories only live entries: + - text inside HTML comments (including multi-line and inline ones) and fenced blocks is ignored; + - IDs must have the canonical `FU-` shape that `straymark followups` also requires; + - metadata lines only attach to the entry heading directly above them. Before, a `- **Work + verb**:` line under an unrelated `###` heading or inside a commented example could fill an + earlier entry's field. +4. `tests/inventory_hygiene.rs`: 6 tests. Each walker is checked against a fixture copied three + times (root, worktree, nested clone). The shipped empty registry is read from `dist/` as data, + plus the #431 reproduction and a mixed registry of live entries and examples. All 6 fail on the + previous code. + +## Decisions Made + +- Skipping any directory with `.git`, rather than matching names like `.worktrees`. Worktrees can + live anywhere, and submodules and nested clones are other repositories whose governance is not + this project's work. +- Out of scope, as stated in #434: `straymark_core::architecture::collect_source_files` has the + same blind spot. Fixing it needs a `core` bump, which also affects the CLI and Loom. Copies of + `specs/` inside the main tree (for example, evidence snapshots) cannot be told apart + structurally, so handling them would need an explicit exclude setting, gated on an adopter + needing it. +- The CLI half of #431 (`straymark validate` warning on the template's example line) belongs to + the CLI and is handled with the CLI follow-up work (#432). + +## Impact + +Measured read-only with the binaries built from `main` and from this branch (git status unchanged +in every repository): + +| Corpus | Before | After | +|---|---|---| +| Estoa (local) | task 1946, batch 22, follow-up 6, duplicated IDs 1839 | task 79, batch 11, follow-up 5, duplicated IDs 0 | +| LNXDrive | follow-up 18 | follow-up 17 (template placeholder) | +| Sentinel | unchanged | unchanged (its one duplicated ID is an AILOG with two `### Batch 1` headings: adopter content) | + +- **Performance / Security / Privacy / Environmental**: N/A. Still read-only. + +## Verification + +- [x] `cargo test -p straymark-baton --locked`: 90 passed (84 existing + 6 new) +- [x] The 6 new tests fail against the unfixed source +- [x] `cargo clippy -p straymark-baton --all-targets --locked -- -D warnings` +- [x] Read-only measurement on three adopter corpora diff --git a/experiment-baton/src/codescan.rs b/experiment-baton/src/codescan.rs index c7572b2c..40b09770 100644 --- a/experiment-baton/src/codescan.rs +++ b/experiment-baton/src/codescan.rs @@ -29,7 +29,7 @@ use std::collections::BTreeMap; use std::path::Path; use crate::intent::{ContractShape, EnumDef, Field, Lang, ShapeRole, SourceRef}; -use crate::scan::{normalize_endpoint, scan_endpoints}; +use crate::scan::{is_nested_checkout, normalize_endpoint, scan_endpoints}; const SKIP_DIRS: &[&str] = &[ ".git", @@ -595,7 +595,7 @@ fn walk_code(root: &Path) -> Vec { for p in entries { if p.is_dir() { let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default(); - if !SKIP_DIRS.contains(&name) { + if !SKIP_DIRS.contains(&name) && !is_nested_checkout(&p) { stack.push(p); } } else if matches!(ext(&p), Some("go") | Some("ts") | Some("tsx")) diff --git a/experiment-baton/src/coherence.rs b/experiment-baton/src/coherence.rs index 566bb613..545961a0 100644 --- a/experiment-baton/src/coherence.rs +++ b/experiment-baton/src/coherence.rs @@ -22,7 +22,7 @@ use std::path::Path; use serde::Serialize; use crate::intent::{Confidence, IntentContract, IntentModel, SourceRef}; -use crate::scan::normalize_endpoint; +use crate::scan::{is_nested_checkout, normalize_endpoint}; use crate::speckit::IntendedComponent; /// A flat, lowercased listing of on-disk file paths (read-only) — used to tell @@ -61,7 +61,7 @@ impl Inventory { for p in entries { if p.is_dir() { let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default(); - if !SKIP.contains(&name) { + if !SKIP.contains(&name) && !is_nested_checkout(&p) { stack.push(p); } } else if let Ok(rel) = p.strip_prefix(root) { diff --git a/experiment-baton/src/scan.rs b/experiment-baton/src/scan.rs index 67587777..f4a240e1 100644 --- a/experiment-baton/src/scan.rs +++ b/experiment-baton/src/scan.rs @@ -1,8 +1,20 @@ //! Low-level, dependency-free text scanners shared across the adapter, the //! code-shape extractor, and provenance inference. No regex (matching the //! `core` philosophy); char-boundary-safe over accented prose. +//! +//! 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() +} /// Scan `text` for all identifiers shaped `` where `body` is made /// of ASCII alphanumerics / hyphens and contains at least one digit. Returns diff --git a/experiment-baton/src/units.rs b/experiment-baton/src/units.rs index 21b66bf8..ddff1ffe 100644 --- a/experiment-baton/src/units.rs +++ b/experiment-baton/src/units.rs @@ -18,6 +18,7 @@ use straymark_core::charter::{discover_and_parse, display_title, read_frontmatte use straymark_core::charter_files::parse_files_to_modify; use crate::intent::SourceRef; +use crate::scan::is_nested_checkout; /// Directories never walked for governance artifacts. const SKIP_DIRS: &[&str] = &[ @@ -211,9 +212,24 @@ fn read_followups(root: &Path) -> Vec { let rel_path = rel(root, ®istry); let mut out = Vec::new(); let mut bucket: Option = None; + // Only live entries count (#431): the shipped registry documents the entry + // shape as a commented `### FU-NNN` example, and adopters keep examples in + // fenced blocks. Metadata lines belong to the entry heading right above + // them, never to an earlier entry across another heading. + let mut in_comment = false; + let mut in_fence = false; + let mut in_entry = false; for line in content.lines() { - let t = line.trim(); + let visible = outside_html_comments(line, &mut in_comment); + let t = visible.trim(); + if t.starts_with("```") || t.starts_with("~~~") { + in_fence = !in_fence; + continue; + } + if in_fence || t.is_empty() { + continue; + } if let Some(h) = t.strip_prefix("## ") { // `## Bucket: ready` → `ready`; any other `## …` is a non-bucket section. bucket = Some( @@ -222,23 +238,23 @@ fn read_followups(root: &Path) -> Vec { .trim() .to_string(), ); + in_entry = false; continue; } // `### FU-NNN — ` if let Some(rest) = t.strip_prefix("### ") { let (head, desc) = split_on_dash(rest); - let Some(id) = head.split_whitespace().next() else { + let id = head.split_whitespace().next().and_then(followup_id); + in_entry = id.is_some(); + let Some(id) = id else { continue; }; - if !id.starts_with("FU-") { - continue; - } out.push(RoutableUnit { - id: id.to_string(), + id: id.clone(), granularity: Granularity::Followup, source: SourceRef { file: rel_path.clone(), - symbol: Some(id.to_string()), + symbol: Some(id), }, title: if desc.is_empty() { rest.trim().to_string() } else { desc }, effort_estimate: None, @@ -250,6 +266,9 @@ fn read_followups(root: &Path) -> Vec { }); continue; } + if !in_entry { + continue; + } // `- **Label**: value` metadata lines within the current entry. if let Some(last) = out.last_mut() { if last.granularity == Granularity::Followup { @@ -266,6 +285,42 @@ fn read_followups(root: &Path) -> Vec { out } +/// Canonical follow-up id of a heading token: `FU-` + digits (`FU-012`, also +/// `FU-012:`), as `straymark followups` reads it. `FU-NNN` and other +/// placeholders are not ids. +fn followup_id(token: &str) -> Option { + let digits: String = token + .strip_prefix("FU-")? + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + (!digits.is_empty()).then(|| format!("FU-{digits}")) +} + +/// The part of `line` outside HTML comments. `in_comment` carries an open +/// `") else { + return out; + }; + rest = &rest[end + 3..]; + *in_comment = false; + } else { + let Some(start) = rest.find("\n\n## Bucket: ready\n"; + assert!(Project::new().followups(registry).is_empty()); +} + +#[test] +fn examples_never_leak_into_live_entries() { + let registry = "\ +## Bucket: ready + +### FU-007 — Real entry +- **Status**: open +- **Severity**: blocking + + + +```markdown +### FU-999 — fenced example +- **Work verb**: audit +``` + +### Triage notes +- **Work verb**: operate + +### FU-008: Colon-separated entry +- **Work verb**: implement +- **Design provenance**: upstream +"; + let units = Project::new().followups(registry); + let ids: Vec<&str> = units.iter().map(|u| u.id.as_str()).collect(); + assert_eq!(ids, ["FU-007", "FU-008"]); + + let (fu7, fu8) = (&units[0], &units[1]); + assert_eq!(fu7.followup_severity.as_deref(), Some("blocking")); + assert_eq!( + fu7.work_verb, None, + "examples and other headings are not FU-007's" + ); + assert_eq!(fu8.work_verb.as_deref(), Some("implement")); + assert_eq!(fu8.design_provenance.as_deref(), Some("upstream")); + assert_eq!(fu8.followup_bucket.as_deref(), Some("ready")); +}