@attest-ai/core 1.0.0-rc.2 → 1.0.0-rc.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/dist/esm/cli.js CHANGED
@@ -2,13 +2,14 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { spawnSync as spawnSync2 } from "child_process";
5
- import * as fs3 from "fs";
5
+ import * as fs4 from "fs";
6
6
  import * as os2 from "os";
7
- import { fileURLToPath } from "url";
7
+ import * as path4 from "path";
8
+ import { fileURLToPath as fileURLToPath2 } from "url";
8
9
 
9
10
  // src/version.ts
10
- var VERSION = "1.0.0-rc.2";
11
- var ENGINE_VERSION = "1.0.0-rc.2";
11
+ var VERSION = "1.0.0-rc.3";
12
+ var ENGINE_VERSION = "1.0.0-rc.3";
12
13
 
13
14
  // src/engine-manager.ts
14
15
  import { spawn, spawnSync } from "child_process";
@@ -76,7 +77,7 @@ function httpsGet(url, redirectCount = 0) {
76
77
  new Error(`Too many redirects (>${MAX_REDIRECTS}) following ${url}`)
77
78
  );
78
79
  }
79
- return new Promise((resolve, reject) => {
80
+ return new Promise((resolve2, reject) => {
80
81
  const client = url.startsWith("https:") ? https : http;
81
82
  client.get(url, (res) => {
82
83
  const status = res.statusCode ?? 0;
@@ -87,7 +88,7 @@ function httpsGet(url, redirectCount = 0) {
87
88
  return;
88
89
  }
89
90
  res.resume();
90
- resolve(httpsGet(location, redirectCount + 1));
91
+ resolve2(httpsGet(location, redirectCount + 1));
91
92
  return;
92
93
  }
93
94
  if (status < 200 || status >= 300) {
@@ -97,7 +98,7 @@ function httpsGet(url, redirectCount = 0) {
97
98
  }
98
99
  const chunks = [];
99
100
  res.on("data", (chunk) => chunks.push(chunk));
100
- res.on("end", () => resolve(Buffer.concat(chunks)));
101
+ res.on("end", () => resolve2(Buffer.concat(chunks)));
101
102
  res.on("error", reject);
102
103
  }).on("error", reject);
103
104
  });
@@ -228,8 +229,8 @@ Install options:
228
229
  }
229
230
  return [await downloadEngine(), "download"];
230
231
  }
