feat: add GitHub API client, diff-aware context, tiktoken token counting

- Add GitHub API integration via @octokit/rest: createGitHubClient,
  getRepoInfo (parses HTTPS/SSH remotes), createPullRequest,
  getPullRequest, listPullRequestReviews, createIssueComment
- Add diff-aware context module: getRecentlyChangedFiles,
  getChangedFilesWithContext, rankFilesByRelevance — prioritizes
  recently-changed files for context window budget allocation
- Add accurate token counting via tiktoken: countTokens (async),
  countTokensSync, initTokenCounter — falls back to chars/4 heuristic
  when tiktoken is unavailable
- 27 new tests across 3 test files
This commit is contained in:
Jeremy McSpadden 2026-03-16 13:50:00 -05:00
parent 0b3163d297
commit 973b8992e5
8 changed files with 1027 additions and 0 deletions

190
package-lock.json generated
View file

@ -19,6 +19,7 @@
"@google/genai": "^1.40.0",
"@mariozechner/jiti": "^2.6.2",
"@mistralai/mistralai": "1.14.1",
"@octokit/rest": "^22.0.1",
"@silvia-odwyer/photon-node": "^0.3.4",
"@sinclair/typebox": "^0.34.41",
"@types/mime-types": "^2.1.4",
@ -1480,6 +1481,161 @@
"zod-to-json-schema": "^3.24.1"
}
},
"node_modules/@octokit/auth-token": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz",
"integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==",
"license": "MIT",
"engines": {
"node": ">= 20"
}
},
"node_modules/@octokit/core": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz",
"integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==",
"license": "MIT",
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.3",
"@octokit/request": "^10.0.6",
"@octokit/request-error": "^7.0.2",
"@octokit/types": "^16.0.0",
"before-after-hook": "^4.0.0",
"universal-user-agent": "^7.0.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@octokit/endpoint": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz",
"integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==",
"license": "MIT",
"dependencies": {
"@octokit/types": "^16.0.0",
"universal-user-agent": "^7.0.2"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@octokit/graphql": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz",
"integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==",
"license": "MIT",
"dependencies": {
"@octokit/request": "^10.0.6",
"@octokit/types": "^16.0.0",
"universal-user-agent": "^7.0.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@octokit/openapi-types": {
"version": "27.0.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz",
"integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==",
"license": "MIT"
},
"node_modules/@octokit/plugin-paginate-rest": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz",
"integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==",
"license": "MIT",
"dependencies": {
"@octokit/types": "^16.0.0"
},
"engines": {
"node": ">= 20"
},
"peerDependencies": {
"@octokit/core": ">=6"
}
},
"node_modules/@octokit/plugin-request-log": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz",
"integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==",
"license": "MIT",
"engines": {
"node": ">= 20"
},
"peerDependencies": {
"@octokit/core": ">=6"
}
},
"node_modules/@octokit/plugin-rest-endpoint-methods": {
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz",
"integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==",
"license": "MIT",
"dependencies": {
"@octokit/types": "^16.0.0"
},
"engines": {
"node": ">= 20"
},
"peerDependencies": {
"@octokit/core": ">=6"
}
},
"node_modules/@octokit/request": {
"version": "10.0.8",
"resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz",
"integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==",
"license": "MIT",
"dependencies": {
"@octokit/endpoint": "^11.0.3",
"@octokit/request-error": "^7.0.2",
"@octokit/types": "^16.0.0",
"fast-content-type-parse": "^3.0.0",
"json-with-bigint": "^3.5.3",
"universal-user-agent": "^7.0.2"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@octokit/request-error": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz",
"integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==",
"license": "MIT",
"dependencies": {
"@octokit/types": "^16.0.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@octokit/rest": {
"version": "22.0.1",
"resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz",
"integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==",
"license": "MIT",
"dependencies": {
"@octokit/core": "^7.0.6",
"@octokit/plugin-paginate-rest": "^14.0.0",
"@octokit/plugin-request-log": "^6.0.0",
"@octokit/plugin-rest-endpoint-methods": "^17.0.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@octokit/types": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz",
"integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==",
"license": "MIT",
"dependencies": {
"@octokit/openapi-types": "^27.0.0"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@ -2443,6 +2599,12 @@
"node": ">=10.0.0"
}
},
"node_modules/before-after-hook": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz",
"integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==",
"license": "Apache-2.0"
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
@ -2797,6 +2959,22 @@
"@types/yauzl": "^2.9.1"
}
},
"node_modules/fast-content-type-parse": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz",
"integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -3300,6 +3478,12 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/json-with-bigint": {
"version": "3.5.7",
"resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.7.tgz",
"integrity": "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@ -4199,6 +4383,12 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/universal-user-agent": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz",
"integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==",
"license": "ISC"
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",

View file

@ -72,6 +72,7 @@
"@google/genai": "^1.40.0",
"@mariozechner/jiti": "^2.6.2",
"@mistralai/mistralai": "1.14.1",
"@octokit/rest": "^22.0.1",
"@silvia-odwyer/photon-node": "^0.3.4",
"@sinclair/typebox": "^0.34.41",
"@types/mime-types": "^2.1.4",

View file

@ -0,0 +1,219 @@
/**
* Diff-aware context module prioritizes recently-changed files when building
* context for the AI agent. Uses git diff/status to discover changes, then
* provides ranking utilities for context-window budget allocation.
*
* Standalone module: only imports node:child_process and node:path.
*/
import { execSync } from "node:child_process";
import { resolve } from "node:path";
// ─── Types ──────────────────────────────────────────────────────────────────
export interface ChangedFileInfo {
path: string;
changeType: "modified" | "added" | "deleted" | "staged";
linesChanged?: number;
}
export interface RecentFilesOptions {
/** Maximum number of files to return (default 20) */
maxFiles?: number;
/** Only consider commits within this many days (default 7) */
sinceDays?: number;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
const EXEC_OPTS = {
encoding: "utf-8" as const,
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"] as const,
};
function git(cmd: string, cwd: string): string {
return execSync(`git ${cmd}`, { ...EXEC_OPTS, cwd }).trim();
}
function splitLines(output: string): string[] {
return output
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
}
// ─── Public API ─────────────────────────────────────────────────────────────
/**
* Returns recently-changed file paths, deduplicated and sorted by recency
* (most recent first). Combines committed diffs, staged changes, and
* unstaged/untracked files from `git status`.
*/
export async function getRecentlyChangedFiles(
cwd: string,
options?: RecentFilesOptions,
): Promise<string[]> {
const maxFiles = options?.maxFiles ?? 20;
const sinceDays = options?.sinceDays ?? 7;
const dir = resolve(cwd);
try {
// 1. Committed changes in the last N commits (or since sinceDays)
let committedFiles: string[] = [];
try {
const since = `--since="${sinceDays} days ago"`;
const raw = git(`log --diff-filter=ACMR --name-only --pretty=format: ${since}`, dir);
committedFiles = splitLines(raw);
} catch {
// Fallback: use HEAD~10
try {
const raw = git("diff --name-only HEAD~10", dir);
committedFiles = splitLines(raw);
} catch {
// Shallow clone or <10 commits — ignore
}
}
// 2. Staged changes
let stagedFiles: string[] = [];
try {
const raw = git("diff --cached --name-only", dir);
stagedFiles = splitLines(raw);
} catch {
// ignore
}
// 3. Unstaged / untracked via porcelain status
let statusFiles: string[] = [];
try {
const raw = git("status --porcelain", dir);
statusFiles = splitLines(raw).map((line) => line.slice(3)); // strip XY + space
} catch {
// ignore
}
// Deduplicate, preserving insertion order (most-recent-first: status → staged → committed)
const seen = new Set<string>();
const result: string[] = [];
for (const file of [...statusFiles, ...stagedFiles, ...committedFiles]) {
if (!seen.has(file)) {
seen.add(file);
result.push(file);
}
}
return result.slice(0, maxFiles);
} catch {
// Non-git directory or git unavailable — graceful fallback
return [];
}
}
/**
* Returns richer change metadata: change type and approximate line counts.
*/
export async function getChangedFilesWithContext(
cwd: string,
): Promise<ChangedFileInfo[]> {
const dir = resolve(cwd);
try {
const result: ChangedFileInfo[] = [];
const seen = new Set<string>();
const add = (info: ChangedFileInfo) => {
if (!seen.has(info.path)) {
seen.add(info.path);
result.push(info);
}
};
// 1. Staged files with numstat
try {
const numstat = git("diff --cached --numstat", dir);
for (const line of splitLines(numstat)) {
const [added, deleted, filePath] = line.split("\t");
if (!filePath) continue;
const lines =
added === "-" || deleted === "-"
? undefined
: Number(added) + Number(deleted);
add({ path: filePath, changeType: "staged", linesChanged: lines });
}
} catch {
// ignore
}
// 2. Unstaged modifications with numstat
try {
const numstat = git("diff --numstat", dir);
for (const line of splitLines(numstat)) {
const [added, deleted, filePath] = line.split("\t");
if (!filePath) continue;
const lines =
added === "-" || deleted === "-"
? undefined
: Number(added) + Number(deleted);
add({ path: filePath, changeType: "modified", linesChanged: lines });
}
} catch {
// ignore
}
// 3. Untracked / deleted from porcelain status
try {
const raw = git("status --porcelain", dir);
for (const line of splitLines(raw)) {
const code = line.slice(0, 2);
const filePath = line.slice(3);
if (seen.has(filePath)) continue;
if (code.includes("?")) {
add({ path: filePath, changeType: "added" });
} else if (code.includes("D")) {
add({ path: filePath, changeType: "deleted" });
} else if (code.includes("A")) {
add({ path: filePath, changeType: "added" });
} else {
add({ path: filePath, changeType: "modified" });
}
}
} catch {
// ignore
}
return result;
} catch {
return [];
}
}
/**
* Ranks a file list so that recently-changed files appear first.
* Files present in `changedFiles` are placed at the front (in their
* original changedFiles order), followed by unchanged files in their
* original order.
*/
export function rankFilesByRelevance(
files: string[],
changedFiles: string[],
): string[] {
const changedSet = new Set(changedFiles);
const changed: string[] = [];
const rest: string[] = [];
for (const f of files) {
if (changedSet.has(f)) {
changed.push(f);
} else {
rest.push(f);
}
}
// Maintain changedFiles priority order within the changed group
const changedOrder = new Map(changedFiles.map((f, i) => [f, i]));
changed.sort((a, b) => (changedOrder.get(a) ?? 0) - (changedOrder.get(b) ?? 0));
return [...changed, ...rest];
}

View file

@ -0,0 +1,235 @@
/**
* GSD GitHub Client
*
* Standalone utility for interacting with GitHub's API via Octokit.
* Provides helpers for PR creation, review reading, and issue management.
* Can be used by other extensions that need GitHub integration.
*/
import { execSync } from "node:child_process";
import { Octokit } from "@octokit/rest";
// ─── Types ─────────────────────────────────────────────────────────────────
export interface RepoInfo {
owner: string;
repo: string;
}
export interface PullRequestOptions {
owner: string;
repo: string;
title: string;
body: string;
head: string;
base: string;
}
export interface PullRequestResult {
number: number;
url: string;
}
export interface PR {
number: number;
title: string;
body: string | null;
state: string;
head: { ref: string; sha: string };
base: { ref: string };
url: string;
user: { login: string } | null;
}
export interface Review {
id: number;
user: { login: string } | null;
state: string;
body: string | null;
submitted_at: string | null;
}
export interface IssueCommentOptions {
owner: string;
repo: string;
number: number;
body: string;
}
// ─── Remote URL Parsing ────────────────────────────────────────────────────
/**
* Parse a GitHub owner/repo from a git remote URL.
* Supports both HTTPS and SSH formats:
* https://github.com/owner/repo.git
* git@github.com:owner/repo.git
* https://github.com/owner/repo
* ssh://git@github.com/owner/repo.git
*/
export function parseRemoteUrl(url: string): RepoInfo | null {
// SSH format: git@github.com:owner/repo.git
const sshMatch = url.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
if (sshMatch) {
return { owner: sshMatch[1], repo: sshMatch[2] };
}
// HTTPS or ssh:// format
const httpsMatch = url.match(
/(?:https?|ssh):\/\/(?:[^@]+@)?github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/,
);
if (httpsMatch) {
return { owner: httpsMatch[1], repo: httpsMatch[2] };
}
return null;
}
// ─── Client Creation ───────────────────────────────────────────────────────
/**
* Create an authenticated Octokit client.
* Uses the provided token, or falls back to GITHUB_TOKEN / GH_TOKEN env vars.
* Returns null if no token is available.
*/
export function createGitHubClient(token?: string): Octokit | null {
const auth = token || process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
if (!auth) {
return null;
}
return new Octokit({ auth });
}
// ─── Repository Info ───────────────────────────────────────────────────────
/**
* Detect the GitHub owner/repo from the git remote in the given working directory.
*/
export async function getRepoInfo(cwd: string): Promise<RepoInfo | null> {
try {
const url = execSync("git config --get remote.origin.url", {
cwd,
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
if (!url) return null;
return parseRemoteUrl(url);
} catch {
return null;
}
}
// ─── Pull Request Operations ───────────────────────────────────────────────
/**
* Create a pull request on GitHub.
*/
export async function createPullRequest(
client: Octokit,
options: PullRequestOptions,
): Promise<PullRequestResult> {
try {
const { data } = await client.pulls.create({
owner: options.owner,
repo: options.repo,
title: options.title,
body: options.body,
head: options.head,
base: options.base,
});
return { number: data.number, url: data.html_url };
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Unknown error";
throw new Error(
`Failed to create pull request for ${options.owner}/${options.repo}: ${message}`,
);
}
}
/**
* Fetch a single pull request by number.
*/
export async function getPullRequest(
client: Octokit,
options: { owner: string; repo: string; number: number },
): Promise<PR> {
try {
const { data } = await client.pulls.get({
owner: options.owner,
repo: options.repo,
pull_number: options.number,
});
return {
number: data.number,
title: data.title,
body: data.body,
state: data.state,
head: { ref: data.head.ref, sha: data.head.sha },
base: { ref: data.base.ref },
url: data.html_url,
user: data.user ? { login: data.user.login } : null,
};
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Unknown error";
throw new Error(
`Failed to get pull request #${options.number} for ${options.owner}/${options.repo}: ${message}`,
);
}
}
/**
* List reviews on a pull request.
*/
export async function listPullRequestReviews(
client: Octokit,
options: { owner: string; repo: string; number: number },
): Promise<Review[]> {
try {
const { data } = await client.pulls.listReviews({
owner: options.owner,
repo: options.repo,
pull_number: options.number,
});
return data.map((review) => ({
id: review.id,
user: review.user ? { login: review.user.login } : null,
state: review.state,
body: review.body,
submitted_at: review.submitted_at ?? null,
}));
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Unknown error";
throw new Error(
`Failed to list reviews for PR #${options.number} in ${options.owner}/${options.repo}: ${message}`,
);
}
}
// ─── Issue Comments ────────────────────────────────────────────────────────
/**
* Create a comment on an issue or pull request.
*/
export async function createIssueComment(
client: Octokit,
options: IssueCommentOptions,
): Promise<{ id: number }> {
try {
const { data } = await client.issues.createComment({
owner: options.owner,
repo: options.repo,
issue_number: options.number,
body: options.body,
});
return { id: data.id };
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Unknown error";
throw new Error(
`Failed to create comment on issue #${options.number} in ${options.owner}/${options.repo}: ${message}`,
);
}
}

View file

@ -0,0 +1,136 @@
/**
* Unit tests for diff-context.ts diff-aware context module.
* Tests git-based file discovery and relevance ranking.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { tmpdir } from "node:os";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import {
getRecentlyChangedFiles,
getChangedFilesWithContext,
rankFilesByRelevance,
} from "../diff-context.js";
// ─── getRecentlyChangedFiles ────────────────────────────────────────────────
describe("diff-context: getRecentlyChangedFiles", () => {
it("returns an array of file paths in the current git repo", async () => {
// Use the project root — guaranteed to be a git repo
const cwd = process.cwd();
const files = await getRecentlyChangedFiles(cwd);
assert.ok(Array.isArray(files), "should return an array");
// The result may be empty if the repo is totally clean with no recent
// commits, but the function should not throw.
});
it("respects maxFiles option", async () => {
const cwd = process.cwd();
const files = await getRecentlyChangedFiles(cwd, { maxFiles: 3 });
assert.ok(files.length <= 3, "should not exceed maxFiles");
});
it("returns empty array for non-git directory", async () => {
const tmp = mkdtempSync(join(tmpdir(), "diff-ctx-test-"));
const files = await getRecentlyChangedFiles(tmp);
assert.deepStrictEqual(files, [], "should return empty array for non-git dir");
});
it("returns deduplicated paths", async () => {
const cwd = process.cwd();
const files = await getRecentlyChangedFiles(cwd, { maxFiles: 100 });
const unique = new Set(files);
assert.equal(files.length, unique.size, "should have no duplicates");
});
});
// ─── getChangedFilesWithContext ─────────────────────────────────────────────
describe("diff-context: getChangedFilesWithContext", () => {
it("returns array of ChangedFileInfo objects", async () => {
const cwd = process.cwd();
const infos = await getChangedFilesWithContext(cwd);
assert.ok(Array.isArray(infos), "should return an array");
for (const info of infos) {
assert.ok(typeof info.path === "string", "path should be a string");
assert.ok(
["modified", "added", "deleted", "staged"].includes(info.changeType),
`changeType should be valid, got: ${info.changeType}`,
);
if (info.linesChanged !== undefined) {
assert.ok(typeof info.linesChanged === "number", "linesChanged should be a number");
}
}
});
it("returns empty array for non-git directory", async () => {
const tmp = mkdtempSync(join(tmpdir(), "diff-ctx-test2-"));
const infos = await getChangedFilesWithContext(tmp);
assert.deepStrictEqual(infos, [], "should return empty array for non-git dir");
});
});
// ─── rankFilesByRelevance ───────────────────────────────────────────────────
describe("diff-context: rankFilesByRelevance", () => {
it("places changed files before unchanged files", () => {
const allFiles = ["a.ts", "b.ts", "c.ts", "d.ts"];
const changed = ["c.ts", "a.ts"];
const ranked = rankFilesByRelevance(allFiles, changed);
// Changed files come first, sorted by changedFiles priority (c before a)
assert.equal(ranked[0], "c.ts");
assert.equal(ranked[1], "a.ts");
// Unchanged files follow in original order
assert.equal(ranked[2], "b.ts");
assert.equal(ranked[3], "d.ts");
});
it("preserves order of changed files based on changedFiles priority", () => {
const allFiles = ["x.ts", "y.ts", "z.ts", "w.ts"];
const changed = ["z.ts", "x.ts"]; // z has higher priority (index 0)
const ranked = rankFilesByRelevance(allFiles, changed);
assert.equal(ranked[0], "z.ts", "z.ts should be first (higher priority in changedFiles)");
assert.equal(ranked[1], "x.ts", "x.ts should be second");
});
it("returns unchanged files in original order when no changed files match", () => {
const allFiles = ["a.ts", "b.ts", "c.ts"];
const changed = ["x.ts", "y.ts"]; // none match
const ranked = rankFilesByRelevance(allFiles, changed);
assert.deepStrictEqual(ranked, ["a.ts", "b.ts", "c.ts"]);
});
it("handles empty inputs gracefully", () => {
assert.deepStrictEqual(rankFilesByRelevance([], []), []);
assert.deepStrictEqual(rankFilesByRelevance(["a.ts"], []), ["a.ts"]);
assert.deepStrictEqual(rankFilesByRelevance([], ["a.ts"]), []);
});
it("handles all files being changed", () => {
const allFiles = ["a.ts", "b.ts"];
const changed = ["b.ts", "a.ts"];
const ranked = rankFilesByRelevance(allFiles, changed);
// Both are changed, so sorted by changedFiles order: b first, then a
assert.equal(ranked[0], "b.ts");
assert.equal(ranked[1], "a.ts");
assert.equal(ranked.length, 2);
});
});

View file

@ -0,0 +1,42 @@
import type { Tiktoken } from "tiktoken";
let encoder: Tiktoken | null = null;
let encoderFailed = false;
async function getEncoder(): Promise<Tiktoken | null> {
if (encoder) return encoder;
if (encoderFailed) return null;
try {
const { encoding_for_model } = await import("tiktoken");
encoder = encoding_for_model("gpt-4o");
return encoder;
} catch {
encoderFailed = true;
return null;
}
}
export async function countTokens(text: string): Promise<number> {
const enc = await getEncoder();
if (enc) {
const tokens = enc.encode(text);
return tokens.length;
}
return Math.ceil(text.length / 4);
}
export function countTokensSync(text: string): number {
if (encoder) {
return encoder.encode(text).length;
}
return Math.ceil(text.length / 4);
}
export async function initTokenCounter(): Promise<boolean> {
const enc = await getEncoder();
return enc !== null;
}
export function isAccurateCountingAvailable(): boolean {
return encoder !== null;
}

View file

@ -0,0 +1,150 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
parseRemoteUrl,
createGitHubClient,
getRepoInfo,
} from "../resources/extensions/gsd/github-client.ts";
describe("parseRemoteUrl — extracts owner/repo from git remote URLs", () => {
it("parses HTTPS URL with .git suffix", () => {
const result = parseRemoteUrl("https://github.com/octocat/hello-world.git");
assert.deepEqual(result, { owner: "octocat", repo: "hello-world" });
});
it("parses HTTPS URL without .git suffix", () => {
const result = parseRemoteUrl("https://github.com/octocat/hello-world");
assert.deepEqual(result, { owner: "octocat", repo: "hello-world" });
});
it("parses SSH URL with .git suffix", () => {
const result = parseRemoteUrl("git@github.com:octocat/hello-world.git");
assert.deepEqual(result, { owner: "octocat", repo: "hello-world" });
});
it("parses SSH URL without .git suffix", () => {
const result = parseRemoteUrl("git@github.com:octocat/hello-world");
assert.deepEqual(result, { owner: "octocat", repo: "hello-world" });
});
it("parses ssh:// protocol URL", () => {
const result = parseRemoteUrl(
"ssh://git@github.com/octocat/hello-world.git",
);
assert.deepEqual(result, { owner: "octocat", repo: "hello-world" });
});
it("handles repos with hyphens and underscores", () => {
const result = parseRemoteUrl(
"https://github.com/my-org/my_cool-repo.git",
);
assert.deepEqual(result, { owner: "my-org", repo: "my_cool-repo" });
});
it("returns null for non-GitHub URLs", () => {
const result = parseRemoteUrl("https://gitlab.com/owner/repo.git");
assert.equal(result, null);
});
it("returns null for malformed URLs", () => {
assert.equal(parseRemoteUrl("not-a-url"), null);
assert.equal(parseRemoteUrl(""), null);
});
it("returns null for bare paths", () => {
assert.equal(parseRemoteUrl("/home/user/repo.git"), null);
});
});
describe("createGitHubClient — Octokit instantiation", () => {
it("returns null when no token is provided and env vars are unset", () => {
const origGH = process.env.GITHUB_TOKEN;
const origGH2 = process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.GH_TOKEN;
try {
const client = createGitHubClient();
assert.equal(client, null);
} finally {
if (origGH !== undefined) process.env.GITHUB_TOKEN = origGH;
if (origGH2 !== undefined) process.env.GH_TOKEN = origGH2;
}
});
it("creates a client when a token is provided directly", () => {
const client = createGitHubClient("ghp_test123");
assert.notEqual(client, null);
assert.equal(typeof client!.pulls, "object");
assert.equal(typeof client!.issues, "object");
});
it("creates a client from GITHUB_TOKEN env var", () => {
const origGH = process.env.GITHUB_TOKEN;
const origGH2 = process.env.GH_TOKEN;
delete process.env.GH_TOKEN;
process.env.GITHUB_TOKEN = "ghp_env_test";
try {
const client = createGitHubClient();
assert.notEqual(client, null);
} finally {
if (origGH !== undefined) {
process.env.GITHUB_TOKEN = origGH;
} else {
delete process.env.GITHUB_TOKEN;
}
if (origGH2 !== undefined) process.env.GH_TOKEN = origGH2;
}
});
it("creates a client from GH_TOKEN env var", () => {
const origGH = process.env.GITHUB_TOKEN;
const origGH2 = process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
process.env.GH_TOKEN = "ghp_gh_token_test";
try {
const client = createGitHubClient();
assert.notEqual(client, null);
} finally {
if (origGH !== undefined) process.env.GITHUB_TOKEN = origGH;
if (origGH2 !== undefined) {
process.env.GH_TOKEN = origGH2;
} else {
delete process.env.GH_TOKEN;
}
}
});
it("prefers explicit token over env vars", () => {
const origGH = process.env.GITHUB_TOKEN;
process.env.GITHUB_TOKEN = "ghp_from_env";
try {
const client = createGitHubClient("ghp_explicit");
assert.notEqual(client, null);
} finally {
if (origGH !== undefined) {
process.env.GITHUB_TOKEN = origGH;
} else {
delete process.env.GITHUB_TOKEN;
}
}
});
});
describe("getRepoInfo — detects repo from git working directory", () => {
it("returns owner/repo for the current repository", async () => {
const info = await getRepoInfo(process.cwd());
// This test repo is gsd-build/gsd-2
assert.notEqual(info, null);
assert.equal(info!.owner, "gsd-build");
assert.equal(info!.repo, "gsd-2" /* or GSD-2 depending on remote */);
});
it("returns null for a non-git directory", async () => {
const info = await getRepoInfo("/tmp");
assert.equal(info, null);
});
});

View file

@ -0,0 +1,54 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
countTokens,
countTokensSync,
initTokenCounter,
isAccurateCountingAvailable,
} from "../resources/extensions/gsd/token-counter.ts";
describe("token-counter", () => {
it("countTokensSync returns heuristic estimate before init", () => {
const count = countTokensSync("hello world");
assert.equal(count, Math.ceil("hello world".length / 4));
});
it("initTokenCounter initializes the encoder", async () => {
const result = await initTokenCounter();
assert.equal(typeof result, "boolean");
});
it("countTokens returns a positive number for non-empty text", async () => {
const count = await countTokens("The quick brown fox jumps over the lazy dog.");
assert.ok(count > 0, "should return positive token count");
});
it("countTokens returns 0 for empty string", async () => {
const count = await countTokens("");
assert.equal(count, 0);
});
it("isAccurateCountingAvailable reflects encoder state", () => {
const available = isAccurateCountingAvailable();
assert.equal(typeof available, "boolean");
});
it("countTokensSync gives accurate count after init", async () => {
await initTokenCounter();
if (isAccurateCountingAvailable()) {
const syncCount = countTokensSync("hello world");
const asyncCount = await countTokens("hello world");
assert.equal(syncCount, asyncCount, "sync and async should match after init");
}
});
it("token count is more accurate than chars/4 for code", async () => {
await initTokenCounter();
if (isAccurateCountingAvailable()) {
const code = 'function add(a: number, b: number): number { return a + b; }';
const tokens = await countTokens(code);
const heuristic = Math.ceil(code.length / 4);
assert.ok(tokens !== heuristic, "tiktoken count should differ from simple heuristic for code");
}
});
});