The SDK reads runtime input, locates the source repository, executes registered rules, groups observations, synthesizes and ranks findings, and emits a validated review result. It does not invoke an LLM or hide repository analysis behind a remote service.
observe -> group -> synthesize -> rank -> reviewInstallation
npm install @adversarylabs/sdk@^0.1.5Requires Node.js 22 or newer and an ESM project.
Your First Adversary
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { Adversary, Severity } from "@adversarylabs/sdk";
const app = new Adversary({
name: "adversarylabs/comment-sentences",
review: { minimumConfidence: "medium" },
});
app.defineRule({
id: "comments.complete-sentence",
category: "code-style",
defaultSeverity: Severity.Info,
defaultConfidence: "high",
groupBy: ["ruleId", "subject"],
aggregate(observations) {
return {
title: observations.length === 1
? "Comment is a complete sentence"
: "Comments contain complete sentences",
summary: observations.length + " comments are written as complete sentences.",
recommendation:
"Keep complete-sentence comments only when they explain non-obvious intent.",
};
},
});
app.rule("comments.complete-sentence", async (ctx) => {
for (const file of await ctx.rglob("*.ts")) {
const content = await readFile(join(ctx.repoPath, file), "utf8");
content.split(/\r?\n/).forEach((line, index) => {
const comment = line.match(/^\s*\/\/\s+(.+)/)?.[1];
if (comment && /^[A-Z][^.!?]*[.!?]$/.test(comment)) {
ctx.observe({
ruleId: "comments.complete-sentence",
subject: file,
title: "Comment is a complete sentence",
location: { file, line: index + 1 },
evidence: { comment },
});
}
});
}
});
export default app;
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await app.runFromEnvironment();
}Detectors report observations. The rule definition owns the repository-specific engineering language used to combine them.
Adversary
const app = new Adversary({
name: "adversarylabs/example",
version: "0.1.0",
review: {
minimumConfidence: "medium",
maximumFindings: 10,
includeInformational: false,
},
});Each instance owns its rules and review policy. Defining or replacing a rule on one adversary cannot modify another adversary.
Rules and Definitions
app.rule(id, handler) registers executable detector logic. app.defineRule(definition) registers aggregation and presentation defaults for a stable rule ID.
- Duplicate rule IDs throw
- Use app.replaceRule for intentional replacement
- Definitions are instance-scoped
- Aggregation receives readonly observations
- Generic synthesis remains available
- Rule confidence overrides generic confidence
Rule Context
Every rule handler receives:
ctx.repoPath
ctx.summary.files_scanned
ctx.cache
ctx.relpath(path)
ctx.glob(pattern)
ctx.rglob(pattern)
ctx.observe(observation)
ctx.finding(finding)
ctx.review.assessment(assessment)
ctx.review.positive(note)
ctx.review.observe(note)
ctx.review.score(score)
ctx.review.opinion(opinion)File contents are read with normal Node.js APIs using ctx.repoPath.
Observations
Prefer ctx.observe for raw detector output. Observations are validated, normalized, deduplicated, grouped, and synthesized after all rule handlers complete.
ctx.observe({
ruleId: "comments.complete-sentence",
subject: "src/index.ts",
groupKey: "complete-sentence-comments",
title: "Comment is a complete sentence",
confidence: 0.95,
location: { file: "src/index.ts", line: 3 },
evidence: { comment: "This comment explains intent." },
recommendation: {
summary: "Keep comments only when they explain non-obvious intent.",
},
});Default grouping uses ruleId + subject + category. A rule definition can set groupBy, and an observation can provide an explicit groupKey.
Findings
Use ctx.finding when the adversary has already synthesized a complete issue. Direct findings still pass through validation, ranking, suppression, and rendering.
ctx.finding({
ruleId: "comments.complete-sentence",
title: "Comments contain complete sentences",
category: "code-style",
severity: "low",
confidence: "high",
summary: "Three comments are written as complete sentences.",
evidence: [
{
location: { file: "src/index.ts", line: 3 },
message: "Explains parser intent.",
data: { parser: "line-comment" },
},
],
recommendation:
"Keep complete-sentence comments only when they explain non-obvious intent.",
remediation: { complexity: "trivial" },
});Output evidence uses one canonical shape: nested location and optional structured data. Set deduplicate: false only when findings that share an identity must remain separate.
Review-Level Output
Review-level APIs add concise context that is not itself a finding. Positive signals are limited to the strongest two.
ctx.review.assessment({
risk: "low",
summary: "The code is easy to follow. One small style issue remains.",
});
ctx.review.positive({
key: "intentional-comments",
summary: "Several comments explain intent rather than restating code.",
evidence: [{ location: { file: "src/index.ts", line: 3 } }],
});
ctx.review.opinion({
ship: true,
summary: "I would ship this as-is.",
});Review Policy
Policy controls what reaches the primary review:
- minimumConfidence: low | medium | high
- maximumFindings: non-negative integer
- includeInformational: boolean
- confidenceThresholds: numeric boundaries
- severityOverrides: rule/group severity map
- includeSuppressed: run option
Confidence accepts low, medium, high, or a number from 0 to 1. Default numeric thresholds are 0.60 for medium and 0.85 for high.
Execution
Library execution and environment runtime execution are deliberately separate.
// Programmatic: explicit input, no environment overrides or file writes.
const result = await app.run({
input: { source: { path: "/repo" } },
includeSuppressed: true,
});
// CLI/container: reads runtime environment and writes the run envelope.
await app.runFromEnvironment();Timing is omitted from stable results unless includeTiming: trueis explicitly requested.
Result and Rendering
app.run returns a normalized ReviewResult. Use the built-in renderers instead of formatting findings inside rules.
const result = await app.run({
input: { source: { path: "/repo" } },
});
await new TerminalRenderer().render(result);
await new JsonRenderer().render(result);Results include assessment, positive signals, review observations, optional scores, ranked findings, opinion, and the count of suppressed findings.
Manifest
The manifest declares execution, permissions, triggers, and the canonical run-envelope format. The SDK owns parse/validate for adversary.yaml (parseAdversaryManifest / validateAdversaryManifest).
name: comment-sentences
version: 0.1.0
description: Reports complete-sentence TypeScript comments.
triggers:
manual: true
files_changed:
- "*.ts"
- "**/*.ts"
runtime:
name: node
version: "22"
command:
- dist/index.js
permissions:
enforcement: advisory
filesystem:
read:
- .
write: []
network: false
model: false
environment:
allow: []
findings:
format: adversary.review.v1Composition (uses)
Language packs and personas can declare other adversaries under uses. The CLI expands composition when you run the entry package; the SDK models and validates the field only.
- Detection depth lives in each uses member (and the root if it has rules)
- GitHub comment voice comes from the CLI entry package, not members
- Transitive expand, dedupe, cycle-safe; --no-compose skips expand
- Exactly one of name or path per item; version is an exact tag with name only
name: lang/go
version: 0.0.7
description: Go language pack — specialists under one entrypoint.
# Composition: CLI expands uses transitively when you run this package.
uses:
- name: go/concurrency
- name: go/security
version: "0.0.13" # optional exact tag (no ^/~ ranges yet)
- path: ../local-leaf # package-relative; exclusive with name
runtime:
name: node
version: "22"
command: [dist/index.js]
findings:
format: adversary.review.v1# Persona entrypoint: voice here, depth from uses
name: local/torvalds-adversary
uses:
- name: lang/go # may expand further
- name: review/engineering
- name: review/complexity
- name: security/secrets
# agent/voice.md (+ section banks) own GitHub rewrite voice for this entryExample CLI: adversary run lang/go --path ./service or adversary run ./torvalds-adversary --path ./app --github-review. Automatic selection (adversary run with no refs) does not expand uses yet.
Comment voice
GitHub PR wording is owned by the CLI, not the TypeScript rule runtime. Put persona rules in agent/voice.md. With --github-review and a model provider, the CLI rewrites each finding body using that document (template body if no model credentials).
- Core voice: cadence, bans, length — not detection logic
- Example bank: real human quotes as style few-shots only
- Never hard-code banked quotes as finding titles in src/
- Technical depth comes from findings; voice only dresses them
- Under composition, the CLI entry package owns rewrite voice
# Review voice: blunt maintainer
## Core voice
- Lead with the defect; explain the mechanism.
- Short declarative sentences. No praise sandwiches.
## Length
- 2–5 tight sentences. Stay under ~1,200 characters.
## Example maintainer comments (style only)
These are style few-shots only — re-ground in current evidence; never emit a quote unchanged.
### Ship / OK
> Looks all reasonable to me
### Defects / correctness
> This looks wrong to me. The length is just mb.len.
## Output
Return only the GitHub PR comment body in Markdown.Train apply issues ask implementers to bank human gold under the example bank. Full CLI guide: composition + voice docs in the adversary repository (docs/voice.md, docs/composition.md).
Train home-built packages
The SDK authors rules; the CLI adversary train command improves local packages from your team’s PR review history. Official catalog packages may run as a jury only—they never receive train drafts.
- init → edit adversary.train.yaml → train run → results apply
- Scope (agent/scope.md or docs/scope.md) defines fair misses
- Apply: docs/train-drafts + optional GitHub issue on the package repo
- Implement detection class + bank human gold in agent/voice.md
- Do not bank synthetic draft titles into the voice corpus
Single-package home-built workflow: adversary train init --single-package then adversary train run. See CLI docs and docs/train.md in the adversary repository.
Runtime Contract
runFromEnvironment() reads ADVERSARY_INPUT or /adversary/input.json, then writes ADVERSARY_OUTPUTor /adversary/output.json.
{
"protocolVersion": 1,
"result": {
"schemaVersion": "adversary.review.v1",
"adversary": { "name": "adversarylabs/comment-sentences" },
"target": { "repository": "/repo", "filesScanned": 4 },
"positives": [],
"observations": [],
"findings": [],
"suppressed": { "findings": 0 }
}
}The shipped adversary.run.v1 JSON Schema validates this envelope. The nested review result has schema version adversary.review.v1.
Compatibility
| Contract | Support |
|---|---|
| Node.js | 22 and 24 |
| Modules | ESM |
| Package | @adversarylabs/sdk 0.1.5 |
| Run envelope | adversary.run.v1 |
| Review result | adversary.review.v1 |
| CommonJS | Not supported |
The SDK remains pre-1.0. Pin a compatible 0.x range and review release notes before upgrading.