@attest-ai/core 0.9.0 → 1.0.0-rc.2

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/dist/esm/cli.js CHANGED
@@ -1,473 +1,516 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import * as fs from "fs";
5
- import * as path from "path";
6
- import * as os from "os";
4
+ import { spawnSync as spawnSync2 } from "child_process";
5
+ import * as fs3 from "fs";
6
+ import * as os2 from "os";
7
+ import { fileURLToPath } from "url";
7
8
 
8
9
  // src/version.ts
9
- var VERSION = "0.9.0";
10
+ var VERSION = "1.0.0-rc.2";
11
+ var ENGINE_VERSION = "1.0.0-rc.2";
10
12
 
11
- // src/calibration.ts
12
- import { createHash } from "crypto";
13
- function computeAgreement(pairs, threshold) {
14
- if (pairs.length === 0) {
15
- throw new Error("no labeled pairs available");
16
- }
17
- if (threshold <= 0 || threshold >= 1) {
18
- throw new Error("threshold must be in (0, 1)");
13
+ // src/engine-manager.ts
14
+ import { spawn, spawnSync } from "child_process";
15
+ import { createInterface } from "readline";
16
+ import { existsSync, accessSync, statSync as statSync2, constants as fsConstants } from "fs";
17
+ import { delimiter, join as join2 } from "path";
18
+
19
+ // src/engine-downloader.ts
20
+ import * as https from "https";
21
+ import * as http from "http";
22
+ import * as fs from "fs";
23
+ import * as path from "path";
24
+ import * as os from "os";
25
+ import * as crypto from "crypto";
26
+ var RELEASE_BASE = `https://github.com/attest-framework/attest/releases/download/v${ENGINE_VERSION}`;
27
+ var CHECKSUMS_URL = `${RELEASE_BASE}/checksums-sha256.txt`;
28
+ var MAX_REDIRECTS = 5;
29
+ function versionedBinDir() {
30
+ return path.join(os.homedir(), ".attest", "bin", ENGINE_VERSION);
31
+ }
32
+ function platformKey() {
33
+ const platform2 = os.platform();
34
+ const arch2 = os.arch();
35
+ const mapping = {
36
+ darwin: { arm64: "darwin-arm64", x64: "darwin-amd64" },
37
+ linux: { arm64: "linux-arm64", x64: "linux-amd64" },
38
+ win32: { x64: "windows-amd64", arm64: "windows-arm64" }
39
+ };
40
+ const osMap = mapping[platform2];
41
+ if (!osMap) {
42
+ throw new Error(
43
+ `Unsupported platform: ${platform2}. Supported: darwin, linux, win32.`
44
+ );
19
45
  }
20
- const n = pairs.length;
21
- let humanPos = 0;
22
- let judgePos = 0;
23
- let bothPos = 0;
24
- let bothNeg = 0;
25
- for (const p of pairs) {
26
- const hPos = p.human >= threshold;
27
- const jPos = p.judge >= threshold;
28
- if (hPos) humanPos++;
29
- if (jPos) judgePos++;
30
- if (hPos && jPos) bothPos++;
31
- if (!hPos && !jPos) bothNeg++;
46
+ const key = osMap[arch2];
47
+ if (!key) {
48
+ throw new Error(
49
+ `Unsupported architecture '${arch2}' on platform '${platform2}'. Supported architectures: ${Object.keys(osMap).join(", ")}.`
50
+ );
32
51
  }
33
- const humanNeg = n - humanPos;
34
- const judgeNeg = n - judgePos;
35
- const agreement = (bothPos + bothNeg) / n;
36
- const expected = (humanPos * judgePos + humanNeg * judgeNeg) / (n * n);
37
- const cohenKappa = expected < 1 ? (agreement - expected) / (1 - expected) : 0;
38
- const rocAuc = rocAUC(pairs, threshold);
39
- return { threshold, n, agreement, cohenKappa, rocAuc };
52
+ return key;
40
53
  }
41
- function rocAUC(pairs, threshold) {
42
- const rows = pairs.map((p) => ({
43
- score: p.judge,
44
- pos: p.human >= threshold
45
- }));
46
- const pos = rows.filter((r) => r.pos).length;
47
- const neg = rows.length - pos;
48
- if (pos === 0 || neg === 0) return 0;
49
- rows.sort((a, b) => a.score - b.score);
50
- let rankSumPos = 0;
51
- let i = 0;
52
- while (i < rows.length) {
53
- let j = i;
54
- while (j < rows.length && rows[j].score === rows[i].score) {
55
- j++;
56
- }
57
- const avgRank = (i + j + 1) / 2;
58
- for (let k = i; k < j; k++) {
59
- if (rows[k].pos) rankSumPos += avgRank;
54
+ function binaryFilename() {
55
+ return os.platform() === "win32" ? "attest-engine.exe" : "attest-engine";
56
+ }
57
+ function cachedEnginePath() {
58
+ const binPath = path.join(versionedBinDir(), binaryFilename());
59
+ return fs.statSync(binPath, { throwIfNoEntry: false })?.isFile() ? binPath : null;
60
+ }
61
+ function parseChecksums(text) {
62
+ const checksums = /* @__PURE__ */ new Map();
63
+ for (const line of text.split("\n")) {
64
+ const trimmed = line.trim();
65
+ if (!trimmed) continue;
66
+ const match = trimmed.match(/^([a-f0-9]{64})\s+(.+)$/);
67
+ if (match) {
68
+ checksums.set(match[2], match[1]);
60
69
  }
61
- i = j;
62
70
  }
63
- const auc = (rankSumPos - pos * (pos + 1) / 2) / (pos * neg);
64
- if (Number.isNaN(auc) || !Number.isFinite(auc)) return 0;
65
- return auc;
71
+ return checksums;
66
72
  }
67
- function loadLabelsCSV(text) {
68
- const rows = parseCSV(text);
69
- if (rows.length === 0) {
70
- throw new Error("empty CSV");
71
- }
72
- let start = 0;
73
- if (rows[0].length >= 2) {
74
- const firstHuman = parseFloatStrict(rows[0][1]);
75
- if (firstHuman === null) start = 1;
73
+ function httpsGet(url, redirectCount = 0) {
74
+ if (redirectCount > MAX_REDIRECTS) {
75
+ return Promise.reject(
76
+ new Error(`Too many redirects (>${MAX_REDIRECTS}) following ${url}`)
77
+ );
76
78
  }
77
- const out = [];
78
- for (let idx = start; idx < rows.length; idx++) {
79
- const row = rows[idx];
80
- const lineNum = idx + 1;
81
- if (row.length === 0) continue;
82
- if (row[0].trimStart().startsWith("#")) continue;
83
- if (row.length < 2) {
84
- throw new Error(
85
- `CSV line ${lineNum}: want at least 2 columns (input, human_label)`
86
- );
87
- }
88
- const human = parseFloatStrict(row[1]);
89
- if (human === null) {
90
- throw new Error(`CSV line ${lineNum}: human_label not a float: ${row[1]}`);
91
- }
92
- const rec = {
93
- input: row[0],
94
- humanLabel: human,
95
- judgeScore: 0,
96
- judgeKnown: false
97
- };
98
- if (row.length >= 3 && row[2].trim() !== "") {
99
- const judge = parseFloatStrict(row[2]);
100
- if (judge === null) {
101
- throw new Error(
102
- `CSV line ${lineNum}: judge_score not a float: ${row[2]}`
103
- );
79
+ return new Promise((resolve, reject) => {
80
+ const client = url.startsWith("https:") ? https : http;
81
+ client.get(url, (res) => {
82
+ const status = res.statusCode ?? 0;
83
+ if (status === 301 || status === 302) {
84
+ const location = res.headers.location;
85
+ if (!location) {
86
+ reject(new Error(`Redirect ${status} without Location header from ${url}`));
87
+ return;
88
+ }
89
+ res.resume();
90
+ resolve(httpsGet(location, redirectCount + 1));
91
+ return;
104
92
  }
105
- rec.judgeScore = judge;
106
- rec.judgeKnown = true;
107
- }
108
- out.push(rec);
109
- }
110
- if (out.length === 0) {
111
- throw new Error("CSV contained no labeled rows");
112
- }
113
- return out;
114
- }
115
- function loadLabelsJSONL(text) {
116
- const out = [];
117
- const lines = text.split(/\r?\n/);
118
- for (let i = 0; i < lines.length; i++) {
119
- const stripped = lines[i].trim();
120
- if (!stripped) continue;
121
- let obj;
122
- try {
123
- obj = JSON.parse(stripped);
124
- } catch (err) {
125
- throw new Error(`JSONL line ${i + 1}: ${err.message}`);
126
- }
127
- if (Object.keys(obj).length === 0) continue;
128
- if (obj["human_label"] === void 0) {
129
- throw new Error(`JSONL line ${i + 1}: missing human_label`);
130
- }
131
- const human = parseJSONNumber(obj["human_label"]);
132
- if (human === null) {
133
- throw new Error(`JSONL line ${i + 1}: human_label is not a number`);
134
- }
135
- const rec = {
136
- input: typeof obj["input"] === "string" ? obj["input"] : "",
137
- humanLabel: human,
138
- judgeScore: 0,
139
- judgeKnown: false
140
- };
141
- if (obj["judge_score"] !== void 0 && obj["judge_score"] !== null) {
142
- const judge = parseJSONNumber(obj["judge_score"]);
143
- if (judge === null) {
144
- throw new Error(`JSONL line ${i + 1}: judge_score is not a number`);
93
+ if (status < 200 || status >= 300) {
94
+ res.resume();
95
+ reject(new Error(`HTTP ${status} fetching ${url}`));
96
+ return;
145
97
  }
146
- rec.judgeScore = judge;
147
- rec.judgeKnown = true;
148
- }
149
- out.push(rec);
98
+ const chunks = [];
99
+ res.on("data", (chunk) => chunks.push(chunk));
100
+ res.on("end", () => resolve(Buffer.concat(chunks)));
101
+ res.on("error", reject);
102
+ }).on("error", reject);
103
+ });
104
+ }
105
+ async function downloadEngine() {
106
+ const key = platformKey();
107
+ const filename = `attest-engine-${key}${os.platform() === "win32" ? ".exe" : ""}`;
108
+ const binaryUrl = `${RELEASE_BASE}/${filename}`;
109
+ process.stderr.write(`[attest] Downloading engine v${ENGINE_VERSION} for ${key}...
110
+ `);
111
+ process.stderr.write(`[attest] Fetching checksums...
112
+ `);
113
+ const checksumsText = (await httpsGet(CHECKSUMS_URL)).toString("utf-8");
114
+ const checksums = parseChecksums(checksumsText);
115
+ const expectedHash = checksums.get(filename);
116
+ if (!expectedHash) {
117
+ throw new Error(
118
+ `No checksum found for '${filename}' in checksums-sha256.txt. Available files: ${[...checksums.keys()].join(", ")}`
119
+ );
150
120
  }
151
- if (out.length === 0) {
152
- throw new Error("JSONL contained no labeled rows");
121
+ process.stderr.write(`[attest] Downloading ${binaryUrl}...
122
+ `);
123
+ const data = await httpsGet(binaryUrl);
124
+ const actualHash = crypto.createHash("sha256").update(data).digest("hex");
125
+ if (actualHash !== expectedHash) {
126
+ throw new Error(
127
+ `SHA256 mismatch for ${filename}. Expected: ${expectedHash}, got: ${actualHash}. The download may be corrupted. Retry or download manually.`
128
+ );
153
129
  }
154
- return out;
155
- }
156
- function parseFloatStrict(raw) {
157
- const trimmed = raw.trim();
158
- if (trimmed === "") return null;
159
- const n = Number(trimmed);
160
- if (Number.isNaN(n)) return null;
161
- return n;
130
+ process.stderr.write(`[attest] Checksum verified.
131
+ `);
132
+ const dir = versionedBinDir();
133
+ fs.mkdirSync(dir, { recursive: true });
134
+ const destPath = path.join(dir, binaryFilename());
135
+ const suffix = `${String(process.pid)}.${crypto.randomBytes(8).toString("hex")}`;
136
+ const tmpPath = path.join(dir, `.${binaryFilename()}.tmp.${suffix}`);
137
+ try {
138
+ fs.writeFileSync(tmpPath, data, { flag: "wx", mode: 493 });
139
+ fs.chmodSync(tmpPath, 493);
140
+ fs.renameSync(tmpPath, destPath);
141
+ } catch (err) {
142
+ if (err.code !== "EEXIST") fs.rmSync(tmpPath, { force: true });
143
+ throw err;
144
+ }
145
+ process.stderr.write(`[attest] Engine installed at ${destPath}
146
+ `);
147
+ return destPath;
162
148
  }
163
- function parseJSONNumber(value) {
164
- if (typeof value !== "number") return null;
165
- if (Number.isNaN(value)) return null;
166
- return value;
149
+
150
+ // src/engine-manager.ts
151
+ var ENGINE_BINARY_NAME = "attest-engine";
152
+ var VERSION_PROBE_TIMEOUT_MS = 1e4;
153
+ var EngineVersionMismatchError = class extends Error {
154
+ enginePath;
155
+ source;
156
+ found;
157
+ required;
158
+ constructor(enginePath, source, found, required) {
159
+ const reported = found === null ? "reports no version" : `reports version ${found}`;
160
+ super(
161
+ `attest-engine at ${enginePath} (from ${source}) ${reported}; this SDK requires attest-engine ${required}. Point ATTEST_ENGINE_PATH at a matching engine, or remove the mismatched one from PATH so the SDK can download ${required} into ~/.attest/bin/${required}/.`
162
+ );
163
+ this.name = "EngineVersionMismatchError";
164
+ this.enginePath = enginePath;
165
+ this.source = source;
166
+ this.found = found;
167
+ this.required = required;
168
+ }
169
+ };
170
+ function engineVersion(enginePath) {
171
+ const proc = spawnSync(enginePath, ["version"], {
172
+ stdio: ["ignore", "pipe", "pipe"],
173
+ encoding: "utf-8",
174
+ timeout: VERSION_PROBE_TIMEOUT_MS
175
+ });
176
+ if (proc.error) throw proc.error;
177
+ const out = proc.stdout.trim();
178
+ const prefix = `${ENGINE_BINARY_NAME} `;
179
+ if (proc.status !== 0 || !out.startsWith(prefix)) return null;
180
+ return out.slice(prefix.length).trim();
167
181
  }
168
- function parseCSV(text) {
169
- const rows = [];
170
- let current = [];
171
- let field = "";
172
- let inQuotes = false;
173
- let i = 0;
174
- while (i < text.length) {
175
- const ch = text[i];
176
- if (inQuotes) {
177
- if (ch === '"') {
178
- if (text[i + 1] === '"') {
179
- field += '"';
180
- i += 2;
181
- continue;
182
- }
183
- inQuotes = false;
184
- i++;
185
- continue;
186
- }
187
- field += ch;
188
- i++;
189
- continue;
190
- }
191
- if (ch === '"') {
192
- inQuotes = true;
193
- i++;
194
- continue;
195
- }
196
- if (ch === ",") {
197
- current.push(field);
198
- field = "";
199
- i++;
200
- continue;
201
- }
202
- if (ch === "\n") {
203
- current.push(field);
204
- rows.push(current);
205
- current = [];
206
- field = "";
207
- i++;
208
- continue;
209
- }
210
- if (ch === "\r") {
211
- i++;
182
+ function onPath() {
183
+ for (const dir of (process.env["PATH"] ?? "").split(delimiter)) {
184
+ if (!dir) continue;
185
+ const candidate = join2(dir, binaryFilename());
186
+ try {
187
+ accessSync(candidate, fsConstants.X_OK);
188
+ } catch {
212
189
  continue;
213
190
  }
214
- field += ch;
215
- i++;
216
- }
217
- if (field.length > 0 || current.length > 0) {
218
- current.push(field);
219
- rows.push(current);
191
+ if (statSync2(candidate).isFile()) return candidate;
220
192
  }
221
- return rows;
193
+ return null;
222
194
  }
223
-
224
- // src/cli.ts
225
- function cacheDir() {
226
- const envOverride = process.env["ATTEST_CACHE_DIR"];
227
- if (envOverride) return envOverride;
228
- return path.join(os.homedir(), ".attest", "cache");
229
- }
230
- function cacheDbPath() {
231
- return path.join(cacheDir(), "attest.db");
195
+ function requireFile(enginePath, label) {
196
+ if (!existsSync(enginePath) || !statSync2(enginePath).isFile()) {
197
+ throw new Error(`${label}=${enginePath} does not exist or is not a file.`);
198
+ }
232
199
  }
233
- function cmdCacheStats() {
234
- const dbPath = cacheDbPath();
235
- const exists = fs.existsSync(dbPath);
236
- let fileSize = 0;
237
- if (exists) {
238
- fileSize = fs.statSync(dbPath).size;
200
+ async function locateEngine(explicitPath, allowDownload) {
201
+ if (explicitPath) {
202
+ requireFile(explicitPath, "Engine path");
203
+ return [explicitPath, "explicit engine path"];
204
+ }
205
+ const envPath = process.env["ATTEST_ENGINE_PATH"];
206
+ if (envPath) {
207
+ requireFile(envPath, "ATTEST_ENGINE_PATH");
208
+ return [envPath, "ATTEST_ENGINE_PATH"];
209
+ }
210
+ const found = onPath();
211
+ if (found) return [found, "PATH"];
212
+ const cached = cachedEnginePath();
213
+ if (cached) return [cached, "engine cache"];
214
+ if (!allowDownload) {
215
+ throw new Error(
216
+ `No '${ENGINE_BINARY_NAME}' ${ENGINE_VERSION} in ATTEST_ENGINE_PATH, PATH or ~/.attest/bin/${ENGINE_VERSION}/.`
217
+ );
218
+ }
219
+ const noDownload = process.env["ATTEST_ENGINE_NO_DOWNLOAD"]?.toLowerCase();
220
+ if (noDownload === "1" || noDownload === "true" || noDownload === "yes") {
221
+ throw new Error(
222
+ `Cannot find '${ENGINE_BINARY_NAME}' ${ENGINE_VERSION} and ATTEST_ENGINE_NO_DOWNLOAD is set.
223
+ Install options:
224
+ 1. Build from source: make engine, then set ATTEST_ENGINE_PATH
225
+ 2. Download release v${ENGINE_VERSION}: https://github.com/attest-framework/attest/releases
226
+ 3. Allow auto-download by unsetting ATTEST_ENGINE_NO_DOWNLOAD`
227
+ );
239
228
  }
240
- const stats = { exists, file_size: fileSize, path: dbPath };
241
- process.stdout.write(JSON.stringify(stats) + "\n");
229
+ return [await downloadEngine(), "download"];
242
230
  }
243
- function cmdCacheClear() {
244
- const dbPath = cacheDbPath();
245
- if (fs.existsSync(dbPath)) {
246
- fs.unlinkSync(dbPath);
247
- process.stdout.write(`Cleared cache: ${dbPath}
248
- `);
249
- } else {
250
- process.stdout.write(`No cache to clear: ${dbPath}
251
- `);
231
+ async function resolveEngine(explicitPath, options = {}) {
232
+ const [enginePath, source] = await locateEngine(explicitPath, options.allowDownload ?? true);
233
+ const found = engineVersion(enginePath);
234
+ if (found !== ENGINE_VERSION) {
235
+ throw new EngineVersionMismatchError(enginePath, source, found, ENGINE_VERSION);
252
236
  }
237
+ return enginePath;
253
238
  }
254
- var CONFTEST_TEMPLATE = `import { describe, it, expect } from "vitest";
255
- import { attestExpect, TraceBuilder } from "@attest-ai/core";
256
239
 
257
- describe("my agent", () => {
258
- it("returns expected output", async () => {
259
- const trace = new TraceBuilder("my-agent")
260
- .setInput({ user_message: "hello" })
261
- .setOutput({ message: "world" })
262
- .build();
240
+ // src/scaffold.ts
241
+ import * as fs2 from "fs";
242
+ import * as path2 from "path";
243
+ var DEMO_MARKER = "ATTEST-DEMO";
244
+ var SAMPLE_TEST_TEMPLATE = `// Sample Attest test written by \`attest init\`. Run it with \`npx vitest run\`.
245
+ import { it } from "vitest";
246
+ import { attestExpect, TraceBuilder, type Trace } from "@attest-ai/core";
247
+ import { evaluate } from "@attest-ai/vitest";
263
248
 
264
- attestExpect(trace).outputContains("world");
265
- });
249
+ // The trace of one agent run, built by hand so the sample needs no model, API key
250
+ // or network. Replace it with a trace from your agent: wrap the agent with
251
+ // \`new Agent(...)\` or record it with an adapter such as OpenAIAdapter.
252
+ function myAgentResult(): Trace {
253
+ return new TraceBuilder("refund-agent")
254
+ .setInput({ user_message: "Please refund order ORD-1001." })
255
+ .addToolCall("lookup_order", {
256
+ args: { order_id: "ORD-1001" },
257
+ result: { status: "delivered", amount: 42.0 },
258
+ })
259
+ .addToolCall("issue_refund", {
260
+ args: { order_id: "ORD-1001", amount: 42.0 },
261
+ result: { refund_id: "RF-1001" },
262
+ })
263
+ .setOutput({
264
+ message: "Your refund of $42.00 has been issued.",
265
+ structured: { refund_id: "RF-1001", amount: 42.0 },
266
+ })
267
+ .setMetadata({ total_tokens: 180, cost_usd: 0.002, latency_ms: 900 })
268
+ .build();
269
+ }
270
+
271
+ it("my agent issues the refund", async (context) => {
272
+ const chain = attestExpect(myAgentResult())
273
+ .outputMatchesSchema({ type: "object", required: ["refund_id"] }) // layer 1: schema
274
+ .costUnder(0.01) // layer 2: constraint
275
+ .toolsCalledInOrder(["lookup_order", "issue_refund"]) // layer 3: trace
276
+ .outputContains("refund"); // ${DEMO_MARKER}: change "refund" to "store credit"
277
+ await evaluate(chain, { context });
266
278
  });
267
279
  `;
268
- function cmdInit(targetDir) {
269
- const testsDir = path.join(targetDir, "tests");
270
- fs.mkdirSync(testsDir, { recursive: true });
271
- process.stdout.write(`Directory: ${testsDir}
272
- `);
273
- const sampleTestPath = path.join(testsDir, "test_my_agent.test.ts");
274
- if (fs.existsSync(sampleTestPath)) {
275
- process.stderr.write(`Skipped (exists): ${sampleTestPath}
276
- `);
277
- } else {
278
- fs.writeFileSync(sampleTestPath, CONFTEST_TEMPLATE);
279
- process.stdout.write(`Created: ${sampleTestPath}
280
+ var REPORT_ARGS = "--reporter=default --reporter=@attest-ai/vitest/reporter --outputFile.attest=attest-report.json";
281
+ var POLICY_TEMPLATE = `# Merge policy for \`attest policy evaluate --report <report> --policy attest.policy.yaml\`.
282
+ # Exit 0: pass. Exit 1: a blocking rule failed. Exit 2: warnings only.
283
+ layers:
284
+ 0: # layer 0 caps every layer together
285
+ max_hard_fails: 0
286
+ # Assertions that could not run (no judge configured, simulation) warn instead of block.
287
+ not_evaluated: warn
288
+ `;
289
+ var GITHUB_WORKFLOW_TEMPLATE = `# Written by \`attest init --ci github\`.
290
+ # The test step records its outcome and continues so the report is always uploaded,
291
+ # the policy evaluated and the pull request annotated; the last step fails the job
292
+ # when the tests failed. A policy warning (exit 2) does not fail the job.
293
+ name: attest
294
+
295
+ on:
296
+ pull_request:
297
+ push:
298
+ branches: [main]
299
+
300
+ permissions:
301
+ contents: read
302
+ pull-requests: write
303
+
304
+ jobs:
305
+ attest:
306
+ runs-on: ubuntu-latest
307
+ steps:
308
+ - uses: actions/checkout@v6
309
+ with:
310
+ persist-credentials: false
311
+ - uses: actions/setup-node@v6
312
+ with:
313
+ node-version: 22
314
+ - name: Install dependencies
315
+ run: npm ci
316
+ - name: Run tests
317
+ id: tests
318
+ continue-on-error: true
319
+ run: npx vitest run ${REPORT_ARGS}
320
+ - name: Upload the Attest report
321
+ if: always()
322
+ uses: actions/upload-artifact@v7
323
+ with:
324
+ name: attest-report
325
+ path: attest-report.json
326
+ if-no-files-found: error
327
+ - name: Evaluate the merge policy
328
+ if: always()
329
+ run: |
330
+ set +e
331
+ npx attest policy evaluate --report attest-report.json --policy attest.policy.yaml
332
+ code=$?
333
+ if [ "$code" -eq 2 ]; then
334
+ echo "::warning::attest policy: warnings only (exit 2)"
335
+ exit 0
336
+ fi
337
+ exit "$code"
338
+ - name: Annotate the pull request
339
+ # Fork pull requests get a read-only token that cannot post reviews; the last step
340
+ # still fails the job when tests failed.
341
+ if: >-
342
+ always() && github.event_name == 'pull_request' &&
343
+ github.event.pull_request.head.repo.full_name == github.repository
344
+ uses: attest-framework/attest/.github/actions/pr-annotate@v${VERSION}
345
+ with:
346
+ report: attest-report.json
347
+ - name: Fail the job when tests failed
348
+ if: always() && steps.tests.outcome == 'failure'
349
+ run: |
350
+ echo "::error::vitest failed; see the Run tests step"
351
+ exit 1
352
+ `;
353
+ function write(file, content) {
354
+ if (fs2.existsSync(file)) {
355
+ process.stderr.write(`Skipped (exists): ${file}
280
356
  `);
357
+ return;
281
358
  }
282
- }
283
- function cmdValidate(targetDir) {
284
- const testsDir = path.join(targetDir, "tests");
285
- let testFiles = [];
286
- if (fs.existsSync(testsDir)) {
287
- testFiles = fs.readdirSync(testsDir).filter((f) => f.endsWith(".test.ts") || f.endsWith(".test.js"));
288
- }
289
- if (testFiles.length === 0) {
290
- process.stderr.write(
291
- `Warning: no test files (*.test.ts / *.test.js) found in ${testsDir}.
292
- `
293
- );
294
- } else {
295
- process.stdout.write(`Found ${testFiles.length} test file(s) in ${testsDir}
359
+ fs2.mkdirSync(path2.dirname(file), { recursive: true });
360
+ fs2.writeFileSync(file, content);
361
+ process.stdout.write(`Created: ${file}
296
362
  `);
297
- }
298
- const STALE_DAYS = 30;
299
- const now = Date.now();
300
- const goldenFiles = findGoldenFiles(targetDir);
301
- for (const golden of goldenFiles) {
302
- const mtime = fs.statSync(golden).mtimeMs;
303
- const ageDays = Math.floor((now - mtime) / (1e3 * 60 * 60 * 24));
304
- if (ageDays > STALE_DAYS) {
305
- process.stderr.write(
306
- `Warning: golden trace ${golden} is ${ageDays} days old (>${STALE_DAYS} days). Consider regenerating.
307
- `
308
- );
309
- }
310
- }
311
363
  }
312
- function findGoldenFiles(dir) {
313
- const results = [];
314
- if (!fs.existsSync(dir)) return results;
315
- const entries = fs.readdirSync(dir, { withFileTypes: true });
316
- for (const entry of entries) {
317
- const fullPath = path.join(dir, entry.name);
318
- if (entry.isDirectory() && entry.name !== "node_modules") {
319
- results.push(...findGoldenFiles(fullPath));
320
- } else if (entry.isFile() && entry.name.endsWith(".golden")) {
321
- results.push(fullPath);
322
- }
364
+ function scaffoldProject(targetDir, options = {}) {
365
+ write(path2.join(targetDir, "tests", "my-agent.test.ts"), SAMPLE_TEST_TEMPLATE);
366
+ write(path2.join(targetDir, "attest.policy.yaml"), POLICY_TEMPLATE);
367
+ if (options.ci === "github") {
368
+ write(path2.join(targetDir, ".github", "workflows", "attest.yml"), GITHUB_WORKFLOW_TEMPLATE);
323
369
  }
324
- return results;
370
+ process.stdout.write(
371
+ `
372
+ Next: run \`npx vitest run\`. Then change the line marked ${DEMO_MARKER} in tests/my-agent.test.ts and run it again to see a failure.
373
+ `
374
+ );
325
375
  }
326
- function main(argv = process.argv.slice(2)) {
327
- const args = argv;
328
- if (args[0] === "--version") {
329
- process.stdout.write(`attest ${VERSION}
376
+
377
+ // src/cli.ts
378
+ var HELP_FLAGS = /* @__PURE__ */ new Set(["-h", "--help"]);
379
+ var SDK_USAGE = "usage: attest <command> [args]\n\nSDK commands:\n init [--ci github] scaffold a sample test and merge policy here\n (--ci github also writes .github/workflows/attest.yml)\n\noptions:\n --version print the SDK version and the engine version it requires\n --help print this help\n";
380
+ var INIT_USAGE = "usage: attest init [--ci github] (scaffolds into the current directory)";
381
+ function cmdInit(args) {
382
+ if (args.some((a) => HELP_FLAGS.has(a))) {
383
+ process.stdout.write(`${INIT_USAGE}
330
384
  `);
331
- process.exit(0);
385
+ return 0;
332
386
  }
333
- if (args[0] === "cache" && args.length >= 2) {
334
- if (args[1] === "stats") {
335
- cmdCacheStats();
336
- process.exit(0);
337
- } else if (args[1] === "clear") {
338
- cmdCacheClear();
339
- process.exit(0);
340
- } else {
341
- process.stderr.write(`Unknown cache subcommand: ${args[1]}
387
+ const joined = args.join(" ");
388
+ if (args.length > 0 && joined !== "--ci github" && joined !== "--ci=github") {
389
+ process.stderr.write(`${INIT_USAGE}
342
390
  `);
343
- process.stderr.write("Available: cache stats, cache clear\n");
344
- process.exit(1);
345
- }
391
+ return 2;
346
392
  }
347
- if (args[0] === "init") {
348
- cmdInit(process.cwd());
349
- process.exit(0);
350
- }
351
- if (args[0] === "validate") {
352
- cmdValidate(process.cwd());
353
- process.exit(0);
354
- }
355
- if (args[0] === "calibrate") {
356
- cmdCalibrate(args.slice(1));
357
- process.exit(0);
358
- }
359
- process.stderr.write(
360
- "Usage: attest <command>\n\nCommands:\n --version Print version\n init Scaffold a test project\n validate Validate test suite\n cache stats Show cache statistics\n cache clear Clear cache database\n calibrate Compute judge agreement metrics over labeled data\n"
361
- );
362
- process.exit(args.length === 0 ? 0 : 1);
393
+ scaffoldProject(process.cwd(), args.length > 0 ? { ci: "github" } : {});
394
+ return 0;
363
395
  }
364
- function parseCalibrateArgs(args) {
365
- const opts = {
366
- labels: "",
367
- rubric: "default",
368
- rubricVersion: "",
369
- threshold: 0.5
370
- };
371
- for (let i = 0; i < args.length; i++) {
372
- const arg = args[i];
373
- const next = args[i + 1];
374
- if (arg === "--labels") {
375
- if (next === void 0) throw new Error("--labels requires a path");
376
- opts.labels = next;
377
- i++;
378
- } else if (arg === "--rubric") {
379
- if (next === void 0) throw new Error("--rubric requires a name");
380
- opts.rubric = next;
381
- i++;
382
- } else if (arg === "--rubric-version") {
383
- if (next === void 0) throw new Error("--rubric-version requires a value");
384
- opts.rubricVersion = next;
385
- i++;
386
- } else if (arg === "--threshold") {
387
- if (next === void 0) throw new Error("--threshold requires a number");
388
- const parsed = Number(next);
389
- if (next.trim() === "" || Number.isNaN(parsed)) {
390
- throw new Error(`--threshold value ${JSON.stringify(next)} is not a number`);
391
- }
392
- opts.threshold = parsed;
393
- i++;
394
- } else {
395
- throw new Error(`unknown calibrate flag: ${arg}`);
396
- }
397
- }
398
- if (!opts.labels) {
399
- throw new Error("--labels is required");
396
+ var HELP_PROBE_TIMEOUT_MS = 1e4;
397
+ function errorMessage(err) {
398
+ return err instanceof Error ? err.message : String(err);
399
+ }
400
+ function engineCommands(engine, timeoutMs = HELP_PROBE_TIMEOUT_MS) {
401
+ const proc = spawnSync2(engine, ["help"], {
402
+ stdio: ["ignore", "pipe", "pipe"],
403
+ encoding: "utf-8",
404
+ timeout: timeoutMs
405
+ });
406
+ if (proc.error || proc.status !== 0) return null;
407
+ const lines = proc.stdout.split("\n");
408
+ const start = lines.indexOf("commands:");
409
+ if (start === -1) return null;
410
+ const commands = /* @__PURE__ */ new Map();
411
+ for (const line of lines.slice(start + 1)) {
412
+ const match = /^\s*(\S+)\s*(.*)$/.exec(line);
413
+ if (match?.[1]) commands.set(match[1], (match[2] ?? "").trim());
400
414
  }
401
- return opts;
415
+ return commands;
402
416
  }
403
- function cmdCalibrate(args) {
404
- let opts;
417
+ async function printHelp() {
418
+ process.stdout.write(`${SDK_USAGE}
419
+ `);
420
+ let engine;
405
421
  try {
406
- opts = parseCalibrateArgs(args);
422
+ engine = await resolveEngine(void 0, { allowDownload: false });
407
423
  } catch (err) {
408
- process.stderr.write(`calibrate: ${err.message}
424
+ const firstLine = (errorMessage(err).split("\n")[0] ?? "").replace(/\.$/, "");
425
+ process.stdout.write(
426
+ `engine commands: not available (${firstLine}). Run \`attest doctor\` to download attest-engine ${ENGINE_VERSION}, or set ATTEST_ENGINE_PATH.
427
+ `
428
+ );
429
+ return 0;
430
+ }
431
+ const commands = engineCommands(engine);
432
+ if (commands === null) {
433
+ process.stdout.write(
434
+ `engine commands: not available (attest-engine at ${engine} lists no commands; it predates the command registry).
435
+ `
436
+ );
437
+ return 0;
438
+ }
439
+ const width = Math.max(0, ...[...commands.keys()].map((name) => name.length));
440
+ process.stdout.write(`engine commands (attest-engine ${ENGINE_VERSION}, ${engine}):
441
+ `);
442
+ for (const [name, summary] of commands) {
443
+ process.stdout.write(` ${name.padEnd(width)} ${summary}
409
444
  `);
410
- process.exit(2);
411
- return;
412
445
  }
413
- const text = fs.readFileSync(opts.labels, "utf-8");
414
- const ext = path.extname(opts.labels).toLowerCase();
415
- let records;
446
+ return 0;
447
+ }
448
+ function exitStatus(status, signal) {
449
+ if (status !== null) return status;
450
+ if (signal !== null) return 128 + (os2.constants.signals[signal] ?? 0);
451
+ return 1;
452
+ }
453
+ async function forward(args) {
454
+ let engine;
416
455
  try {
417
- if (ext === ".jsonl" || ext === ".ndjson") {
418
- records = loadLabelsJSONL(text);
419
- } else if (ext === ".csv") {
420
- records = loadLabelsCSV(text);
421
- } else if (text.trimStart().startsWith("{")) {
422
- records = loadLabelsJSONL(text);
423
- } else {
424
- records = loadLabelsCSV(text);
425
- }
456
+ engine = await resolveEngine();
426
457
  } catch (err) {
427
- process.stderr.write(`calibrate: ${err.message}
458
+ process.stderr.write(`attest: ${errorMessage(err)}
428
459
  `);
429
- process.exit(1);
430
- return;
460
+ return 1;
431
461
  }
432
- const pairs = records.filter((r) => r.judgeKnown).map((r) => ({ human: r.humanLabel, judge: r.judgeScore }));
433
- const missing = records.filter((r) => !r.judgeKnown).length;
434
- if (pairs.length === 0) {
462
+ const command = args[0] ?? "";
463
+ const commands = engineCommands(engine);
464
+ if (commands === null || command !== "help" && !commands.has(command)) {
435
465
  process.stderr.write(
436
- `calibrate: no rows had judge_score (${missing} rows missing); pre-score the labels file first
466
+ `attest: attest-engine at ${engine} (version ${ENGINE_VERSION}) predates \`attest ${command}\`: its help does not list that command. Set ATTEST_ENGINE_PATH to an engine built from this release (make engine), or install a release whose engine provides it.
437
467
  `
438
468
  );
439
- process.exit(1);
440
- return;
469
+ return 1;
441
470
  }
442
- let result;
443
- try {
444
- result = computeAgreement(pairs, opts.threshold);
445
- } catch (err) {
446
- process.stderr.write(`calibrate: ${err.message}
471
+ const proc = spawnSync2(engine, args, { stdio: "inherit" });
472
+ if (proc.error) throw proc.error;
473
+ return exitStatus(proc.status, proc.signal);
474
+ }
475
+ async function main(argv = process.argv.slice(2)) {
476
+ const [command] = argv;
477
+ if (command === void 0 || HELP_FLAGS.has(command) || command === "help" && argv.length === 1) {
478
+ return printHelp();
479
+ }
480
+ if (command === "--version") {
481
+ process.stdout.write(`attest ${VERSION} (requires attest-engine ${ENGINE_VERSION})
447
482
  `);
448
- process.exit(1);
449
- return;
483
+ return 0;
450
484
  }
451
- const output = {
452
- rubric_name: opts.rubric,
453
- rubric_version: opts.rubricVersion,
454
- threshold: result.threshold,
455
- label_count: result.n,
456
- agreement: round3(result.agreement),
457
- cohen_kappa: round3(result.cohenKappa),
458
- roc_auc: round3(result.rocAuc),
459
- missing_judge: missing
460
- };
461
- process.stdout.write(JSON.stringify(output, null, 2) + "\n");
485
+ if (command === "init") {
486
+ return cmdInit(argv.slice(1));
487
+ }
488
+ if (command.startsWith("-")) {
489
+ process.stderr.write(`attest: unknown option ${command}
490
+
491
+ ${SDK_USAGE}`);
492
+ return 2;
493
+ }
494
+ return forward(argv);
462
495
  }
463
- function round3(x) {
464
- return Math.round(x * 1e3) / 1e3;
496
+ function isDirectExecution() {
497
+ const script = process.argv[1];
498
+ if (script === void 0 || !fs3.existsSync(script)) return false;
499
+ return fs3.realpathSync(script) === fs3.realpathSync(fileURLToPath(import.meta.url));
465
500
  }
466
- var isDirectExecution = typeof import.meta.url === "string" && import.meta.url.startsWith("file:") && process.argv[1] !== void 0 && import.meta.url.endsWith(path.basename(process.argv[1]));
467
- if (isDirectExecution) {
468
- main();
501
+ if (isDirectExecution()) {
502
+ main().then(
503
+ (code) => process.exit(code),
504
+ (err) => {
505
+ process.stderr.write(`attest: ${errorMessage(err)}
506
+ `);
507
+ process.exit(1);
508
+ }
509
+ );
469
510
  }
470
511
  export {
512
+ engineCommands,
513
+ exitStatus,
471
514
  main
472
515
  };
473
516
  //# sourceMappingURL=cli.js.map