1use 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
52pub const TRACEABILITY_URI: &str = "majordomus://traceability";
54
55#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
58#[serde(deny_unknown_fields)]
59pub struct TraceIssueInput {
61 pub issue: String,
66}
67
68impl BenchmarkCases for TraceIssueInput {
69 fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
70 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)]
82pub struct TraceCommitInput {
84 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)]
102pub struct TraceReportInput {
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 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
119fn 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
131fn 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
145fn declared(index: &Index, issue: &str) -> bool {
147 index
148 .objects
149 .iter()
150 .any(|o| o.kind == "issue" && o.identity == issue)
151}
152
153fn 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
167fn 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
175fn 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
200fn 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
242pub 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 #[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 #[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 #[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}