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
7 changes: 4 additions & 3 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
## Status

- **Current phase:** 5 — Context bundle & agent interface
- **Next step:** 5.6Git 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.7MCP 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
Expand Down Expand Up @@ -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<User[]>`, `useQuery<T>`) 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
Expand Down
21 changes: 20 additions & 1 deletion packages/agent-sdk/src/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 {
Expand All @@ -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. */
Expand All @@ -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 = {
Expand Down Expand Up @@ -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<string>([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) {
Expand Down
46 changes: 46 additions & 0 deletions packages/agent-sdk/src/history.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
57 changes: 57 additions & 0 deletions packages/agent-sdk/src/history.ts
Original file line number Diff line number Diff line change
@@ -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<string, { subject: string; time: number }>();
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 };
});
}
10 changes: 10 additions & 0 deletions packages/parser-react/src/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion schemas/context-bundle.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -396,14 +396,18 @@
},
"subject": {
"type": "string"
},
"pr": {
"type": "number",
"description": "PR number parsed from a merge/squash subject, when present."
}
},
"required": [
"sha",
"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."
}
}
}
Loading