Skip to main content

majordomus_cli/capability/builtin/
trace.rs

1//! The `trace` module: the work graph above the branch, both ways.
2//!
3//! The canonical model reached as far as a branch — `worktree::state::issue_of` reads an
4//! issue id out of a branch name, and the topology gate enforces it. These three
5//! capabilities continue that one edge in both directions, deriving everything and storing
6//! nothing: an issue's branches and commits come from `git for-each-ref` and `git log`, and
7//! a commit's issue is the issue whose branches contain it.
8//!
9//! The milestone is the one field here that git does not hold. It is not derived either: it
10//! is read from the canonical issue record through the index, which is where the project
11//! model already declares it, and it is the reason these handlers take a [`Context`] rather
12//! than a path.
13//!
14//! # Where the pull requests are
15//!
16//! A pull request is a GitHub fact, and this executable makes no network call —
17//! `test/cases/08_no_forbidden_constructs.sh` is the proof, and the rule is the reason
18//! `scripts/github-sync` exists in `scripts/` rather than in `lib/`. So the pull-request
19//! half of the same derivation is `scripts/traceability`, which reads pull requests with
20//! `gh` (or from a fixture), maps each head branch to an issue by this module's rule, and
21//! joins the two answers. The seam between them is the branch name, which both sides read
22//! and neither side stores.
23//!
24//! # Why no command line
25//!
26//! Every exposure of a capability is declared here, and the command line is the one
27//! projection that is declared twice — a `cli` exposure has to have a matching clap command
28//! in `cli.rs`, which `capability::closure` proves. These capabilities claim MCP and HTTP
29//! only; `scripts/traceability` reaches the git half over MCP, the way any other client
30//! would.
31
32use std::path::Path;
33
34use schemars::JsonSchema;
35use serde::{Deserialize, Serialize};
36use serde_json::Value;
37
38use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
39use crate::capability::handler::{CapabilityError, Context};
40use crate::capability::model::{CachePolicy, Exposure, McpResource, Stability};
41use crate::capability::module::ModuleDescriptor;
42use crate::index::Index;
43use crate::worktree::trace::{
44    self, CommitAttribution, IssueTrace, TraceReport, Tracer, DEFAULT_REPORT_COMMITS,
45    MAX_REPORT_COMMITS,
46};
47use crate::worktree::WorktreeError;
48use crate::{capability, module};
49
50use super::{get, mcp};
51
52/// The URI under which the whole traceability report is read as an MCP resource.
53pub const TRACEABILITY_URI: &str = "majordomus://traceability";
54
55// ---------------------------------------------------------------- inputs
56
57#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
58#[serde(deny_unknown_fields)]
59/// One issue of the project model.
60pub struct TraceIssueInput {
61    /// The issue id, as the project model spells it (`I1305`). An id the model does not
62    /// declare is answered with `declared: false` rather than refused, and rather than
63    /// answered with a bare empty trace: a typo that read as "nothing realised this issue"
64    /// is the one answer this capability must never give.
65    pub issue: String,
66}
67
68impl BenchmarkCases for TraceIssueInput {
69    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
70        // An issue that exists in the repository the benchmark runs against, so the case
71        // measures the derivation rather than the refusal; a hard-coded id would be a
72        // second declaration of this repository's own plan. A repository with no issues at
73        // all still gets a case — an empty list would read to the coverage gate as a
74        // capability nobody benchmarks, and timing the refusal is a real measurement.
75        let issue = first_issue(ctx.index).unwrap_or_else(|| "I0001".to_string());
76        vec![NamedCase::new("first-issue", TraceIssueInput { issue })]
77    }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
81#[serde(deny_unknown_fields)]
82/// One commit.
83pub struct TraceCommitInput {
84    /// Anything git resolves to a commit: a full or abbreviated object name, a ref, `HEAD`,
85    /// `master~3`.
86    pub commit: String,
87}
88
89impl BenchmarkCases for TraceCommitInput {
90    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
91        vec![NamedCase::new(
92            "head",
93            TraceCommitInput {
94                commit: "HEAD".into(),
95            },
96        )]
97    }
98}
99
100#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
101#[serde(deny_unknown_fields)]
102/// How much of the trunk to attribute.
103pub struct TraceReportInput {
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    /// How many trunk commits to attribute, newest first. Default: 50. Anything above 2000
106    /// is read as 2000 — a traceability report is a reading, not a history export.
107    pub limit: Option<usize>,
108}
109
110impl BenchmarkCases for TraceReportInput {
111    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
112        vec![
113            NamedCase::new("default", TraceReportInput { limit: None }),
114            NamedCase::new("ten", TraceReportInput { limit: Some(10) }),
115        ]
116    }
117}
118
119// ---------------------------------------------------------------- the canonical edges the index holds
120
121/// The first issue the index holds, in URI order. The benchmark case's input, and nothing
122/// else: a capability never picks an object for a caller.
123fn first_issue(index: &Index) -> Option<String> {
124    index
125        .objects
126        .iter()
127        .find(|o| o.kind == "issue")
128        .map(|o| o.identity.clone())
129}
130
131/// The milestone one issue record names. Git does not hold this edge, and neither does
132/// GitHub in any form this repository trusts: the canonical record under
133/// `.ai/repo/project/issues/` declares it, the index has already parsed it, and reading it
134/// here is reading the model rather than repeating it.
135fn milestone_of(index: &Index, issue: &str) -> Option<String> {
136    index
137        .objects
138        .iter()
139        .find(|o| o.kind == "issue" && o.identity == issue)
140        .and_then(|o| o.metadata.get("milestone"))
141        .and_then(Value::as_str)
142        .map(str::to_string)
143}
144
145/// Does the project model declare this issue at all?
146fn declared(index: &Index, issue: &str) -> bool {
147    index
148        .objects
149        .iter()
150        .any(|o| o.kind == "issue" && o.identity == issue)
151}
152
153/// Every issue id the project model declares, in id order. The index has already read and
154/// validated every issue record, so this is the model itself rather than a second listing
155/// of the directory it lives in.
156fn issue_ids(index: &Index) -> Vec<String> {
157    let mut ids: Vec<String> = index
158        .objects
159        .iter()
160        .filter(|o| o.kind == "issue")
161        .map(|o| o.identity.clone())
162        .collect();
163    crate::order::canonical(&mut ids);
164    ids
165}
166
167/// Fill in every canonical edge the git derivation deliberately left empty.
168fn with_milestones(index: &Index, mut traces: Vec<IssueTrace>) -> Vec<IssueTrace> {
169    for t in &mut traces {
170        t.milestone = milestone_of(index, &t.issue);
171    }
172    traces
173}
174
175/// The same, for an attribution: the milestone follows the issue it named.
176fn attribution_milestones(
177    index: &Index,
178    mut commits: Vec<CommitAttribution>,
179) -> Vec<CommitAttribution> {
180    for c in &mut commits {
181        c.milestone = c.issue.as_deref().and_then(|i| milestone_of(index, i));
182    }
183    commits
184}
185
186fn refused(e: WorktreeError) -> CapabilityError {
187    match e.exit_code() {
188        crate::worktree::EXIT_MISSING => CapabilityError::NotFound(e.to_string()),
189        crate::worktree::EXIT_REFUSED => CapabilityError::Refused(e.to_string()),
190        _ => CapabilityError::Internal(e.to_string()),
191    }
192}
193
194fn tracer_of(ctx: &Context) -> Result<Tracer, CapabilityError> {
195    Tracer::open(Path::new(&ctx.index.repository.root))
196        .map(|t| t.with_issues(issue_ids(&ctx.index)))
197        .map_err(refused)
198}
199
200// ---------------------------------------------------------------- handlers
201
202fn trace_issue(ctx: &Context, input: TraceIssueInput) -> Result<IssueTrace, CapabilityError> {
203    let mut t = tracer_of(ctx)?.trace(&input.issue).map_err(refused)?;
204    t.declared = declared(&ctx.index, &input.issue);
205    t.milestone = milestone_of(&ctx.index, &input.issue);
206    Ok(t)
207}
208
209fn trace_commit(
210    ctx: &Context,
211    input: TraceCommitInput,
212) -> Result<CommitAttribution, CapabilityError> {
213    let tracer = tracer_of(ctx)?;
214    let Some(commit) = tracer.commit(&input.commit).map_err(refused)? else {
215        return Err(CapabilityError::NotFound(format!(
216            "`{}` does not resolve to a commit in this repository",
217            input.commit
218        )));
219    };
220    let traces = with_milestones(&ctx.index, tracer.traces().map_err(refused)?);
221    let answer = trace::attribute(&[commit], &traces)
222        .into_iter()
223        .next()
224        .expect("attribute answers for every commit it is given");
225    Ok(attribution_milestones(&ctx.index, vec![answer])
226        .into_iter()
227        .next()
228        .expect("one in, one out"))
229}
230
231fn trace_report(ctx: &Context, input: TraceReportInput) -> Result<TraceReport, CapabilityError> {
232    let limit = input
233        .limit
234        .unwrap_or(DEFAULT_REPORT_COMMITS)
235        .min(MAX_REPORT_COMMITS);
236    let mut report = tracer_of(ctx)?.report(limit).map_err(refused)?;
237    report.issues = with_milestones(&ctx.index, report.issues);
238    report.commits = attribution_milestones(&ctx.index, report.commits);
239    Ok(report)
240}
241
242// ---------------------------------------------------------------- the module
243
244/// The `trace` module: the issue-to-commit edge, derived on every call.
245pub fn module() -> ModuleDescriptor {
246    module! {
247        id: "trace",
248        title: "Traceability",
249        description: "Which branches and commits realised an issue, and which issue and milestone a commit served — derived from git and from the canonical project model on every call, stored nowhere. A branch names an issue when one of its path components is an issue id; a commit belongs to the issue whose branches hold it; a commit no such branch holds is reported as unattributed rather than left out, because work with no execution contract is what a traceability report exists to make visible. Pull requests are a GitHub fact and this executable makes no network call: `scripts/traceability` reads them and joins them to this answer over the branch name.",
250        stability: Stability::BehaviorallyVerified,
251        capabilities: [
252            capability! {
253                id: "trace.issue",
254                title: "What realised this issue",
255                description: "One issue with the branches that name it — local, and remote-tracking where only the remote still has the branch — and, for each, the commits it holds that the trunk did not: measured against the trunk while the branch is open, and against the first parent of the merge commit that brought it in once it is merged. A branch that reached the trunk without a merge commit of its own says so and claims nothing, because its commits cannot be told from the trunk's. The milestone comes from the canonical issue record, which is the one edge here that git does not hold, and `declared` says whether the project model has this id at all — a repository with no plan still gets the branches, and a typo still cannot read as work nobody did.",
256                input: TraceIssueInput,
257                output: IssueTrace,
258                stability: Stability::BehaviorallyVerified,
259                exposure: Exposure {
260                    mcp: mcp("majordomus_trace_issue"),
261                    http: get("/api/v1/trace/issue"),
262                    cli: None,
263                },
264                tags: ["trace", "git", "plan", "issue", "provenance"],
265                cache: CachePolicy::Disabled,
266                handler: trace_issue,
267            },
268            capability! {
269                id: "trace.commit",
270                title: "What contract this commit served",
271                description: "One commit with the issue and milestone it served, or the fact that none can be found. Attributed when exactly one issue's branches hold it, ambiguous when branches naming two issues do, and unattributed when no branch naming an issue holds it at all — which is either work committed without an execution contract or a branch deleted after its merge, and the answer says so rather than guessing between them.",
272                input: TraceCommitInput,
273                output: CommitAttribution,
274                stability: Stability::BehaviorallyVerified,
275                exposure: Exposure {
276                    mcp: mcp("majordomus_trace_commit"),
277                    http: get("/api/v1/trace/commit"),
278                    cli: None,
279                },
280                tags: ["trace", "git", "plan", "commit", "provenance"],
281                cache: CachePolicy::Disabled,
282                handler: trace_commit,
283            },
284            capability! {
285                id: "trace.report",
286                title: "The whole work graph above the branch",
287                description: "Every issue at least one branch names with its branches and commits, every declared issue no branch names, and the newest stretch of the trunk with each commit attributed to the issue whose branches hold it or reported as unattributed. The tallies count both sides, so the proportion of the trunk that no execution contract accounts for is a number rather than an impression. Read from git on every call: the history changes outside this process.",
288                input: TraceReportInput,
289                output: TraceReport,
290                stability: Stability::BehaviorallyVerified,
291                exposure: Exposure {
292                    mcp: Some(crate::capability::model::McpExposure {
293                        tool: Some("majordomus_traceability".into()),
294                        resource: Some(McpResource { uri: TRACEABILITY_URI.into(), name: "traceability".into() }),
295                    }),
296                    http: get("/api/v1/trace"),
297                    cli: None,
298                },
299                tags: ["trace", "git", "plan", "provenance", "introspection"],
300                cache: CachePolicy::Disabled,
301                handler: trace_report,
302            },
303        ],
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    /// The declaration is the only place these names exist; every projection derives from
312    /// it. This is the assertion a refactor that dropped an exposure would fail.
313    #[test]
314    fn the_declaration_yields_the_projections_it_claims() {
315        let m = module();
316        assert_eq!(m.id.as_str(), "trace");
317        let expected: &[(&str, &str, &str)] = &[
318            (
319                "trace.issue",
320                "majordomus_trace_issue",
321                "/api/v1/trace/issue",
322            ),
323            (
324                "trace.commit",
325                "majordomus_trace_commit",
326                "/api/v1/trace/commit",
327            ),
328            ("trace.report", "majordomus_traceability", "/api/v1/trace"),
329        ];
330        let ids: Vec<&str> = m
331            .capabilities
332            .iter()
333            .map(|e| e.capability.id.as_str())
334            .collect();
335        let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
336        assert_eq!(ids, want);
337        for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
338            let exposure = &executable.capability.exposure;
339            assert_eq!(
340                exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
341                Some(*tool),
342                "{id} lost or renamed its MCP tool"
343            );
344            assert_eq!(
345                exposure.http.as_ref().map(|h| h.path.as_str()),
346                Some(*path),
347                "{id} lost or renamed its HTTP route"
348            );
349            assert!(
350                !executable.capability.cache.is_enabled(),
351                "{id} must not cache: the history changes outside this process"
352            );
353        }
354    }
355
356    /// Every capability of the module is read-only. Traceability is derived; a capability
357    /// here that wrote anything would be the second store of truth the whole module exists
358    /// to avoid.
359    #[test]
360    fn every_capability_is_a_query() {
361        for e in module().capabilities {
362            assert!(
363                e.capability.kind.is_read_only(),
364                "{} writes",
365                e.capability.id
366            );
367        }
368    }
369
370    /// The command line is declared twice — here and in clap — so a `cli` exposure this
371    /// module has no clap command for would fail the projection closure. It claims none.
372    #[test]
373    fn nothing_here_claims_a_command_line() {
374        for e in module().capabilities {
375            assert!(
376                e.capability.exposure.cli.is_none(),
377                "{} claims a command line that cli.rs does not declare",
378                e.capability.id
379            );
380        }
381    }
382}