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
53 changes: 53 additions & 0 deletions skills/rig/samples/481-git-blame-line-age-analyzer.md
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" });

Copy link
Copy Markdown
Contributor Author

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: filePath is interpolated directly into the execSync command — a path with spaces or shell metacharacters will break or be exploited at runtime.

💡 Suggested fix

Use spawnSync with an argument array to avoid shell interpretation:

const { spawnSync } = await import("node:child_process");
const result = spawnSync("git", ["blame", "--line-porcelain", "--", filePath], { encoding: "utf-8" });
output = result.stdout;

The same pattern applies to samples 485 (branch in git log) and 487 (tag in git cat-file).

} 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;
```
50 changes: 50 additions & 0 deletions skills/rig/samples/482-ts-mapped-type-extractor.md
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;
```
44 changes: 44 additions & 0 deletions skills/rig/samples/483-dotenv-drift-detector.md
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;
```
53 changes: 53 additions & 0 deletions skills/rig/samples/484-vitest-snapshot-reporter.md
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,
};
},
});
```
47 changes: 47 additions & 0 deletions skills/rig/samples/485-git-stale-branch-reporter.md
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;
```
51 changes: 51 additions & 0 deletions skills/rig/samples/486-ts-abstract-class-finder.md
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) ?? [];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] Incorrect per-class metrics: methodCount and abstractMethodCount are counted from the entire file content, not from within each class body. If a file has multiple classes, every class gets the same (wrong) counts.

💡 Suggested fix

Slice 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;
```
52 changes: 52 additions & 0 deletions skills/rig/samples/487-git-tag-message-extractor.md
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;
```
Loading
Loading