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
54 changes: 54 additions & 0 deletions skills/rig/samples/371-git-file-ownership-mapper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 371 - Git File Ownership Mapper

```rig
import { agent, p, s, defineTool, steering } from "rig";
import { execSync } from "node:child_process";

const getFileOwner = defineTool("getFileOwner", {
description: "Get git commit history for a file and return ownership info.",
parameters: { filePath: s.path },
handler: ({ filePath }: { filePath: string }) => {
try {

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 shell command string without sanitization — paths containing shell metacharacters (spaces, $(), backticks) will be interpreted by the shell.

💡 Fix: use spawnSync with argument array

Switch from a template-string execSync call to spawnSync with a proper argument array, so the path is never parsed by the shell:

import { spawnSync } from "node:child_process";
const result = spawnSync("git", ["log", "--format=%ae", "--", filePath], { encoding: "utf-8" });
const output = result.stdout.trim();

Agent-generated tool inputs are a realistic injection vector since the LLM controls the filePath argument.

const output = execSync(`git log --format="%ae" -- "${filePath}" 2>/dev/null`, { encoding: "utf-8" }).trim();
const emails = output ? output.split("\n").filter(Boolean) : [];
const counts: Record<string, number> = {};
for (const email of emails) counts[email] = (counts[email] ?? 0) + 1;
const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
const primaryOwner = sorted[0]?.[0] ?? "unknown";
const commitCount = emails.length;
const contributors = sorted.map(([email]) => email);
return { primaryOwner, commitCount, contributors };
} catch {
return { primaryOwner: "unknown", commitCount: 0, contributors: [] };
}
},
});

// Agent role: map git file ownership for all TypeScript source files.
const gitFileOwnershipMapper = agent({
model: "small",
instructions: p`Map git file ownership for TypeScript source files.

Source files:
${p.glob("src/**/*.ts")}

Steps:
1. For each file path listed above, call getFileOwner to retrieve primaryOwner, commitCount, contributors.
2. Build ownership record keyed by file path.
3. Find mostActiveContributor: the email with the highest total commit count across all files.`,
output: s.object({
ownership: s.record(
s.object({
primaryOwner: s.string,
commitCount: s.int,
contributors: s.array(s.string),
})
),
mostActiveContributor: s.string,
}),
tools: [getFileOwner],
addons: [steering()],
});

export default gitFileOwnershipMapper;
```
64 changes: 64 additions & 0 deletions skills/rig/samples/372-workflow-input-validator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# 372 - Workflow Input Validator

```rig
import { agent, p, s, defineTool, repair } from "rig";
import { readFile } from "node:fs/promises";

const validateWorkflowInputs = defineTool("validateWorkflowInputs", {
description: "Parse a GitHub Actions workflow YAML file and extract workflow_dispatch inputs.",
parameters: { filePath: s.path },
handler: async ({ filePath }: { filePath: string }) => {
const content = await readFile(filePath, "utf-8");
const inputsMatch = content.match(/workflow_dispatch:\s*\n(?:\s+.*\n)*?\s+inputs:([\s\S]*?)(?=\n\w|\n\s{0,2}\w|$)/);

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] The inputsMatch regex is fragile: it requires workflow_dispatch: to be followed immediately by inputs: within a narrow look-ahead, and uses a fixed indentation depth of 4–8 spaces for inputPattern. Real-world workflow files often use 2-space indentation (GitHub's default), which will be silently missed, returning { inputs: {}, inputCount: 0 } for valid workflow files.

💡 Suggestion: use a YAML parser or loosen the indentation constraint

The simplest fix is to widen the indent range in inputPattern:

const inputPattern = /^\s{2,}(\w+):\s*\n((?:\s{3,}.+\n?)*)/gm;

A more robust fix would be to use a lightweight YAML parser (e.g. js-yaml) instead of regex, which is explicitly what the sample's docstring implies the agent does. Since this is a sample, a comment noting the limitation is also acceptable.

if (!inputsMatch) return { inputs: {}, inputCount: 0, hasRequiredWithoutDefault: false };
const inputsBlock = inputsMatch[1];
const inputEntries: Record<string, { type: string; required: boolean; default?: string | undefined }> = {};
const inputPattern = /^\s{4,8}(\w+):\s*\n((?:\s{6,12}.+\n?)*)/gm;
let match: RegExpExecArray | null;
while ((match = inputPattern.exec(inputsBlock)) !== null) {
const name = match[1];
const block = match[2];
const typeM = block.match(/type:\s*(.+)/);
const requiredM = block.match(/required:\s*(true|false)/);
const defaultM = block.match(/default:\s*(.+)/);
inputEntries[name] = {
type: typeM ? typeM[1].trim() : "string",
required: requiredM ? requiredM[1] === "true" : false,
default: defaultM ? defaultM[1].trim() : undefined,
};
}
const inputCount = Object.keys(inputEntries).length;
const hasRequiredWithoutDefault = Object.values(inputEntries).some(
(v) => v.required && v.default === undefined
);
return { inputs: inputEntries, inputCount, hasRequiredWithoutDefault };
},
});

// Agent role: validate GitHub Actions workflow_dispatch inputs across all workflow files.
const workflowInputValidator = agent({
model: "small",
instructions: p`Validate workflow_dispatch inputs in all GitHub Actions workflow files.

