-
Notifications
You must be signed in to change notification settings - Fork 0
[rig-tasks] Add 10 rig samples — 2026-08-28 #504
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| # 481 - Git Blame Line Age Analyzer | ||
|
|
||
| ```rig | ||
| import { agent, defineTool, p, repair, s } from "rig"; | ||
|
|
||
| const parseBlameBlock = defineTool("parseBlameBlock", { | ||
| description: "Parse git blame --line-porcelain output for a file and return per-line age stats.", | ||
| parameters: s.object({ filePath: s.path }), | ||
| handler: async ({ filePath }) => { | ||
| const { execSync } = await import("node:child_process"); | ||
| let output = ""; | ||
| try { | ||
| output = execSync(`git blame --line-porcelain -- ${filePath}`, { encoding: "utf-8" }); | ||
| } catch { | ||
| return { avgAgeDays: 0, staleLines: 0, recentLines: 0, totalLines: 0 }; | ||
| } | ||
| const now = Date.now(); | ||
| const timestamps: number[] = []; | ||
| for (const line of output.split("\n")) { | ||
| if (line.startsWith("author-time ")) { | ||
| timestamps.push(parseInt(line.slice("author-time ".length), 10) * 1000); | ||
| } | ||
| } | ||
| if (timestamps.length === 0) return { avgAgeDays: 0, staleLines: 0, recentLines: 0, totalLines: 0 }; | ||
| const ages = timestamps.map((t: number) => (now - t) / 86400000); | ||
| const avgAgeDays = ages.reduce((a: number, b: number) => a + b, 0) / ages.length; | ||
| const recentLines = ages.filter((d: number) => d < 30).length; | ||
| const staleLines = ages.filter((d: number) => d >= 180).length; | ||
| return { avgAgeDays, staleLines, recentLines, totalLines: ages.length }; | ||
| }, | ||
| }); | ||
|
|
||
| // Agent role: analyze line age across TypeScript source files using git blame. | ||
| const gitBlameLineAge = agent({ | ||
| model: "small", | ||
| instructions: p`Find TypeScript files using ${p.glob("src/**/*.ts")}. For each file path, call parseBlameBlock. Return files as a record keyed by path with avgAgeDays, staleLines, recentLines, totalLines. Also include oldestFile (path with highest avgAgeDays, omit if none) and newestFile (path with lowest avgAgeDays, omit if none).`, | ||
| output: s.object({ | ||
| files: s.record(s.object({ | ||
| avgAgeDays: s.number, | ||
| staleLines: s.int, | ||
| recentLines: s.int, | ||
| totalLines: s.int, | ||
| })), | ||
| oldestFile: s.optional(s.string), | ||
| newestFile: s.optional(s.string), | ||
| }), | ||
| tools: [parseBlameBlock], | ||
| maxTurns: 8, | ||
| addons: [repair()], | ||
| }); | ||
|
|
||
| export default gitBlameLineAge; | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| # 482 - TS Mapped Type Extractor | ||
|
|
||
| ```rig | ||
| import { agent, defineTool, p, s, steering } from "rig"; | ||
|
|
||
| const extractMappedTypes = defineTool("extractMappedTypes", { | ||
| description: "Extract TypeScript mapped type patterns from a source file.", | ||
| parameters: s.object({ filePath: s.path }), | ||
| handler: async ({ filePath }) => { | ||
| const { readFile } = await import("node:fs/promises"); | ||
| const content = await readFile(filePath, "utf-8"); | ||
| const pattern = /\{\s*\[(\w+)\s+in\s+([^\]]+)\]\s*(?::\s*([^;}\n]+))?/g; | ||
| const results: Array<{ keySource: string; valueType: string; isReadonly: boolean; sourceFile: string }> = []; | ||
| let m: RegExpExecArray | null; | ||
| while ((m = pattern.exec(content)) !== null) { | ||
| const before = content.slice(0, m.index); | ||
| const isReadonly = /readonly\s*$/.test(before.trimEnd()); | ||
| results.push({ | ||
| keySource: m[2]?.trim() ?? "unknown", | ||
| valueType: m[3]?.trim() ?? "unknown", | ||
| isReadonly, | ||
| sourceFile: filePath, | ||
| }); | ||
| } | ||
| return results; | ||
| }, | ||
| }); | ||
|
|
||
| // Agent role: scan TypeScript source files for mapped type patterns and summarize usage. | ||
| const tsMappedTypeExtractor = agent({ | ||
| model: "small", | ||
| instructions: p`Find TypeScript files using ${p.glob("src/**/*.ts")}. For each file path, call extractMappedTypes. Build a types record keyed by a unique name (e.g., "FilePath:index") with keySource, valueType, isReadonly, sourceFile. Include totalMappedTypes, totalFiles, and mostUsedKeySource (the keySource string that appears most often, omit if no types found).`, | ||
| output: s.object({ | ||
| types: s.record(s.object({ | ||
| keySource: s.string, | ||
| valueType: s.string, | ||
| isReadonly: s.boolean, | ||
| sourceFile: s.path, | ||
| })), | ||
| totalMappedTypes: s.int, | ||
| totalFiles: s.int, | ||
| mostUsedKeySource: s.optional(s.string), | ||
| }), | ||
| tools: [extractMappedTypes], | ||
| maxTurns: 8, | ||
| addons: [steering()], | ||
| }); | ||
|
|
||
| export default tsMappedTypeExtractor; | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| # 483 - Dotenv Drift Detector | ||
|
|
||
| ```rig | ||
| import { agent, defineTool, p, repair, s } from "rig"; | ||
|
|
||
| const classifyEnvKey = defineTool("classifyEnvKey", { | ||
| description: "Classify an env key as declared, undeclared, or unused given env example keys and code keys.", | ||
| parameters: s.object({ | ||
| key: s.string, | ||
| exampleKeys: s.array(s.string), | ||
| codeKeys: s.array(s.string), | ||
| }), | ||
| handler: async ({ key, exampleKeys, codeKeys }) => { | ||
| const inExample = exampleKeys.includes(key); | ||
| const usedInCode = codeKeys.includes(key); | ||
| let status: "declared" | "undeclared" | "unused"; | ||
| if (inExample && usedInCode) status = "declared"; | ||
| else if (!inExample && usedInCode) status = "undeclared"; | ||
| else status = "unused"; | ||
| return { inExample, usedInCode, status }; | ||
| }, | ||
| }); | ||
|
|
||
| // Agent role: detect drift between .env.example declarations and process.env usage in source code. | ||
| const dotenvDriftDetector = agent({ | ||
| model: "small", | ||
| instructions: p`Read the .env.example file: ${p.readOptional(".env.example", "# empty")}. Find all process.env usages in source code: ${p.bash("grep -rn 'process\\.env\\.' src/ 2>/dev/null || echo 'no matches'")}. Extract the declared keys from .env.example (lines matching KEY=) and the used keys from grep output (process.env.KEY patterns). For each unique key across both sets, call classifyEnvKey. Return keys as a record with inExample, usedInCode, status. Include totalKeys, missingFromExample (keys used in code but not in .env.example), and unusedDeclarations (keys in .env.example not used in code).`, | ||
| output: s.object({ | ||
| keys: s.record(s.object({ | ||
| inExample: s.boolean, | ||
| usedInCode: s.boolean, | ||
| status: s.enum("declared", "undeclared", "unused"), | ||
| })), | ||
| totalKeys: s.int, | ||
| missingFromExample: s.array(s.string), | ||
| unusedDeclarations: s.array(s.string), | ||
| }), | ||
| tools: [classifyEnvKey], | ||
| maxTurns: 6, | ||
| addons: [repair()], | ||
| }); | ||
|
|
||
| export default dotenvDriftDetector; | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| # 484 - Vitest Snapshot Reporter | ||
|
|
||
| ```rig | ||
| import { agent, p, s, workflow } from "rig"; | ||
|
|
||
| // Agent role: find snapshot files using bash and report total file count. | ||
| const snapshotFileAgent = agent({ | ||
| model: "small", | ||
| instructions: p`Run ${p.bash("find . -name '*.snap' 2>/dev/null || echo ''")} to list snapshot files. Return the list of file paths and total count.`, | ||
| output: s.object({ | ||
| files: s.array(s.path), | ||
| totalFiles: s.int, | ||
| }), | ||
| }); | ||
|
|
||
| // Agent role: count snapshot entries in a single snapshot file. | ||
| const snapshotCountAgent = agent({ | ||
| model: "small", | ||
| input: s.object({ path: s.path }), | ||
| instructions: p`Read the snapshot file at ${p.readInput("path")}. Count the number of snapshot entries (lines matching /^exports\[/). Return the file path and entry count.`, | ||
| output: s.object({ | ||
| path: s.path, | ||
| entryCount: s.int, | ||
| }), | ||
| }); | ||
|
|
||
| // Workflow role: discover snapshot files and count entries across the workspace. | ||
| export default workflow({ | ||
| meta: { | ||
| name: "vitest-snapshot-reporter", | ||
| description: "Discover vitest snapshot files and count snapshot entries across the workspace.", | ||
| }, | ||
| body: async ({ call }) => { | ||
| const fileResult = await call(snapshotFileAgent, "List all snapshot files."); | ||
| if (!fileResult) return null; | ||
| const counts = await Promise.all( | ||
| fileResult.files.map((path: string) => call(snapshotCountAgent, { path })) | ||
| ); | ||
| const validCounts = counts.filter((r): r is { path: string; entryCount: number } => r !== null); | ||
| const totalSnapshots = validCounts.reduce((sum: number, r: { entryCount: number }) => sum + r.entryCount, 0); | ||
| const largest = validCounts.reduce( | ||
| (best: { path: string; entryCount: number } | null, r: { path: string; entryCount: number }) => | ||
| !best || r.entryCount > best.entryCount ? r : best, | ||
| null | ||
| ); | ||
| return { | ||
| totalSnapshots, | ||
| totalFiles: fileResult.totalFiles, | ||
| largestSnapshotFile: largest?.path ?? undefined, | ||
| }; | ||
| }, | ||
| }); | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| # 485 - Git Stale Branch Reporter | ||
|
|
||
| ```rig | ||
| import { agent, defineTool, p, repair, s } from "rig"; | ||
|
|
||
| const getBranchAge = defineTool("getBranchAge", { | ||
| description: "Get the age in days and age class for a git branch.", | ||
| parameters: s.object({ branch: s.string }), | ||
| handler: async ({ branch }) => { | ||
| const { execSync } = await import("node:child_process"); | ||
| let lastCommit = ""; | ||
| try { | ||
| lastCommit = execSync(`git log --format=%ci -1 "${branch.trim()}" 2>/dev/null`, { encoding: "utf-8" }).trim(); | ||
| } catch { | ||
| return { lastCommit: "unknown", ageDays: 9999, ageClass: "ancient" as const }; | ||
| } | ||
| if (!lastCommit) return { lastCommit: "unknown", ageDays: 9999, ageClass: "ancient" as const }; | ||
| const ageDays = Math.floor((Date.now() - new Date(lastCommit).getTime()) / 86400000); | ||
| const ageClass = | ||
| ageDays < 7 ? "fresh" : | ||
| ageDays < 30 ? "recent" : | ||
| ageDays < 180 ? "stale" : "ancient"; | ||
| return { lastCommit, ageDays, ageClass } as { lastCommit: string; ageDays: number; ageClass: "fresh" | "recent" | "stale" | "ancient" }; | ||
| }, | ||
| }); | ||
|
|
||
| // Agent role: report age class of all remote git branches. | ||
| const gitStaleBranchReporter = agent({ | ||
| model: "small", | ||
| instructions: p`List remote branches: ${p.bash("git branch -r 2>/dev/null || echo ''")}. For each branch name, call getBranchAge. Return branches as a record keyed by branch name with lastCommit, ageDays, ageClass. Include staleCount (ageClass=stale), ancientCount (ageClass=ancient), and totalBranches.`, | ||
| output: s.object({ | ||
| branches: s.record(s.object({ | ||
| lastCommit: s.string, | ||
| ageDays: s.int, | ||
| ageClass: s.enum("fresh", "recent", "stale", "ancient"), | ||
| })), | ||
| staleCount: s.int, | ||
| ancientCount: s.int, | ||
| totalBranches: s.int, | ||
| }), | ||
| tools: [getBranchAge], | ||
| maxTurns: 8, | ||
| addons: [repair()], | ||
| }); | ||
|
|
||
| export default gitStaleBranchReporter; | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| # 486 - TS Abstract Class Finder | ||
|
|
||
| ```rig | ||
| import { agent, defineTool, p, s, steering } from "rig"; | ||
|
|
||
| const extractAbstractClasses = defineTool("extractAbstractClasses", { | ||
| description: "Extract abstract class declarations and their abstract methods from a TypeScript file.", | ||
| parameters: s.object({ filePath: s.path }), | ||
| handler: async ({ filePath }) => { | ||
| const { readFile } = await import("node:fs/promises"); | ||
| const content = await readFile(filePath, "utf-8"); | ||
| const classPattern = /abstract\s+class\s+(\w+)[^{]*\{/g; | ||
| const abstractMethodPattern = /abstract\s+(?:readonly\s+)?(?:\w+\s*[(<])/g; | ||
| const classes: Array<{ name: string; methodCount: number; abstractMethodCount: number; sourceFile: string }> = []; | ||
| let m: RegExpExecArray | null; | ||
| while ((m = classPattern.exec(content)) !== null) { | ||
| const name = m[1]; | ||
| const methodMatches = content.match(/\b(?:public|protected|private|async)?\s+\w+\s*[(<]/g) ?? []; | ||
| const abstractMatches = content.match(abstractMethodPattern) ?? []; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/codebase-design] Incorrect per-class metrics: 💡 Suggested fixSlice the content between the class opening brace and its matching close brace before counting methods: // find the index of '{' after the class declaration, then track brace depth to find the end
const start = m.index + m[0].length - 1; // index of '{'
let depth = 1, i = start + 1;
while (i < content.length && depth > 0) {
if (content[i] === '{') depth++;
else if (content[i] === '}') depth--;
i++;
}
const body = content.slice(start, i);
const methodMatches = body.match(/(?:public|protected|private|async)?\s+\w+\s*[(<]/g) ?? [];
const abstractMatches = body.match(abstractMethodPattern) ?? [];As a sample, this inaccuracy teaches readers that the pattern is correct when it isn't. |
||
| classes.push({ | ||
| name, | ||
| methodCount: methodMatches.length, | ||
| abstractMethodCount: abstractMatches.length, | ||
| sourceFile: filePath, | ||
| }); | ||
| } | ||
| return classes; | ||
| }, | ||
| }); | ||
|
|
||
| // Agent role: find abstract classes and their abstract method counts across TypeScript source files. | ||
| const tsAbstractClassFinder = agent({ | ||
| model: "small", | ||
| instructions: p`Find TypeScript files using ${p.glob("src/**/*.ts")}. For each file path, call extractAbstractClasses. Build a classes record keyed by class name with methodCount, abstractMethodCount, sourceFile. Include totalClasses, totalAbstractMethods, and mostAbstractFile (path with most abstract methods, omit if none found).`, | ||
| output: s.object({ | ||
| classes: s.record(s.object({ | ||
| methodCount: s.int, | ||
| abstractMethodCount: s.int, | ||
| sourceFile: s.path, | ||
| })), | ||
| totalClasses: s.int, | ||
| totalAbstractMethods: s.int, | ||
| mostAbstractFile: s.optional(s.string), | ||
| }), | ||
| tools: [extractAbstractClasses], | ||
| maxTurns: 8, | ||
| addons: [steering()], | ||
| }); | ||
|
|
||
| export default tsAbstractClassFinder; | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| # 487 - Git Tag Message Extractor | ||
|
|
||
| ```rig | ||
| import { agent, defineTool, p, s, steering } from "rig"; | ||
|
|
||
| const getTagDetails = defineTool("getTagDetails", { | ||
| description: "Get details of a git tag including type (annotated or lightweight), message, and tagger.", | ||
| parameters: s.object({ tag: s.string }), | ||
| handler: async ({ tag }) => { | ||
| const { execSync } = await import("node:child_process"); | ||
| let tagType: "annotated" | "lightweight" = "lightweight"; | ||
| let message: string | undefined; | ||
| let tagger: string | undefined; | ||
| try { | ||
| const output = execSync(`git cat-file -t "${tag}" 2>/dev/null`, { encoding: "utf-8" }).trim(); | ||
| if (output === "tag") { | ||
| tagType = "annotated"; | ||
| const tagObj = execSync(`git cat-file tag "${tag}" 2>/dev/null`, { encoding: "utf-8" }); | ||
| const taggerLine = tagObj.split("\n").find((l: string) => l.startsWith("tagger ")); | ||
| if (taggerLine) tagger = taggerLine.replace(/^tagger\s+/, "").trim(); | ||
| const msgStart = tagObj.indexOf("\n\n"); | ||
| if (msgStart !== -1) message = tagObj.slice(msgStart + 2).trim(); | ||
| } | ||
| } catch { | ||
| // lightweight tag | ||
| } | ||
| return { tagType, message, tagger }; | ||
| }, | ||
| }); | ||
|
|
||
| // Agent role: extract and classify all git tag messages and metadata. | ||
| const gitTagMessageExtractor = agent({ | ||
| model: "small", | ||
| instructions: p`List all git tags: ${p.bash("git tag -l 2>/dev/null || echo ''")}. For each tag name, call getTagDetails. Return tags as a record keyed by tag name with tagType, message (omit if lightweight), and tagger (omit if not annotated). Include totalTags, annotatedCount, lightweightCount, and mostRecentTag (last tag alphabetically or by creation, omit if no tags).`, | ||
| output: s.object({ | ||
| tags: s.record(s.object({ | ||
| tagType: s.enum("annotated", "lightweight"), | ||
| message: s.optional(s.string), | ||
| tagger: s.optional(s.string), | ||
| })), | ||
| totalTags: s.int, | ||
| annotatedCount: s.int, | ||
| lightweightCount: s.int, | ||
| mostRecentTag: s.optional(s.string), | ||
| }), | ||
| tools: [getTagDetails], | ||
| maxTurns: 8, | ||
| addons: [steering()], | ||
| }); | ||
|
|
||
| export default gitTagMessageExtractor; | ||
| ``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/diagnosing-bugs] Shell injection risk:
filePathis interpolated directly into theexecSynccommand — a path with spaces or shell metacharacters will break or be exploited at runtime.💡 Suggested fix
Use
spawnSyncwith an argument array to avoid shell interpretation:The same pattern applies to samples 485 (
branchingit log) and 487 (tagingit cat-file).