@adversarylabs/sdk 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,549 @@
1
+ # adversary-sdk-typescript
2
+
3
+ Small TypeScript SDK for building file-based Adversaries.
4
+
5
+ The SDK owns the runtime boilerplate: read runtime input, discover the source repository path,
6
+ execute registered rules, collect observations, synthesize findings, normalize and rank the review,
7
+ and write runtime output.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @adversarylabs/sdk
13
+ ```
14
+
15
+ Requires Node 22 or newer and ESM.
16
+
17
+ ## Author an adversary
18
+
19
+ ```ts
20
+ import { Adversary, Severity, defineRule, log } from "@adversarylabs/sdk";
21
+
22
+ const app = new Adversary({
23
+ name: "adversarylabs/comment-sentences"
24
+ });
25
+
26
+ defineRule({
27
+ id: "comments.complete-sentence",
28
+ category: "code-style",
29
+ defaultSeverity: Severity.Info,
30
+ groupBy: ["ruleId", "subject"],
31
+ aggregate(observations) {
32
+ return {
33
+ title:
34
+ observations.length === 1
35
+ ? "Comment is a complete sentence"
36
+ : "Comments contain complete sentences",
37
+ confidence: "high",
38
+ summary: `${observations.length} comments are written as complete sentences.`,
39
+ recommendation:
40
+ "Keep complete-sentence comments only when they explain non-obvious intent."
41
+ };
42
+ }
43
+ });
44
+
45
+ app.rule("comments.complete-sentence", async (ctx) => {
46
+ log.debug(`scanning ${ctx.repoPath}`);
47
+
48
+ ctx.observe({
49
+ ruleId: "comments.complete-sentence",
50
+ subject: "src/index.ts",
51
+ confidence: "high",
52
+ title: "Comment is a complete sentence",
53
+ location: {
54
+ file: "src/index.ts",
55
+ line: 2
56
+ },
57
+ evidence: "This comment is a complete sentence.",
58
+ recommendation: "Use complete-sentence comments intentionally where they clarify non-obvious code."
59
+ });
60
+ });
61
+
62
+ await app.run();
63
+ ```
64
+
65
+ ## API
66
+
67
+ ### `new Adversary(options)`
68
+
69
+ Creates an adversary app.
70
+
71
+ ```ts
72
+ const app = new Adversary({
73
+ name: "adversarylabs/example"
74
+ });
75
+ ```
76
+
77
+ ### `app.rule(ruleId, handler)`
78
+
79
+ Registers a rule. Rules report through `ctx.observe(...)`, `ctx.finding(...)`, and `ctx.review.*`.
80
+
81
+ Rule context exposes:
82
+
83
+ - `ctx.repoPath`
84
+ - `ctx.summary`
85
+ - `ctx.cache`
86
+ - `ctx.relpath(path)`
87
+ - `ctx.glob(pattern)`
88
+ - `ctx.rglob(pattern)`
89
+ - `ctx.observe(observation)`
90
+ - `ctx.finding(finding)`
91
+ - `ctx.review.assessment(assessment)`
92
+ - `ctx.review.positive(note)`
93
+ - `ctx.review.observe(note)`
94
+ - `ctx.review.opinion(opinion)`
95
+
96
+ ### `defineRule(definition)`
97
+
98
+ Registers domain-specific aggregation for a stable rule id. The SDK still owns grouping,
99
+ deduplication, ranking, suppression, and rendering; the rule definition supplies engineering
100
+ language for a grouped set of observations.
101
+
102
+ ```ts
103
+ defineRule({
104
+ id: "comments.complete-sentence",
105
+ category: "code-style",
106
+ defaultSeverity: "info",
107
+ defaultConfidence: "high",
108
+ groupBy: ["ruleId", "subject"],
109
+ aggregate(observations) {
110
+ return {
111
+ title:
112
+ observations.length === 1
113
+ ? "Comment is a complete sentence"
114
+ : "Comments contain complete sentences",
115
+ confidence: "high",
116
+ summary: `${observations.length} comments are written as complete sentences.`,
117
+ whyItMatters:
118
+ "Comments are most useful when they explain non-obvious intent rather than restating code.",
119
+ recommendation:
120
+ "Keep complete-sentence comments only when they explain non-obvious intent."
121
+ };
122
+ }
123
+ });
124
+ ```
125
+
126
+ `category`, `defaultSeverity`, `defaultConfidence`, and `groupBy` act as defaults for observations
127
+ with the same `ruleId`. If a rule has no `aggregate(...)`, the SDK uses generic synthesis.
128
+
129
+ ### `ctx.observe(input)`
130
+
131
+ Use observations for raw detector output and evidence. Observations are normalized, deduplicated,
132
+ grouped, synthesized, ranked, and rendered by the SDK. Prefer this path for new adversaries.
133
+
134
+ Default grouping uses:
135
+
136
+ ```text
137
+ ruleId + subject + category
138
+ ```
139
+
140
+ Rule definitions can override this with `groupBy`. Individual observations can still override the
141
+ issue boundary with `groupKey`:
142
+
143
+ ```ts
144
+ ctx.observe({
145
+ ruleId: "comments.complete-sentence",
146
+ subject: "src/index.ts",
147
+ groupKey: "complete-sentence-comments",
148
+ category: "code-style",
149
+ severity: "info",
150
+ confidence: 0.95,
151
+ title: "Comments contain complete sentences",
152
+ location: { file: "src/index.ts", line: 3 },
153
+ evidence: { comment: "This comment is a complete sentence." },
154
+ recommendation: {
155
+ summary: "Use complete-sentence comments intentionally where they clarify non-obvious code."
156
+ }
157
+ });
158
+ ```
159
+
160
+ Set `deduplicate: false` only when repeated evidence is meaningful.
161
+
162
+ ### `ctx.finding(input)`
163
+
164
+ Use completed findings when the adversary has already synthesized the issue:
165
+
166
+ ```ts
167
+ ctx.finding({
168
+ title: "Comments contain complete sentences",
169
+ category: "code-style",
170
+ severity: "info",
171
+ confidence: "high",
172
+ summary: "Three comments are written as complete sentences.",
173
+ whyItMatters: "Complete-sentence comments can be useful for intent, but noisy when they restate code.",
174
+ impact: "Reviewers may spend time reading comments that do not add much context.",
175
+ evidence: [
176
+ { file: "src/index.ts", line: 3, message: "Explains parser intent." },
177
+ { file: "src/index.ts", line: 11, message: "Explains fallback behavior." },
178
+ { file: "src/index.ts", line: 20, message: "Explains output formatting." }
179
+ ],
180
+ recommendation: "Keep complete-sentence comments only when they explain non-obvious intent.",
181
+ remediation: { complexity: "trivial" }
182
+ });
183
+ ```
184
+
185
+ Completed findings still pass through validation, deduplication, ranking, suppression, and
186
+ rendering.
187
+
188
+ `remediation.complexity` accepts `"trivial"`, `"small"`, `"medium"`, `"large"`, or
189
+ `"architectural"`. It remains available in structured output but is not rendered in the default
190
+ terminal review.
191
+
192
+ ### Confidence
193
+
194
+ Confidence accepts `"low"`, `"medium"`, `"high"`, or a number from `0` to `1`.
195
+
196
+ Default numeric thresholds:
197
+
198
+ - `low`: less than `0.60`
199
+ - `medium`: `0.60` through `0.84`
200
+ - `high`: `0.85` and above
201
+
202
+ Customize thresholds with `new Adversary({ review: { confidenceThresholds } })`.
203
+
204
+ ### Severity
205
+
206
+ The SDK uses severity as a review calibration signal, not just a detector label.
207
+
208
+ - `info`: interesting observations.
209
+ - `low`: reasonable engineering improvements.
210
+ - `medium`: issues likely to create operational problems.
211
+ - `high`: security, correctness, or reliability risks.
212
+ - `critical`: immediate production risk.
213
+
214
+ Override calibration when needed:
215
+
216
+ ```ts
217
+ new Adversary({
218
+ name: "adversarylabs/example",
219
+ review: {
220
+ severityOverrides: {
221
+ "rule.id": "medium"
222
+ }
223
+ }
224
+ });
225
+ ```
226
+
227
+ ### Suppression and Ranking
228
+
229
+ Review policy controls human-readable output:
230
+
231
+ ```ts
232
+ new Adversary({
233
+ name: "adversarylabs/comment-sentences",
234
+ review: {
235
+ minimumConfidence: "medium",
236
+ maximumFindings: 5,
237
+ includeInformational: false
238
+ }
239
+ });
240
+ ```
241
+
242
+ By default, low-confidence and informational findings are suppressed from the primary review.
243
+ Suppressed findings are counted and can be included with `run({ includeSuppressed: true })`.
244
+ Raw observations can be included with `run({ includeRawObservations: true })`.
245
+
246
+ Ranking is deterministic and considers severity, confidence, affected evidence count, runtime or
247
+ production tags, and qualitative remediation complexity. It is not severity-only; a high-confidence
248
+ medium issue can rank above a speculative high-severity issue.
249
+
250
+ ### Review Notes
251
+
252
+ Use review-level APIs for concise summaries that are not findings:
253
+
254
+ ```ts
255
+ ctx.review.assessment({
256
+ risk: "none",
257
+ summary: "This review only reports complete-sentence comments."
258
+ });
259
+
260
+ ctx.review.positive({
261
+ key: "intentional-comments",
262
+ summary: "Several comments explain intent rather than restating implementation.",
263
+ evidence: [{ file: "src/index.ts", line: 3 }]
264
+ });
265
+
266
+ ctx.review.observe({
267
+ key: "sentence-style",
268
+ summary: "Some comments are written as complete sentences."
269
+ });
270
+
271
+ ctx.review.opinion({
272
+ ship: true,
273
+ summary: "Comment sentence style does not block shipping."
274
+ });
275
+
276
+ ctx.review.score({
277
+ key: "production-readiness",
278
+ label: "Production readiness",
279
+ score: 8.8,
280
+ max: 10,
281
+ summary: "Ready"
282
+ });
283
+ ```
284
+
285
+ Scores are optional. They are included in JSON and rendered in terminal output when present.
286
+
287
+ ### Observation-First Authoring
288
+
289
+ Prefer `ctx.observe(...)` for new adversaries. The intended flow is:
290
+
291
+ ```text
292
+ observe -> group -> synthesize -> rank -> review
293
+ ```
294
+
295
+ Adversaries should describe what they observed, where it happened, and why it matters. The SDK
296
+ should decide how observations group, which findings survive suppression, how they are ranked, and
297
+ how they are presented.
298
+
299
+ Use `ctx.finding(...)` when the adversary has already done issue synthesis itself.
300
+
301
+ ### `log`
302
+
303
+ `log.debug()` and `log.info()` print only when `ADVERSARY_VERBOSE` is enabled with `1`, `true`,
304
+ `TRUE`, `yes`, or `YES`. `log.warn()` and `log.error()` always print. Logs go to stderr as:
305
+
306
+ ```text
307
+ [adversary] level: message
308
+ ```
309
+
310
+ ## Review Result
311
+
312
+ `app.run()` returns one normalized review object:
313
+
314
+ ```ts
315
+ type ReviewResult = {
316
+ adversary: { name: string; version?: string };
317
+ target: { repository?: string; filesScanned?: number };
318
+ assessment?: { risk: "none" | "low" | "medium" | "high" | "critical"; summary?: string };
319
+ positives: ReviewNote[];
320
+ observations: ReviewNote[];
321
+ scores?: ReviewScore[];
322
+ findings: ReviewFinding[];
323
+ opinion?: { ship?: boolean; summary: string };
324
+ suppressed: { observations: number; findings: number };
325
+ timing?: { totalMs?: number };
326
+ };
327
+ ```
328
+
329
+ Renderers consume this result. The SDK includes `TerminalRenderer` and `JsonRenderer`:
330
+
331
+ ```ts
332
+ const result = await app.run({ write: false });
333
+ new TerminalRenderer().render(result);
334
+ new JsonRenderer().render(result);
335
+ ```
336
+
337
+ Adversary implementations should not manually format review output.
338
+
339
+ ## Comment Sentence Example
340
+
341
+ `adversary.yaml`:
342
+
343
+ ```yaml
344
+ name: comment-sentences
345
+ version: 0.1.0
346
+ description: Reports TypeScript comments that are written as complete sentences.
347
+
348
+ triggers:
349
+ manual: true
350
+ files_changed:
351
+ - "*.ts"
352
+ - "**/*.ts"
353
+
354
+ runtime:
355
+ name: node
356
+ version: "22"
357
+ command:
358
+ - dist/index.js
359
+
360
+ permissions:
361
+ filesystem:
362
+ read:
363
+ - .
364
+ write:
365
+ - .adversary/results
366
+ network: false
367
+ env: []
368
+
369
+ findings:
370
+ format: adversary.findings.v1
371
+ ```
372
+
373
+ `src/index.ts`:
374
+
375
+ ```ts
376
+ import { readFile } from "node:fs/promises";
377
+ import { join } from "node:path";
378
+ import { Adversary, defineRule } from "@adversarylabs/sdk";
379
+
380
+ const app = new Adversary({
381
+ name: "adversarylabs/comment-sentences",
382
+ review: {
383
+ minimumConfidence: "medium"
384
+ }
385
+ });
386
+
387
+ defineRule({
388
+ id: "comments.complete-sentence",
389
+ category: "code-style",
390
+ defaultSeverity: "info",
391
+ defaultConfidence: "high",
392
+ groupBy: ["ruleId", "subject"],
393
+ aggregate(observations) {
394
+ return {
395
+ title:
396
+ observations.length === 1
397
+ ? "Comment is a complete sentence"
398
+ : "Comments contain complete sentences",
399
+ confidence: "high",
400
+ summary: `${observations.length} comments in ${observations[0]?.subject ?? "the file"} are written as complete sentences.`,
401
+ whyItMatters:
402
+ "Comments are most useful when they explain non-obvious intent rather than restating code.",
403
+ impact: "Repeated prose can make routine code harder to scan during review.",
404
+ evidence: observations.map((observation) => ({
405
+ file: observation.location?.file,
406
+ line: observation.location?.line,
407
+ message: "complete sentence",
408
+ snippet:
409
+ typeof observation.evidence === "object" && observation.evidence !== null
410
+ ? String(observation.evidence.comment)
411
+ : undefined,
412
+ data:
413
+ typeof observation.evidence === "object" && observation.evidence !== null
414
+ ? observation.evidence
415
+ : undefined
416
+ })),
417
+ recommendation:
418
+ "Keep complete-sentence comments only when they explain non-obvious intent.",
419
+ remediation: {
420
+ complexity: "trivial"
421
+ }
422
+ };
423
+ }
424
+ });
425
+
426
+ app.rule("comments.complete-sentence", async (ctx) => {
427
+ const files = await ctx.rglob("*.ts");
428
+ ctx.summary.files_scanned = files.length;
429
+
430
+ for (const file of files) {
431
+ const content = await readFile(join(ctx.repoPath, file), "utf8");
432
+ const lines = content.split(/\r?\n/);
433
+
434
+ lines.forEach((line, index) => {
435
+ const match = line.match(/^\s*\/\/\s+(.+)/);
436
+ if (!match) {
437
+ return;
438
+ }
439
+
440
+ const comment = match[1] ?? "";
441
+ if (!/^[A-Z][^.!?]*[.!?]$/.test(comment)) {
442
+ return;
443
+ }
444
+
445
+ ctx.observe({
446
+ ruleId: "comments.complete-sentence",
447
+ subject: file,
448
+ confidence: "high",
449
+ title: "Comment is a complete sentence",
450
+ location: {
451
+ file,
452
+ line: index + 1
453
+ },
454
+ evidence: {
455
+ comment
456
+ },
457
+ tags: ["style"]
458
+ });
459
+ });
460
+ }
461
+
462
+ ctx.review.assessment({
463
+ risk: "none",
464
+ summary: "This review only reports complete-sentence comments."
465
+ });
466
+
467
+ ctx.review.opinion({
468
+ ship: true,
469
+ summary: "Comment sentence style does not block shipping."
470
+ });
471
+ });
472
+
473
+ export default app;
474
+ ```
475
+
476
+ ## Runtime Contract
477
+
478
+ Input is read from `ADVERSARY_INPUT` when set, otherwise:
479
+
480
+ ```text
481
+ /adversary/input.json
482
+ ```
483
+
484
+ Expected input:
485
+
486
+ ```json
487
+ {
488
+ "source": {
489
+ "path": "/repo"
490
+ }
491
+ }
492
+ ```
493
+
494
+ Output is written to `ADVERSARY_OUTPUT` when set, otherwise:
495
+
496
+ ```text
497
+ /adversary/output.json
498
+ ```
499
+
500
+ Output shape:
501
+
502
+ ```json
503
+ {
504
+ "protocolVersion": 1,
505
+ "result": {
506
+ "adversary": {
507
+ "name": "adversarylabs/example"
508
+ },
509
+ "target": {
510
+ "repository": "/repo",
511
+ "filesScanned": 2
512
+ },
513
+ "positives": [],
514
+ "observations": [],
515
+ "findings": [],
516
+ "suppressed": {
517
+ "observations": 0,
518
+ "findings": 0
519
+ }
520
+ }
521
+ }
522
+ ```
523
+
524
+ ## Development
525
+
526
+ ```bash
527
+ npm install
528
+ npm test
529
+ npm run build
530
+ npm run lint
531
+ ```
532
+
533
+ ## CI and Release
534
+
535
+ Depot CI workflows live in `.depot/workflows/`.
536
+
537
+ - Pull requests run lint, tests, and build.
538
+ - Tags matching `v*` run lint, tests, build, verify the tag matches `package.json`, and publish to npm.
539
+
540
+ Publishing requires an `NPM_TOKEN` secret in Depot CI. Release tags should match the package
541
+ version, for example `v0.1.0`.
542
+
543
+ With direnv:
544
+
545
+ ```bash
546
+ direnv allow
547
+ ```
548
+
549
+ The Nix flake provides Node 22 and npm.