Workflow files:
${p.glob(".github/workflows/*.yml")}

Steps:
1. For each file path listed above, call validateWorkflowInputs to extract inputs, inputCount, hasRequiredWithoutDefault.
2. Build workflows record keyed by filename.
3. Set totalWorkflows to the count of workflow files processed.`,
output: s.object({
workflows: s.record(
s.object({
inputCount: s.int,
hasRequiredWithoutDefault: s.boolean,
})
),
totalWorkflows: s.int,
}),
tools: [validateWorkflowInputs],
addons: [repair()],
});

export default workflowInputValidator;
```
59 changes: 59 additions & 0 deletions skills/rig/samples/373-ts-dead-export-finder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# 373 - TypeScript Dead Export Finder

```rig
import { agent, p, s, defineTool, steering, repair } from "rig";
import { readFile } from "node:fs/promises";

const findUnusedExports = defineTool("findUnusedExports", {
description: "Find exported symbols in a TypeScript file that are not imported in other files.",
parameters: { filePath: s.path, allFiles: s.array(s.string) },
handler: async ({ filePath, allFiles }: { filePath: string; allFiles: string[] }) => {
const content = await readFile(filePath, "utf-8");
const exportPattern = /export\s+(?:const|function|class|type|interface|enum)\s+(\w+)/g;
const exported: string[] = [];
let m: RegExpExecArray | null;
while ((m = exportPattern.exec(content)) !== null) exported.push(m[1]);
const unused: string[] = [];
for (const sym of exported) {
let found = false;
for (const other of allFiles) {
if (other === filePath) continue;
try {
const otherContent = await readFile(other, "utf-8");
if (otherContent.includes(sym)) { found = true; break; }

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] False negative: otherContent.includes(sym) matches any substring, so a symbol like log will be considered "used" if any file contains logError, blogPost, etc. This produces incorrect dead-export results.

💡 Fix: use a word-boundary regex match
const usagePattern = new RegExp(`\\b${sym}\\b`);
if (usagePattern.test(otherContent)) { found = true; break; }

Word-boundary matching is the minimum needed to avoid false negatives on short symbol names.

} catch { /* skip */ }
}
if (!found) unused.push(sym);
}
return { unused };
},
});

// Agent role: find unused TypeScript exports across the codebase.
const tsDeadExportFinder = agent({
model: "small",
instructions: p`Find TypeScript exported symbols that are never imported elsewhere.

TypeScript files:
${p.glob("src/**/*.ts")}

All TypeScript files (for cross-reference):
${p.bash("find . -name '*.ts' -not -path '*/node_modules/*' 2>/dev/null | head -100")}

Steps:
1. For each file in the source list, call findUnusedExports with that file and the full allFiles list.
2. Build unusedExports record keyed by file path, value is array of unused symbol names.
3. Omit files with no unused exports.
4. totalUnused = total count of unused symbols across all files.
5. hasDeadCode = totalUnused > 0.`,
output: s.object({
unusedExports: s.record(s.array(s.string)),
totalUnused: s.int,
hasDeadCode: s.boolean,
}),
tools: [findUnusedExports],
addons: [steering(), repair()],
});

export default tsDeadExportFinder;
```
54 changes: 54 additions & 0 deletions skills/rig/samples/374-package-scripts-documenter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 374 - Package Scripts Documenter

```rig
import { agent, p, s, defineTool, repair } from "rig";

const inferScriptPurpose = defineTool("inferScriptPurpose", {
description: "Classify a package.json script by its command into a category.",
parameters: { name: s.string, command: s.string },
handler: ({ name, command }: { name: string; command: string }) => {
const cmd = command.toLowerCase();
const nm = name.toLowerCase();
let category: "build" | "test" | "lint" | "release" | "dev" | "other" = "other";
if (/\btest\b|jest|vitest|mocha/.test(cmd) || /\btest/.test(nm)) category = "test";
else if (/\bbuild\b|tsc|webpack|vite|rollup|esbuild/.test(cmd) || /\bbuild/.test(nm)) category = "build";
else if (/\blint\b|eslint|prettier|biome/.test(cmd) || /\blint/.test(nm)) category = "lint";
else if (/\brelease\b|publish|changeset|version/.test(cmd) || /\brelease\b|\bpublish/.test(nm)) category = "release";
else if (/\bdev\b|watch|start\b|nodemon/.test(cmd) || /\bdev\b|\bstart\b|\bwatch/.test(nm)) category = "dev";
const purpose = `Runs ${name}: ${command.slice(0, 60)}`;
return { purpose, category } as const;
},
});

// Agent role: document all package.json scripts with purpose and category, then write SCRIPTS.md.
const packageScriptsDocumenter = agent({
model: "small",
instructions: p`Document all scripts in package.json and write SCRIPTS.md.

package.json contents:
${p.read("package.json")}