231
- async function resolveEngine(explicitPath, options = {}) {
232
- const [enginePath, source] = await locateEngine(explicitPath, options.allowDownload ?? true);
232
+ async function resolveEngine(explicitPath, options2 = {}) {
233
+ const [enginePath, source] = await locateEngine(explicitPath, options2.allowDownload ?? true);
233
234
  const found = engineVersion(enginePath);
234
235
  if (found !== ENGINE_VERSION) {
235
236
  throw new EngineVersionMismatchError(enginePath, source, found, ENGINE_VERSION);
@@ -288,8 +289,8 @@ not_evaluated: warn
288
289
  `;
289
290
  var GITHUB_WORKFLOW_TEMPLATE = `# Written by \`attest init --ci github\`.
290
291
  # 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.
292
+ # the JUnit file written, the policy evaluated and the pull request summarised; the last
293
+ # step fails the job when the tests failed. A policy warning (exit 2) does not fail the job.
293
294
  name: attest
294
295
 
295
296
  on:
@@ -299,11 +300,13 @@ on:
299
300
 
300
301
  permissions:
301
302
  contents: read
302
- pull-requests: write
303
303
 
304
304
  jobs:
305
305
  attest:
306
306
  runs-on: ubuntu-latest
307
+ permissions:
308
+ contents: read
309
+ pull-requests: write
307
310
  steps:
308
311
  - uses: actions/checkout@v6
309
312
  with:
@@ -317,12 +320,17 @@ jobs:
317
320
  id: tests
318
321
  continue-on-error: true
319
322
  run: npx vitest run ${REPORT_ARGS}
323
+ - name: Write the JUnit report
324
+ if: always()
325
+ run: npx attest report junit --report attest-report.json --out attest-junit.xml
320
326
  - name: Upload the Attest report
321
327
  if: always()
322
328
  uses: actions/upload-artifact@v7
323
329
  with:
324
330
  name: attest-report
325
- path: attest-report.json
331
+ path: |
332
+ attest-report.json
333
+ attest-junit.xml
326
334
  if-no-files-found: error
327
335
  - name: Evaluate the merge policy
328
336
  if: always()
@@ -336,8 +344,9 @@ jobs:
336
344
  fi
337
345
  exit "$code"
338
346
  - 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.
347
+ # Posts one summary comment (updated on every run) and annotates failures at their
348
+ # file and line. Fork pull requests get a read-only token that cannot comment; the
349
+ # last step still fails the job when tests failed.
341
350
  if: >-
342
351
  always() && github.event_name == 'pull_request' &&
343
352
  github.event.pull_request.head.repo.full_name == github.repository
@@ -361,10 +370,10 @@ function write(file, content) {
361
370
  process.stdout.write(`Created: ${file}
362
371
  `);
363
372
  }
364
- function scaffoldProject(targetDir, options = {}) {
373
+ function scaffoldProject(targetDir, options2 = {}) {
365
374
  write(path2.join(targetDir, "tests", "my-agent.test.ts"), SAMPLE_TEST_TEMPLATE);
366
375
  write(path2.join(targetDir, "attest.policy.yaml"), POLICY_TEMPLATE);
367
- if (options.ci === "github") {
376
+ if (options2.ci === "github") {
368
377
  write(path2.join(targetDir, ".github", "workflows", "attest.yml"), GITHUB_WORKFLOW_TEMPLATE);
369
378
  }
370
379
  process.stdout.write(
@@ -374,9 +383,232 @@ Next: run \`npx vitest run\`. Then change the line marked ${DEMO_MARKER} in test
374
383
  );
375
384
  }
376
385
 
386
+ // src/capture.ts
387
+ import { spawn as spawn2 } from "child_process";
388
+ import * as fs3 from "fs";
389
+ import * as path3 from "path";
390
+ import { fileURLToPath } from "url";
391
+ var FIXTURE_VERSION = 1;
392
+ var DEFAULT_FIXTURE_DIR = path3.join("tests", "attest", "fixtures");
393
+ var CaptureError = class extends Error {
394
+ /** The fixture written, when the engine wrote one. */
395
+ fixture;
396
+ constructor(message, fixture = null) {
397
+ super(message);
398
+ this.name = "CaptureError";
399
+ this.fixture = fixture;
400
+ }
401
+ };
402
+ function toPath(p) {
403
+ return p instanceof URL ? fileURLToPath(p) : p;
404
+ }
405
+ function loadFixture(file) {
406
+ const p = toPath(file);
407
+ const doc = JSON.parse(fs3.readFileSync(p, "utf8"));
408
+ if (!isObject(doc) || doc["fixture_version"] !== FIXTURE_VERSION) {
409
+ throw new CaptureError(`${p}: not a fixture_version ${FIXTURE_VERSION} capture fixture`);
410
+ }
411
+ validate(p, doc);
412
+ return doc;
413
+ }
414
+ var NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
415
+ function isObject(v) {
416
+ return typeof v === "object" && v !== null && !Array.isArray(v);
417
+ }
418
+ function kindOf(v) {
419
+ if (Array.isArray(v)) return "array";
420
+ if (isObject(v)) return "object";
421
+ if (typeof v === "string" || typeof v === "boolean") return typeof v;
422
+ return "other";
423
+ }
424
+ function validate(p, doc) {
425
+ const need = (where, obj, fields) => {
426
+ if (!isObject(obj)) throw new CaptureError(`${p}: ${where} is not an object`);
427
+ for (const [key, kind] of Object.entries(fields)) {
428
+ if (kindOf(obj[key]) !== kind) throw new CaptureError(`${p}: ${where}.${key} is missing or not a ${kind}`);
429
+ }
430
+ return obj;
431
+ };
432
+ need("fixture", doc, { name: "string", source: "object", trace: "object", exchanges: "array", contracts: "array", verification: "object" });
433
+ if (!("world" in doc)) throw new CaptureError(`${p}: fixture.world is missing`);
434
+ if (!NAME_PATTERN.test(doc["name"])) {
435
+ throw new CaptureError(`${p}: fixture name ${JSON.stringify(doc["name"])} is not a valid fixture name`);
436
+ }
437
+ const v = need("fixture.verification", doc["verification"], {
438
+ verified: "boolean",
439
+ expect_fail: "boolean",
440
+ results: "array",
441
+ expected_failures: "array"
442
+ });
443
+ doc["contracts"].forEach(
444
+ (c, i) => need(`fixture.contracts[${i}]`, c, { id: "string", kind: "string", description: "string", type: "string", spec: "object" })
445
+ );
446
+ v["results"].forEach(
447
+ (r, i) => need(`fixture.verification.results[${i}]`, r, { contract: "string", status: "string", explanation: "string" })
448
+ );
449
+ }
450
+ function objectKey(k) {
451
+ if (k === "__proto__") return `[${JSON.stringify(k)}]`;
452
+ return /^[A-Za-z_$][\w$]*$/.test(k) ? k : JSON.stringify(k);
453
+ }
454
+ function comment(text) {
455
+ return text.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, " ");
456
+ }
457
+ function ts(value) {
458
+ if (value === null || typeof value === "boolean" || typeof value === "number") {
459
+ return JSON.stringify(value);
460
+ }
461
+ if (typeof value === "string") return JSON.stringify(value);
462
+ if (Array.isArray(value)) return `[${value.map(ts).join(", ")}]`;
463
+ if (typeof value === "object") {
464
+ const entries = Object.entries(value).map(
465
+ ([k, v]) => `${objectKey(k)}: ${ts(v)}`
466
+ );
467
+ return entries.length === 0 ? "{}" : `{ ${entries.join(", ")} }`;
468
+ }
469
+ throw new Error(`value ${String(value)} has no literal`);
470
+ }
471
+ function str(spec, key) {
472
+ const v = spec[key];
473
+ if (typeof v !== "string") throw new Error(`missing ${key}`);
474
+ return v;
475
+ }
476
+ function options(entries) {
477
+ const set = entries.filter(([, v]) => v !== void 0 && v !== null && v !== false);
478
+ return set.length === 0 ? "" : `{ ${set.map(([k, v]) => `${k}: ${ts(v)}`).join(", ")} }`;
479
+ }
480
+ function call(method, args, opts) {
481
+ return `.${method}(${[...args, ...opts ? [opts] : []].join(", ")})`;
482
+ }
483
+ function dslCall(contract) {
484
+ const s = contract.spec;
485
+ const soft = s["soft"] === true ? true : void 0;
486
+ try {
487
+ if (contract.type === "trace") {
488
+ const check = s["check"];
489
+ if (check === "trajectory" && s["mode"] !== "partial_order") {
490
+ const mode = s["mode"] === "strict" || s["mode"] === void 0 ? void 0 : s["mode"];
491
+ if (!Array.isArray(s["tools"])) throw new Error("missing tools");
492
+ return call("trajectory", [ts(s["tools"])], options([["mode", mode], ["soft", soft]]));
493
+ }
494
+ if (check === "tool_order") {
495
+ return call("toolCalledBefore", [ts(str(s, "before")), ts(str(s, "after"))], options([["soft", soft]]));
496
+ }
497
+ if (check === "tool_cardinality") {
498
+ return call(
499
+ "toolCardinality",
500
+ [ts(str(s, "tool"))],
501
+ options([
502
+ ["per", s["per"]],
503
+ ["atLeast", s["min"]],
504
+ ["atMost", s["max"]],
505
+ ["soft", soft]
506
+ ])
507
+ );
508
+ }
509
+ }
510
+ if (contract.type === "schema" && s["target"] === "output.structured") {
511
+ if (typeof s["schema"] !== "object" || s["schema"] === null) throw new Error("missing schema");
512
+ return call("outputMatchesSchema", [ts(s["schema"])], "");
513
+ }
514
+ if (contract.type === "constraint" && s["field"] === "metadata.cost_usd" && s["operator"] === "lte") {
515
+ if (typeof s["value"] !== "number") throw new Error("missing value");
516
+ return call("costUnder", [ts(s["value"])], options([["soft", soft]]));
517
+ }
518
+ } catch (err) {
519
+ throw new Error(`contract ${contract.id}: malformed spec (${err.message})`);
520
+ }
521
+ throw new Error(`contract ${contract.id}: no TypeScript DSL call for ${contract.type} spec`);
522
+ }
523
+ function testPathFor(fixturePath) {
524
+ const name = path3.basename(fixturePath).replace(/\.attest\.json$/, "");
525
+ return path3.join(path3.dirname(path3.dirname(fixturePath)), `${name}.test.ts`);
526
+ }
527
+ function renderVitest(fixture, fixturePath, testPath) {
528
+ const calls = [];
529
+ const skipped = [];
530
+ for (const c of fixture.contracts) {
531
+ try {
532
+ calls.push([c, dslCall(c)]);
533
+ } catch (err) {
534
+ skipped.push(err.message);
535
+ }
536
+ }
537
+ const rel = path3.relative(path3.dirname(testPath), fixturePath).split(path3.sep).join("/");
538
+ const status = new Map(fixture.verification.results.map((r) => [r.contract, r.status]));
539
+ const expectFail = fixture.verification.expect_fail;
540
+ const lines = [
541
+ `// Contracts drafted by \`attest capture\` from fixture ${comment(ts(fixture.name))}.`,
542
+ "//",
543
+ "// Review and edit before committing: they check the captured trace without running",
544
+ "// the agent. Re-capturing does not overwrite this file.",
545
+ `import { attestExpect, loadFixture, type ExpectChain } from "@attest-ai/core";`,
546
+ `import { ${expectFail ? "inspect" : "evaluate"} } from "@attest-ai/vitest";`,
547
+ `import { describe, ${expectFail ? "expect, " : ""}it } from "vitest";`,
548
+ "",
549
+ `const FIXTURE = loadFixture(new URL(${ts(rel.startsWith(".") ? rel : `./${rel}`)}, import.meta.url));`
550
+ ];
551
+ if (skipped.length > 0) {
552
+ lines.push("", "// Drafted contracts without a TypeScript DSL call (left out):");
553
+ for (const s of skipped) lines.push(`// - ${comment(s)}`);
554
+ }
555
+ lines.push("", "function contracts(): ExpectChain {", " return attestExpect(FIXTURE.trace)");
556
+ for (const [c, dsl] of calls) {
557
+ lines.push(` // ${comment(`${c.id}: ${c.description}`)}`, ` ${dsl}`);
558
+ }
559
+ lines[lines.length - 1] += ";";
560
+ lines.push("}");
561
+ if (expectFail) {
562
+ lines.push("", "// Status of each contract on the captured incident, in contracts() order.");
563
+ lines.push("const EXPECTED: Array<[string, string]> = [");
564
+ for (const [c] of calls) lines.push(` [${ts(c.id)}, ${ts(status.get(c.id) ?? "pass")}],`);
565
+ lines.push(
566
+ "];",
567
+ "",
568
+ `describe(${ts(fixture.name)}, () => {`,
569
+ ` it("reproduces the incident", async () => {`,
570
+ " const result = await inspect(contracts());",
571
+ "",
572
+ " expect(result.assertionResults.map((row) => row.status)).toEqual(EXPECTED.map(([, s]) => s));",
573
+ " });",
574
+ "});"
575
+ );
576
+ } else {
577
+ lines.push(
578
+ "",
579
+ `describe(${ts(fixture.name)}, () => {`,
580
+ ` it("keeps its drafted contracts", async () => {`,
581
+ " await evaluate(contracts());",
582
+ " });",
583
+ "});"
584
+ );
585
+ }
586
+ return `${lines.join("\n")}
587
+ `;
588
+ }
589
+ function emitVitest(fixturePath) {
590
+ const testPath = testPathFor(fixturePath);
591
+ const existing = fs3.lstatSync(testPath, { throwIfNoEntry: false });
592
+ if (existing?.isSymbolicLink()) {
593
+ throw new CaptureError(`${testPath} is a symbolic link; refusing to draft a test through it`);
594
+ }
595
+ if (existing !== void 0) return null;
596
+ const source = renderVitest(loadFixture(fixturePath), fixturePath, testPath);
597
+ fs3.mkdirSync(path3.dirname(testPath), { recursive: true });
598
+ try {
599
+ fs3.writeFileSync(testPath, source, { encoding: "utf8", flag: "wx" });
600
+ } catch (err) {
601
+ if (err.code === "EEXIST") {
602
+ throw new CaptureError(`${testPath} appeared while drafting; left unchanged`);
603
+ }
604
+ throw err;
605
+ }
606
+ return testPath;
607
+ }
608
+
377
609
  // src/cli.ts
378
610
  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";
611
+ 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\n`attest capture` (engine) also drafts tests/attest/<name>.test.ts from a verified\nfixture, unless that file exists.\n\noptions:\n --version print the SDK version and the engine version it requires\n --help print this help\n";
380
612
  var INIT_USAGE = "usage: attest init [--ci github] (scaffolds into the current directory)";
381
613
  function cmdInit(args) {
382
614
  if (args.some((a) => HELP_FLAGS.has(a))) {
@@ -468,10 +700,67 @@ async function forward(args) {
468
700
  );
469
701
  return 1;
470
702
  }
703
+ if (command === "capture" && captureWritesFixture(args.slice(1))) {
704
+ return captureAndEmit(engine, args.slice(1));
705
+ }
471
706
  const proc = spawnSync2(engine, args, { stdio: "inherit" });
472
707
  if (proc.error) throw proc.error;
473
708
  return exitStatus(proc.status, proc.signal);
474
709
  }
710
+ var CAPTURE_NO_TEST = /* @__PURE__ */ new Set(["check-redaction", "serve", "h", "help"]);
711
+ function flagName(arg) {
712
+ if (!arg.startsWith("-") || arg === "-" || arg === "--") return null;
713
+ return arg.replace(/^-+/, "").split("=", 1)[0] ?? null;
714
+ }
715
+ function captureWritesFixture(args) {
716
+ for (const a of args) {
717
+ if (a === "--") break;
718
+ const name = flagName(a);
719
+ if (name !== null && CAPTURE_NO_TEST.has(name)) return false;
720
+ }
721
+ return true;
722
+ }
723
+ function userResultFile(args) {
724
+ for (const a of args) {
725
+ if (a === "--") break;
726
+ if (flagName(a) === "result-file") return true;
727
+ }
728
+ return false;
729
+ }
730
+ function captureAndEmit(engine, args) {
731
+ if (userResultFile(args)) {
732
+ process.stderr.write(
733
+ "attest: capture --result-file is set by the attest CLI; run attest-engine capture directly to choose it\n"
734
+ );
735
+ return 2;
736
+ }
737
+ const tmp = fs4.mkdtempSync(path4.join(os2.tmpdir(), "attest-capture-"));
738
+ const resultFile = path4.join(tmp, "result.json");
739
+ let result;
740
+ let code;
741
+ try {
742
+ const proc = spawnSync2(engine, ["capture", "--result-file", resultFile, ...args], { stdio: "inherit" });
743
+ if (proc.error) throw proc.error;
744
+ code = exitStatus(proc.status, proc.signal);
745
+ if (!fs4.existsSync(resultFile)) return code;
746
+ result = JSON.parse(fs4.readFileSync(resultFile, "utf8"));
747
+ } finally {
748
+ fs4.rmSync(tmp, { recursive: true, force: true });
749
+ }
750
+ const fixture = result.fixture;
751
+ if (!result.verified) {
752
+ process.stderr.write(` test: none drafted for unverified ${fixture}
753
+ `);
754
+ return code;
755
+ }
756
+ const written = emitVitest(fixture);
757
+ process.stderr.write(
758
+ written === null ? ` test: ${testPathFor(fixture)} exists; left unchanged
759
+ ` : ` test: drafted ${written}
760
+ `
761
+ );
762
+ return code;
763
+ }
475
764
  async function main(argv = process.argv.slice(2)) {
476
765
  const [command] = argv;
477
766
  if (command === void 0 || HELP_FLAGS.has(command) || command === "help" && argv.length === 1) {
@@ -495,8 +784,8 @@ ${SDK_USAGE}`);
495
784
  }
496
785
  function isDirectExecution() {
497
786
  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));
787
+ if (script === void 0 || !fs4.existsSync(script)) return false;
788
+ return fs4.realpathSync(script) === fs4.realpathSync(fileURLToPath2(import.meta.url));
500
789
  }
501
790
  if (isDirectExecution()) {
502
791
  main().then(
@@ -509,6 +798,7 @@ if (isDirectExecution()) {
509
798
  );
510
799
  }
511
800
  export {
801
+ captureWritesFixture,
512
802
  engineCommands,
513
803
  exitStatus,
514
804
  main