diff --git a/TRACKER.md b/TRACKER.md index 39d7c62..4d55cf9 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 5 — Context bundle & agent interface -- **Next step:** 5.6 — Git history -- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.5 +- **Next step:** 5.7 — MCP server (→ Gate 5) +- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.6 - **Gates passed:** Gate 0 (CI + red-path, #5/#6) · Gate 1 (precision 1.000, recall 0.895, zero poison) · Gate 2 (C1 instance attribution 1.000 · B1 4-level handler chains · C6 store writers↔readers · A9 portals — scorecard 137/0/0, precision & recall 1.000) · Gate 3 (B3 action effects · B4 routers · B6 cyclic journeys terminate · B7/B8 form & non-JSX events · G5 flag/role conditions — precision & recall 1.000) · Gate 4 (A4 rarity · A10 fuzzy/OCR · A1 structural · A6 subtree · E3 vision annotations · E2 aliases · G4 corrections — high-conf correct 1.000, ambiguity honesty 1.000, poison rate 0.000) ## What CodeRadar is @@ -297,10 +297,11 @@ The heart of the project. C1 and B1 live here. **Accept:** fixture `f4-typed-responses` green for all three sources (generic, annotation, OpenAPI). **Done:** `ResponseType { name, fields: {name,type}[], source }` on `DataSourceNode` in core (schema regenerated, drift gate green). New `response.ts` parser module: `responseFromCall` recovers the type from a call's generic argument (`axios.get`, `useQuery`) or, failing that, the annotation on the nearest enclosing typed variable whose initializer holds the call (`const data: Invoice[] = await fetch(…).then(r => r.json())`), stopping at function boundaries; only property signatures are read (one level, methods skipped). `loadOpenApi`/`linkOpenApiResponses` is a post-pass that fills untyped sources from an OpenAPI 3 JSON spec (`openapi` scan option / CLI `--openapi`), matching `${METHOD} ${endpoint}` with `{id}`→`:id` normalization and `$ref` resolution. Bundle lineage `dataSources` carry `responseType`; `trace` prints it. New `f4-typed-responses` fixture + `GoldenResponse`/`responses` check kind (all three sources). 5 parser unit tests incl. bundle-level; eval 265/0/0, gate OK. -### [ ] 5.6 Git history context +### [x] 5.6 Git history context **Failure modes:** F5 **Build:** `history` section: last N commits touching matched files (`git log --follow`), PR numbers parsed from merge/squash subjects. Pure `git` subprocess, no network. **Accept:** integration test on this repo's own history; graceful empty section outside a git repo. +**Done:** `gitHistory(root, files, limit)` in agent-sdk — per-file `git log --follow` (via `execFileSync`, no network), commits deduped across files and sorted newest-first by committer time, `parsePrNumber` pulls `#NN` from merge/squash subjects. Any failure (not a repo, git missing, untracked path) yields `[]` — the bundle degrades gracefully. Bundle `history` section (default 5) is populated from the matched component's files + render subtree; `BundleCommit` gains optional `pr` (context-bundle schema regenerated, drift gate green). Integration tests run against this repo's own history; 5 agent-sdk unit tests + a bundle-wiring test. eval 265/0/0, gate OK. ### [ ] 5.7 MCP server **Failure modes:** G1 (surface), pipeline integration diff --git a/packages/agent-sdk/src/bundle.ts b/packages/agent-sdk/src/bundle.ts index 55056b6..2b6c70d 100644 --- a/packages/agent-sdk/src/bundle.ts +++ b/packages/agent-sdk/src/bundle.ts @@ -20,6 +20,7 @@ import { traceLineage, } from "@coderadar/core"; +import { gitHistory } from "./history.js"; import { resolveContext } from "./resolve.js"; import type { EntryPoint, Ticket } from "./types.js"; @@ -57,10 +58,12 @@ export interface BundleTest { file: string; } -/** Recent git history (step 5.6) — empty until then. */ +/** Recent git history (step 5.6, F5) — a commit touching the matched files. */ export interface BundleCommit { sha: string; subject: string; + /** PR number parsed from a merge/squash subject, when present. */ + pr?: number; } export interface ContextBundle { @@ -81,6 +84,8 @@ export interface BundleOptions { budgetTokens?: number; /** Journey expansion depth around the match. Default 2. */ journeyDepth?: number; + /** Max recent commits to include in the history section. Default 5. */ + historyLimit?: number; } /** Trim sections in this reverse-priority order until the bundle fits its budget. */ @@ -99,6 +104,7 @@ export function buildBundle( ): ContextBundle { const budgetTokens = options.budgetTokens ?? 4000; const depth = options.journeyDepth ?? 2; + const historyLimit = options.historyLimit ?? 5; const ctx = resolveContext(graph, ticket); const bundle: ContextBundle = { @@ -181,6 +187,19 @@ export function buildBundle( } bundle.tests = [...testFiles].sort().map((file) => ({ file })); if (!topCovered) bundle.warnings.push(`untested — no test renders ${top.component.name}`); + + // Git history (5.6): recent commits touching the matched component's files. + const files = new Set([top.component.loc.file]); + for (const instance of top.instances) files.add(instance.loc.file); + for (const id of subtree) { + const node = byId.get(id); + if (node !== undefined) files.add(node.loc.file); + } + bundle.history = gitHistory(graph.root, [...files], historyLimit).map((commit) => ({ + sha: commit.sha, + subject: commit.subject, + ...(commit.pr !== undefined ? { pr: commit.pr } : {}), + })); } if (graph.meta?.dirty === true) { diff --git a/packages/agent-sdk/src/history.test.ts b/packages/agent-sdk/src/history.test.ts new file mode 100644 index 0000000..67cff8e --- /dev/null +++ b/packages/agent-sdk/src/history.test.ts @@ -0,0 +1,46 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { gitHistory, parsePrNumber } from "./history.js"; + +// This repo's own root — packages/agent-sdk/src → ../../.. +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); + +describe("gitHistory (TRACKER 5.6)", () => { + it("returns recent commits touching a tracked file", () => { + const commits = gitHistory(repoRoot, ["TRACKER.md"], 5); + expect(commits.length).toBeGreaterThan(0); + expect(commits.length).toBeLessThanOrEqual(5); + for (const commit of commits) { + expect(commit.sha).toMatch(/^[0-9a-f]{7,}$/); + expect(typeof commit.subject).toBe("string"); + } + }); + + it("parses PR numbers out of merge and squash subjects", () => { + expect(parsePrNumber("Merge pull request #38 from officialCodeWork/x")).toBe(38); + expect(parsePrNumber("feat(response): typed responses (#12)")).toBe(12); + expect(parsePrNumber("plain commit with no pr")).toBeUndefined(); + }); + + it("attaches a PR number when a commit subject carries one", () => { + // Merge commits carry "#NN"; whether they surface for a given file depends on + // the merge strategy, so assert the shape holds for any that do. + const commits = gitHistory(repoRoot, ["TRACKER.md"], 20); + for (const commit of commits) { + if (/#\d+/.test(commit.subject)) expect(typeof commit.pr).toBe("number"); + else expect(commit.pr).toBeUndefined(); + } + }); + + it("degrades to an empty list outside a git repo", () => { + expect(gitHistory("/nonexistent-path-xyz", ["whatever.ts"], 5)).toEqual([]); + }); + + it("returns empty for no files or a non-positive limit", () => { + expect(gitHistory(repoRoot, [], 5)).toEqual([]); + expect(gitHistory(repoRoot, ["TRACKER.md"], 0)).toEqual([]); + }); +}); diff --git a/packages/agent-sdk/src/history.ts b/packages/agent-sdk/src/history.ts new file mode 100644 index 0000000..c5289d5 --- /dev/null +++ b/packages/agent-sdk/src/history.ts @@ -0,0 +1,57 @@ +/** + * Git-history context (TRACKER step 5.6, failure mode F5). + * + * The last commits that touched the matched files, so an agent knows what + * recently changed around a ticket. Pure `git` subprocess, no network; any + * failure (not a repo, git missing, untracked files) yields an empty list + * rather than throwing — the bundle degrades gracefully. + */ + +import { execFileSync } from "node:child_process"; + +export interface CommitInfo { + /** Abbreviated commit hash. */ + sha: string; + subject: string; + /** PR number parsed from a merge/squash subject, when present. */ + pr?: number; +} + +const UNIT = "\x1f"; // field separator unlikely to appear in a subject + +/** Extract a PR number from a merge ("… pull request #12 …") or squash ("… (#12)") subject. */ +export function parsePrNumber(subject: string): number | undefined { + const match = /#(\d+)/.exec(subject); + return match !== null ? Number(match[1]) : undefined; +} + +/** The most recent `limit` commits touching any of `files` (relative to `root`). */ +export function gitHistory(root: string, files: string[], limit: number): CommitInfo[] { + if (files.length === 0 || limit <= 0) return []; + const seen = new Map(); + for (const file of files) { + let out: string; + try { + out = execFileSync( + "git", + ["-C", root, "log", "--follow", "-n", String(limit), `--format=%h${UNIT}%ct${UNIT}%s`, "--", file], + { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }, + ); + } catch { + continue; // not a repo, untracked path, or git unavailable + } + for (const line of out.split("\n")) { + if (line.trim() === "") continue; + const [sha, ct, subject] = line.split(UNIT); + if (sha === undefined || ct === undefined || subject === undefined) continue; + if (!seen.has(sha)) seen.set(sha, { subject, time: Number(ct) }); + } + } + return [...seen.entries()] + .sort((a, b) => b[1].time - a[1].time) + .slice(0, limit) + .map(([sha, { subject }]) => { + const pr = parsePrNumber(subject); + return pr !== undefined ? { sha, subject, pr } : { sha, subject }; + }); +} diff --git a/packages/parser-react/src/bundle.test.ts b/packages/parser-react/src/bundle.test.ts index 3f02681..1fe091a 100644 --- a/packages/parser-react/src/bundle.test.ts +++ b/packages/parser-react/src/bundle.test.ts @@ -24,6 +24,16 @@ describe("context bundle over a real scanned graph (TRACKER 5.2)", () => { expect(bundle.journeys.length).toBeGreaterThan(0); }); + it("wires recent git history over the matched files (5.6)", () => { + // The b3 fixture files are tracked in this repo, so history is populated. + const bundle = buildBundle(graph, { text: "the 'All users' page is broken" }, { budgetTokens: 8000 }); + expect(Array.isArray(bundle.history)).toBe(true); + for (const commit of bundle.history) { + expect(commit.sha).toMatch(/^[0-9a-f]{7,}$/); + expect(typeof commit.subject).toBe("string"); + } + }); + it("every bundle fits the token budget at 2k / 4k / 8k", () => { const tickets = [ "the 'All users' page is broken", diff --git a/schemas/context-bundle.schema.json b/schemas/context-bundle.schema.json index 31683fe..d587bbf 100644 --- a/schemas/context-bundle.schema.json +++ b/schemas/context-bundle.schema.json @@ -396,6 +396,10 @@ }, "subject": { "type": "string" + }, + "pr": { + "type": "number", + "description": "PR number parsed from a merge/squash subject, when present." } }, "required": [ @@ -403,7 +407,7 @@ "subject" ], "additionalProperties": false, - "description": "Recent git history (step 5.6) — empty until then." + "description": "Recent git history (step 5.6, F5) — a commit touching the matched files." } } }