Steps:
1. Parse the scripts object from the package.json content above.
2. For each script name and command, call inferScriptPurpose to get purpose and category.
3. Build the scripts record keyed by script name.
4. Write SCRIPTS.md using p.write with a markdown table listing each script, its category, and purpose.
5. documentedCount = number of scripts processed.
6. outputFile = "SCRIPTS.md".`,
output: s.object({
scripts: s.record(
s.object({
purpose: s.string,
category: s.enum("build", "test", "lint", "release", "dev", "other"),
command: s.string,
})
),
documentedCount: s.int,

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] Schema/tool mismatch: the output schema declares scripts: s.record(s.object({ purpose, category, command })), but inferScriptPurpose only returns { purpose, category } — it never returns command. The LLM must hallucinate command from context, which is unreliable.

💡 Fix: either return `command` from the tool or remove it from the output schema

Option A — return command from the tool:

return { purpose, category, command } as const;

Option B — drop command from the output schema since the agent instructions can describe the scripts table without it.

The output schema should be the single source of truth; every field the LLM must populate should have a clear data source in the instructions or tool returns.

outputFile: s.path,
}),
tools: [inferScriptPurpose],
addons: [repair()],
});

export default packageScriptsDocumenter;
```
51 changes: 51 additions & 0 deletions skills/rig/samples/375-git-diff-stats-summarizer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# 375 - Git Diff Stats Summarizer

```rig
import { agent, p, s, defineTool, repair } from "rig";

const classifyDiffEntry = defineTool("classifyDiffEntry", {
description: "Classify a git diff --numstat entry as added, modified, deleted, or renamed.",
parameters: { additions: s.int, deletions: s.int, path: s.string },
handler: ({ additions, deletions, path }: { additions: number; deletions: number; path: string }) => {
let changeType: "added" | "modified" | "deleted" | "renamed" = "modified";
if (path.includes(" => ") || path.includes("{")) changeType = "renamed";
else if (deletions === 0 && additions > 0) changeType = "added";
else if (additions === 0 && deletions > 0) changeType = "deleted";
return { changeType } as const;
},
});

// Agent role: summarize git diff statistics between HEAD~1 and HEAD.
const gitDiffStatsSummarizer = agent({
model: "small",
instructions: p`Summarize file changes between the last two commits.

Diff numstat output:
${p.bash("git diff --numstat HEAD~1 HEAD 2>/dev/null || echo ''")}

Steps:
1. Parse each line of the numstat output (format: additions TAB deletions TAB path).
2. For each entry, call classifyDiffEntry with additions, deletions, path to get changeType.
3. Build files array with path, additions, deletions, changeType.
4. totalAdditions = sum of all additions.
5. totalDeletions = sum of all deletions.
6. mostChangedFile = path with highest additions+deletions (omit if no files).`,
output: s.object({
files: s.array(
s.object({
path: s.string,
additions: s.int,
deletions: s.int,
changeType: s.enum("added", "modified", "deleted", "renamed"),
})
),
totalAdditions: s.int,
totalDeletions: s.int,
mostChangedFile: s.optional(s.string),
}),
tools: [classifyDiffEntry],
addons: [repair()],
});

export default gitDiffStatsSummarizer;
```
53 changes: 53 additions & 0 deletions skills/rig/samples/376-dotenv-template-generator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 376 - Dotenv Template Generator

```rig
import { agent, p, s, defineTool, repair } from "rig";
import { readFile } from "node:fs/promises";

const extractEnvReferences = defineTool("extractEnvReferences", {
description: "Extract process.env.X references from a TypeScript file.",
parameters: { filePath: s.path },
handler: async ({ filePath }: { filePath: string }) => {
try {
const content = await readFile(filePath, "utf-8");
const pattern = /process\.env\.([A-Z_][A-Z0-9_]*)/g;
const keys = new Set<string>();
let m: RegExpExecArray | null;
while ((m = pattern.exec(content)) !== null) keys.add(m[1]);
return { keys: Array.from(keys) };
} catch {
return { keys: [] };
}
},
});

// Agent role: generate a .env.template from process.env references in source files.
const dotenvTemplateGenerator = agent({
model: "small",
instructions: p`Generate a .env.template file from process.env references in TypeScript source files.

Existing .env file (if present):
${p.readOptional(".env", "(no .env file found)")}

TypeScript source files:
${p.glob("src/**/*.ts")}

Steps:
1. For each TypeScript file path listed above, call extractEnvReferences to get the list of env keys.
2. Collect all unique keys referenced across all files → envKeys.
3. Parse the existing .env content to find documented keys.
4. undocumentedKeys = envKeys not already in .env.
5. Write .env.template with each key as KEY= (one per line, with a comment header).
6. templatePath = ".env.template", templateGenerated = true.`,
output: s.object({
templatePath: s.path,
envKeys: s.array(s.string),
undocumentedKeys: s.array(s.string),
templateGenerated: s.boolean,
}),
tools: [extractEnvReferences],
addons: [repair()],
});

export default dotenvTemplateGenerator;
```
Loading
Loading