dunstan 0.1.15 → 0.1.18

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.
Files changed (3) hide show
  1. package/README.md +59 -7
  2. package/dist/dunstan.mjs +1676 -430
  3. package/package.json +1 -1
package/dist/dunstan.mjs CHANGED
@@ -101,7 +101,7 @@ var require_code = __commonJS({
101
101
  }
102
102
  exports._ = _;
103
103
  var plus = new _Code("+");
104
- function str3(strs, ...args) {
104
+ function str4(strs, ...args) {
105
105
  const expr = [safeStringify(strs[0])];
106
106
  let i = 0;
107
107
  while (i < args.length) {
@@ -112,7 +112,7 @@ var require_code = __commonJS({
112
112
  optimize(expr);
113
113
  return new _Code(expr);
114
114
  }
115
- exports.str = str3;
115
+ exports.str = str4;
116
116
  function addCodeArg(code, arg) {
117
117
  if (arg instanceof _Code)
118
118
  code.push(...arg._items);
@@ -155,7 +155,7 @@ var require_code = __commonJS({
155
155
  return;
156
156
  }
157
157
  function strConcat(c1, c2) {
158
- return c2.emptyStr() ? c1 : c1.emptyStr() ? c2 : str3`${c1}${c2}`;
158
+ return c2.emptyStr() ? c1 : c1.emptyStr() ? c2 : str4`${c1}${c2}`;
159
159
  }
160
160
  exports.strConcat = strConcat;
161
161
  function interpolate(x) {
@@ -859,9 +859,9 @@ var require_codegen = __commonJS({
859
859
  forOf(nameOrPrefix, iterable, forBody, varKind = scope_1.varKinds.const) {
860
860
  const name = this._scope.toName(nameOrPrefix);
861
861
  if (this.opts.es5) {
862
- const arr2 = iterable instanceof code_1.Name ? iterable : this.var("_arr", iterable);
863
- return this.forRange("_i", 0, (0, code_1._)`${arr2}.length`, (i) => {
864
- this.var(name, (0, code_1._)`${arr2}[${i}]`);
862
+ const arr3 = iterable instanceof code_1.Name ? iterable : this.var("_arr", iterable);
863
+ return this.forRange("_i", 0, (0, code_1._)`${arr3}.length`, (i) => {
864
+ this.var(name, (0, code_1._)`${arr3}[${i}]`);
865
865
  forBody(name);
866
866
  });
867
867
  }
@@ -869,12 +869,12 @@ var require_codegen = __commonJS({
869
869
  }
870
870
  // `for-in` statement.
871
871
  // With option `ownProperties` replaced with a `for-of` loop for object keys
872
- forIn(nameOrPrefix, obj2, forBody, varKind = this.opts.es5 ? scope_1.varKinds.var : scope_1.varKinds.const) {
872
+ forIn(nameOrPrefix, obj3, forBody, varKind = this.opts.es5 ? scope_1.varKinds.var : scope_1.varKinds.const) {
873
873
  if (this.opts.ownProperties) {
874
- return this.forOf(nameOrPrefix, (0, code_1._)`Object.keys(${obj2})`, forBody);
874
+ return this.forOf(nameOrPrefix, (0, code_1._)`Object.keys(${obj3})`, forBody);
875
875
  }
876
876
  const name = this._scope.toName(nameOrPrefix);
877
- return this._for(new ForIter("in", varKind, name, obj2), () => forBody(name));
877
+ return this._for(new ForIter("in", varKind, name, obj3), () => forBody(name));
878
878
  }
879
879
  // end `for` loop
880
880
  endFor() {
@@ -1060,9 +1060,9 @@ var require_util = __commonJS({
1060
1060
  exports.checkStrictMode = exports.getErrorPath = exports.Type = exports.useFunc = exports.setEvaluated = exports.evaluatedPropsToName = exports.mergeEvaluated = exports.eachItem = exports.unescapeJsonPointer = exports.escapeJsonPointer = exports.escapeFragment = exports.unescapeFragment = exports.schemaRefOrVal = exports.schemaHasRulesButRef = exports.schemaHasRules = exports.checkUnknownRules = exports.alwaysValidSchema = exports.toHash = void 0;
1061
1061
  var codegen_1 = require_codegen();
1062
1062
  var code_1 = require_code();
1063
- function toHash(arr2) {
1063
+ function toHash(arr3) {
1064
1064
  const hash2 = {};
1065
- for (const item of arr2)
1065
+ for (const item of arr3)
1066
1066
  hash2[item] = true;
1067
1067
  return hash2;
1068
1068
  }
@@ -1117,22 +1117,22 @@ var require_util = __commonJS({
1117
1117
  return (0, codegen_1._)`${topSchemaRef}${schemaPath}${(0, codegen_1.getProperty)(keyword)}`;
1118
1118
  }
1119
1119
  exports.schemaRefOrVal = schemaRefOrVal;
1120
- function unescapeFragment(str3) {
1121
- return unescapeJsonPointer(decodeURIComponent(str3));
1120
+ function unescapeFragment(str4) {
1121
+ return unescapeJsonPointer(decodeURIComponent(str4));
1122
1122
  }
1123
1123
  exports.unescapeFragment = unescapeFragment;
1124
- function escapeFragment(str3) {
1125
- return encodeURIComponent(escapeJsonPointer(str3));
1124
+ function escapeFragment(str4) {
1125
+ return encodeURIComponent(escapeJsonPointer(str4));
1126
1126
  }
1127
1127
  exports.escapeFragment = escapeFragment;
1128
- function escapeJsonPointer(str3) {
1129
- if (typeof str3 == "number")
1130
- return `${str3}`;
1131
- return str3.replace(/~/g, "~0").replace(/\//g, "~1");
1128
+ function escapeJsonPointer(str4) {
1129
+ if (typeof str4 == "number")
1130
+ return `${str4}`;
1131
+ return str4.replace(/~/g, "~0").replace(/\//g, "~1");
1132
1132
  }
1133
1133
  exports.escapeJsonPointer = escapeJsonPointer;
1134
- function unescapeJsonPointer(str3) {
1135
- return str3.replace(/~1/g, "/").replace(/~0/g, "~");
1134
+ function unescapeJsonPointer(str4) {
1135
+ return str4.replace(/~1/g, "/").replace(/~0/g, "~");
1136
1136
  }
1137
1137
  exports.unescapeJsonPointer = unescapeJsonPointer;
1138
1138
  function eachItem(xs, f) {
@@ -2157,8 +2157,8 @@ var require_json_schema_traverse = __commonJS({
2157
2157
  post(schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex);
2158
2158
  }
2159
2159
  }
2160
- function escapeJsonPtr(str3) {
2161
- return str3.replace(/~/g, "~0").replace(/\//g, "~1");
2160
+ function escapeJsonPtr(str4) {
2161
+ return str4.replace(/~/g, "~0").replace(/\//g, "~1");
2162
2162
  }
2163
2163
  }
2164
2164
  });
@@ -2694,11 +2694,11 @@ var require_validate = __commonJS({
2694
2694
  if (!this.allErrors)
2695
2695
  this.gen.if(cond);
2696
2696
  }
2697
- setParams(obj2, assign) {
2697
+ setParams(obj3, assign) {
2698
2698
  if (assign)
2699
- Object.assign(this.params, obj2);
2699
+ Object.assign(this.params, obj3);
2700
2700
  else
2701
- this.params = obj2;
2701
+ this.params = obj3;
2702
2702
  }
2703
2703
  block$data(valid, codeBlock, $dataValid = codegen_1.nil) {
2704
2704
  this.gen.block(() => {
@@ -3255,10 +3255,10 @@ var require_utils = __commonJS({
3255
3255
  isIPV6: true
3256
3256
  };
3257
3257
  }
3258
- function findToken(str3, token) {
3258
+ function findToken(str4, token) {
3259
3259
  let ind = 0;
3260
- for (let i = 0; i < str3.length; i++) {
3261
- if (str3[i] === token) ind++;
3260
+ for (let i = 0; i < str4.length; i++) {
3261
+ if (str4[i] === token) ind++;
3262
3262
  }
3263
3263
  return ind;
3264
3264
  }
@@ -4272,7 +4272,7 @@ var require_core = __commonJS({
4272
4272
  var util_1 = require_util();
4273
4273
  var $dataRefSchema = require_data();
4274
4274
  var uri_1 = require_uri();
4275
- var defaultRegExp = (str3, flags) => new RegExp(str3, flags);
4275
+ var defaultRegExp = (str4, flags) => new RegExp(str4, flags);
4276
4276
  defaultRegExp.code = "new RegExp";
4277
4277
  var META_IGNORE_OPTIONS = ["removeAdditional", "useDefaults", "coerceTypes"];
4278
4278
  var EXT_SCOPE_NAMES = /* @__PURE__ */ new Set([
@@ -5067,16 +5067,16 @@ var require_ucs2length = __commonJS({
5067
5067
  "node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/runtime/ucs2length.js"(exports) {
5068
5068
  "use strict";
5069
5069
  Object.defineProperty(exports, "__esModule", { value: true });
5070
- function ucs2length(str3) {
5071
- const len = str3.length;
5070
+ function ucs2length(str4) {
5071
+ const len = str4.length;
5072
5072
  let length = 0;
5073
5073
  let pos = 0;
5074
5074
  let value;
5075
5075
  while (pos < len) {
5076
5076
  length++;
5077
- value = str3.charCodeAt(pos++);
5077
+ value = str4.charCodeAt(pos++);
5078
5078
  if (value >= 55296 && value <= 56319 && pos < len) {
5079
- value = str3.charCodeAt(pos);
5079
+ value = str4.charCodeAt(pos);
5080
5080
  if ((value & 64512) === 56320)
5081
5081
  pos++;
5082
5082
  }
@@ -7534,8 +7534,8 @@ var require_formats = __commonJS({
7534
7534
  }
7535
7535
  var DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/;
7536
7536
  var DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
7537
- function date5(str3) {
7538
- const matches = DATE.exec(str3);
7537
+ function date5(str4) {
7538
+ const matches = DATE.exec(str4);
7539
7539
  if (!matches)
7540
7540
  return false;
7541
7541
  const year = +matches[1];
@@ -7554,8 +7554,8 @@ var require_formats = __commonJS({
7554
7554
  }
7555
7555
  var TIME = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i;
7556
7556
  function getTime(strictTimeZone) {
7557
- return function time3(str3) {
7558
- const matches = TIME.exec(str3);
7557
+ return function time3(str4) {
7558
+ const matches = TIME.exec(str4);
7559
7559
  if (!matches)
7560
7560
  return false;
7561
7561
  const hr = +matches[1];
@@ -7601,8 +7601,8 @@ var require_formats = __commonJS({
7601
7601
  var DATE_TIME_SEPARATOR = /t|\s/i;
7602
7602
  function getDateTime(strictTimeZone) {
7603
7603
  const time3 = getTime(strictTimeZone);
7604
- return function date_time(str3) {
7605
- const dateTime = str3.split(DATE_TIME_SEPARATOR);
7604
+ return function date_time(str4) {
7605
+ const dateTime = str4.split(DATE_TIME_SEPARATOR);
7606
7606
  return dateTime.length === 2 && date5(dateTime[0]) && time3(dateTime[1]);
7607
7607
  };
7608
7608
  }
@@ -7627,13 +7627,13 @@ var require_formats = __commonJS({
7627
7627
  }
7628
7628
  var NOT_URI_FRAGMENT = /\/|:/;
7629
7629
  var URI = /^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)(?:\?(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i;
7630
- function uri(str3) {
7631
- return NOT_URI_FRAGMENT.test(str3) && URI.test(str3);
7630
+ function uri(str4) {
7631
+ return NOT_URI_FRAGMENT.test(str4) && URI.test(str4);
7632
7632
  }
7633
7633
  var BYTE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/gm;
7634
- function byte(str3) {
7634
+ function byte(str4) {
7635
7635
  BYTE.lastIndex = 0;
7636
- return BYTE.test(str3);
7636
+ return BYTE.test(str4);
7637
7637
  }
7638
7638
  var MIN_INT32 = -(2 ** 31);
7639
7639
  var MAX_INT32 = 2 ** 31 - 1;
@@ -7647,11 +7647,11 @@ var require_formats = __commonJS({
7647
7647
  return true;
7648
7648
  }
7649
7649
  var Z_ANCHOR = /[^\\]\\Z/;
7650
- function regex(str3) {
7651
- if (Z_ANCHOR.test(str3))
7650
+ function regex(str4) {
7651
+ if (Z_ANCHOR.test(str4))
7652
7652
  return false;
7653
7653
  try {
7654
- new RegExp(str3);
7654
+ new RegExp(str4);
7655
7655
  return true;
7656
7656
  } catch (e) {
7657
7657
  return false;
@@ -8030,7 +8030,7 @@ import { readFileSync as readFileSync8 } from "node:fs";
8030
8030
  import { createHash } from "node:crypto";
8031
8031
 
8032
8032
  // src/spec/constants.ts
8033
- var SPEC_VERSION = "0.1.9";
8033
+ var SPEC_VERSION = "0.1.11";
8034
8034
  var BLOCK_VERSION = "0.1";
8035
8035
  var SUPPORTED_BLOCK_VERSIONS = [BLOCK_VERSION];
8036
8036
  var BLOCK_INFO_STRING = "dunstan-handback";
@@ -8038,6 +8038,8 @@ var STATEMENT_TYPE = "https://in-toto.io/Statement/v1";
8038
8038
  var PREDICATE_TYPE = "https://barglabs.ai/dunstan/record/v0.1";
8039
8039
  var DRAFT_SPEC_VERSION = "0.2.0-draft";
8040
8040
  var DRAFT_PREDICATE_TYPE = "https://barglabs.ai/dunstan/record/v0.2-draft";
8041
+ var GITLAB_DRAFT_SPEC_VERSION = `${SPEC_VERSION}-gitlab-0.1-draft`;
8042
+ var GITLAB_DRAFT_PREDICATE_TYPE = "https://barglabs.ai/dunstan/record/v0.1-gitlab-draft";
8041
8043
  var BLOCK_SUBJECT_NAME = "handback-block";
8042
8044
  var SIGNATURE_NAMESPACE = "dunstan-record";
8043
8045
  var MAX_BLOCK_DEPTH = 32;
@@ -8344,11 +8346,11 @@ function unreadReason(unread2) {
8344
8346
  function absentSection(section) {
8345
8347
  return `evidence_field_unpopulated:${section}`;
8346
8348
  }
8347
- function toSecond(timestamp2) {
8348
- if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z$/.test(timestamp2)) {
8349
- throw new Error(`not an RFC 3339 UTC timestamp: ${timestamp2}`);
8349
+ function toSecond(timestamp3) {
8350
+ if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z$/.test(timestamp3)) {
8351
+ throw new Error(`not an RFC 3339 UTC timestamp: ${timestamp3}`);
8350
8352
  }
8351
- return timestamp2.slice(0, 19);
8353
+ return timestamp3.slice(0, 19);
8352
8354
  }
8353
8355
  function compareCodeUnits(a, b) {
8354
8356
  return a < b ? -1 : a > b ? 1 : 0;
@@ -8533,9 +8535,6 @@ function checkReferences(block, evidence, repository) {
8533
8535
  }
8534
8536
  const observed = { exists: entry.exists, reachableFromHead: entry.reachableFromHead };
8535
8537
  if (!entry.exists) return fail("reference", field, reference, observed, "not_found");
8536
- if (!entry.reachableFromHead) {
8537
- return fail("reference", field, reference, observed, "not_reachable");
8538
- }
8539
8538
  return pass("reference", field, reference, observed);
8540
8539
  });
8541
8540
  }
@@ -8553,6 +8552,15 @@ function countRow(field, declared, evidence, pick2) {
8553
8552
  const observed = pick2(evidence.runs[0]);
8554
8553
  return observed === declared ? pass("count", field, declared, observed) : fail("count", field, declared, observed, "count_mismatch");
8555
8554
  }
8555
+ function producerReason(record2, evidence) {
8556
+ const files = evidence.files;
8557
+ if (files === void 0) return absentSection("files");
8558
+ if (files.status !== "ok") return unreadReason(files);
8559
+ if (files.entries.some((e) => e.path === record2.workflow || e.previousPath === record2.workflow)) {
8560
+ return "record_producer_changed_by_pr";
8561
+ }
8562
+ return files.complete ? void 0 : "file_list_truncated";
8563
+ }
8556
8564
  function checkTests(block, evidence) {
8557
8565
  const rows = [];
8558
8566
  (block.tests ?? []).forEach((test, i) => {
@@ -8567,6 +8575,14 @@ function checkTests(block, evidence) {
8567
8575
  return;
8568
8576
  }
8569
8577
  const record2 = test.record;
8578
+ const producer = producerReason(record2, evidence);
8579
+ if (producer !== void 0) {
8580
+ rows.push(unverifiable("count", countField, test.count, producer));
8581
+ if (test.failures !== void 0) {
8582
+ rows.push(unverifiable("count", failuresField, test.failures, producer));
8583
+ }
8584
+ return;
8585
+ }
8570
8586
  const entry = evidence.testRecords?.find((r) => sameRecord(r.record, record2));
8571
8587
  rows.push(countRow(countField, test.count, entry, (run) => run.executed));
8572
8588
  if (test.failures !== void 0) {
@@ -9881,7 +9897,7 @@ async function reportFromComment(client, repository, number4, login) {
9881
9897
  // src/record/checker.ts
9882
9898
  import { readFileSync } from "node:fs";
9883
9899
  var CHECKER_NAME = "dunstan";
9884
- var CHECKER_VERSION = "0.1.15";
9900
+ var CHECKER_VERSION = "0.1.18";
9885
9901
  function checkerIdentity(artifact) {
9886
9902
  return {
9887
9903
  name: CHECKER_NAME,
@@ -10077,7 +10093,7 @@ var FILES = { "handback-block-0.1.schema.json": `{
10077
10093
  }
10078
10094
  }
10079
10095
  }
10080
- `, "record-0.1.schema.json": '{\n "$schema": "https://json-schema.org/draft/2020-12/schema",\n "$id": "https://barglabs.ai/dunstan/spec/schema/record-0.1.schema.json",\n "title": "Dunstan record, v0.1",\n "description": "An in-toto Statement v1 whose predicate records one check of a handback block against the record. See spec/claim-format.md.",\n "type": "object",\n "required": ["_type", "subject", "predicateType", "predicate"],\n "additionalProperties": false,\n "properties": {\n "_type": { "const": "https://in-toto.io/Statement/v1" },\n "subject": {\n "type": "array",\n "minItems": 1,\n "maxItems": 2,\n "prefixItems": [{ "$ref": "#/$defs/commitSubject" }, { "$ref": "#/$defs/blockSubject" }],\n "items": false\n },\n "predicateType": { "const": "https://barglabs.ai/dunstan/record/v0.1" },\n "predicate": { "$ref": "#/$defs/predicate" }\n },\n "allOf": [\n {\n "if": {\n "properties": {\n "predicate": {\n "properties": { "block": { "properties": { "status": { "const": "found" } } } }\n }\n }\n },\n "then": { "properties": { "subject": { "minItems": 2 } } },\n "else": { "properties": { "subject": { "maxItems": 1 } } }\n }\n ],\n "$defs": {\n "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },\n "commitSha": { "type": "string", "pattern": "^[0-9a-f]{40}$" },\n "repository": {\n "description": "owner/repo",\n "type": "string",\n "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/[A-Za-z0-9._-]{1,100}$"\n },\n "issueRef": {\n "description": "owner/repo#N, always fully qualified in evidence.",\n "type": "string",\n "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/[A-Za-z0-9._-]{1,100}#[1-9][0-9]{0,9}$"\n },\n "timestamp": {\n "type": "string",\n "format": "date-time",\n "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\\\.[0-9]+)?Z$"\n },\n "sourceKind": {\n "enum": [\n "pull_request",\n "pull_request_files",\n "pull_request_commits",\n "closing_references",\n "repository",\n "issue",\n "commit",\n "compare",\n "check_runs",\n "workflow_runs",\n "artifact",\n "deployments"\n ]\n },\n "commitSubject": {\n "type": "object",\n "required": ["name", "digest"],\n "additionalProperties": false,\n "properties": {\n "name": {\n "type": "string",\n "pattern": "^git\\\\+https://github\\\\.com/[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/[A-Za-z0-9._-]{1,100}@[0-9a-f]{40}$"\n },\n "digest": {\n "type": "object",\n "required": ["gitCommit"],\n "additionalProperties": false,\n "properties": { "gitCommit": { "$ref": "#/$defs/commitSha" } }\n }\n }\n },\n "blockSubject": {\n "type": "object",\n "required": ["name", "digest"],\n "additionalProperties": false,\n "properties": {\n "name": { "const": "handback-block" },\n "digest": {\n "type": "object",\n "required": ["sha256"],\n "additionalProperties": false,\n "properties": { "sha256": { "$ref": "#/$defs/sha256" } }\n }\n }\n },\n "predicate": {\n "type": "object",\n "required": [\n "spec",\n "checker",\n "report",\n "block",\n "subject",\n "evidence",\n "claims",\n "verdict",\n "digests",\n "rerun",\n "assurance"\n ],\n "additionalProperties": false,\n "properties": {\n "spec": {\n "description": "The spec version the checker implements, semver.",\n "type": "string",\n "pattern": "^0\\\\.1\\\\.(?:0|[1-9][0-9]*)$"\n },\n "checker": {\n "type": "object",\n "required": ["name", "version", "digest"],\n "additionalProperties": false,\n "properties": {\n "name": { "type": "string", "minLength": 1, "maxLength": 214 },\n "version": { "type": "string", "minLength": 1, "maxLength": 100 },\n "digest": {\n "type": "object",\n "required": ["sha256"],\n "additionalProperties": false,\n "properties": { "sha256": { "$ref": "#/$defs/sha256" } }\n }\n }\n },\n "report": {\n "description": "The report\'s digest and where it was read. Never its text.",\n "type": "object",\n "required": ["sha256", "source"],\n "additionalProperties": false,\n "properties": {\n "sha256": { "$ref": "#/$defs/sha256" },\n "source": {\n "type": "object",\n "required": ["kind", "locator"],\n "additionalProperties": false,\n "properties": {\n "kind": { "enum": ["pr-body", "pr-comment", "file", "stdin", "api"] },\n "locator": { "type": "string", "minLength": 1, "maxLength": 2000 }\n }\n }\n }\n },\n "block": { "$ref": "#/$defs/block" },\n "subject": {\n "type": "object",\n "required": ["repository", "pullRequest", "headSha", "mergeSha"],\n "additionalProperties": false,\n "properties": {\n "repository": { "$ref": "#/$defs/repository" },\n "pullRequest": { "type": "integer", "minimum": 1 },\n "headSha": { "$ref": "#/$defs/commitSha" },\n "mergeSha": {\n "oneOf": [{ "$ref": "#/$defs/commitSha" }, { "type": "null" }]\n }\n }\n },\n "evidence": { "$ref": "#/$defs/evidence" },\n "claims": {\n "type": "array",\n "items": { "$ref": "#/$defs/claim" }\n },\n "verdict": { "$ref": "#/$defs/verdict" },\n "digests": {\n "type": "object",\n "required": ["claims", "evidence"],\n "additionalProperties": false,\n "properties": {\n "claims": { "$ref": "#/$defs/sha256" },\n "evidence": { "$ref": "#/$defs/sha256" }\n }\n },\n "rerun": {\n "type": "object",\n "required": ["offline", "online"],\n "additionalProperties": false,\n "properties": {\n "offline": { "type": "string", "minLength": 1, "maxLength": 2000 },\n "online": { "type": "string", "minLength": 1, "maxLength": 2000 }\n }\n },\n "assurance": {\n "oneOf": [\n {\n "type": "object",\n "required": ["status", "issuer"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "unsigned" },\n "issuer": { "const": "self-generated" }\n }\n },\n {\n "type": "object",\n "required": ["status", "issuer", "keyFingerprint"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "signed" },\n "issuer": { "type": "string", "minLength": 1, "maxLength": 320 },\n "keyFingerprint": { "type": "string", "pattern": "^SHA256:[A-Za-z0-9+/]{43}$" }\n }\n }\n ]\n }\n }\n },\n "verdict": { "enum": ["pass", "fail", "unverifiable"] },\n "blockError": {\n "type": "object",\n "required": ["code"],\n "additionalProperties": false,\n "properties": {\n "code": {\n "enum": [\n "unterminated_fence",\n "invalid_json",\n "duplicate_member",\n "unsupported_version",\n "schema_violation"\n ]\n },\n "pointer": { "type": "string", "pattern": "^(?:/.*)?$" },\n "keyword": { "type": "string", "minLength": 1 }\n }\n },\n "block": {\n "oneOf": [\n {\n "type": "object",\n "required": ["status", "sha256", "value"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "found" },\n "sha256": { "$ref": "#/$defs/sha256" },\n "value": { "$ref": "handback-block-0.1.schema.json" }\n }\n },\n {\n "type": "object",\n "required": ["status", "reason", "sha256", "value"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "missing" },\n "reason": { "const": "block_missing" },\n "sha256": { "type": "null" },\n "value": { "type": "null" }\n }\n },\n {\n "type": "object",\n "required": ["status", "reason", "count", "sha256", "value"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "ambiguous" },\n "reason": { "const": "block_ambiguous" },\n "count": { "type": "integer", "minimum": 2 },\n "sha256": { "type": "null" },\n "value": { "type": "null" }\n }\n },\n {\n "type": "object",\n "required": ["status", "reason", "errors", "sha256", "value"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "invalid" },\n "reason": { "const": "block_invalid" },\n "errors": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/blockError" } },\n "sha256": { "type": "null" },\n "value": { "type": "null" }\n }\n }\n ]\n },\n "claim": {\n "type": "object",\n "required": ["id", "check", "field", "declared", "observed", "verdict"],\n "additionalProperties": false,\n "properties": {\n "id": { "type": "string", "minLength": 1 },\n "check": { "enum": ["head", "scope", "reference", "count", "time"] },\n "field": {\n "description": "A JSON Pointer (RFC 6901) into the block.",\n "type": "string",\n "pattern": "^/.+$"\n },\n "declared": true,\n "observed": true,\n "verdict": { "$ref": "#/$defs/verdict" },\n "reason": { "$ref": "#/$defs/claimReason" }\n },\n "if": { "properties": { "verdict": { "const": "pass" } } },\n "then": { "not": { "required": ["reason"] } },\n "else": { "required": ["reason"] }\n },\n "claimReason": {\n "type": "string",\n "pattern": "^(?:head_mismatch|declared_not_changed|undeclared_file|file_list_truncated|not_closing|closing_link_unsettled|closing_base_not_default|closing_event_unsettled|not_found|not_reachable|count_mismatch|all_succeeded_mismatch|none_failed_mismatch|checks_incomplete|no_check_runs|no_succeeded_check_runs|check_runs_not_at_head|no_comparable_record_field|local_run_not_in_record|record_not_found|record_ambiguous|not_merged|premature|no_deployment|evidence_field_unpopulated:[A-Za-z0-9_.]+|source_unreadable:(?:pull_request|pull_request_files|pull_request_commits|closing_references|repository|issue|commit|compare|check_runs|workflow_runs|artifact|deployments))$"\n },\n "unread": {\n "description": "A section the checker could not use: its source errored, or the source answered without the field.",\n "oneOf": [\n {\n "type": "object",\n "required": ["status", "source"],\n "properties": {\n "status": { "const": "unreadable" },\n "source": { "$ref": "#/$defs/sourceKind" }\n }\n },\n {\n "type": "object",\n "required": ["status", "field"],\n "properties": {\n "status": { "const": "unpopulated" },\n "field": { "type": "string", "pattern": "^[A-Za-z0-9_.]+$" }\n }\n }\n ]\n },\n "evidence": {\n "type": "object",\n "required": ["pullRequest", "sources"],\n "additionalProperties": false,\n "properties": {\n "pullRequest": {\n "description": "Always read: a checker that cannot read the pull request writes no record.",\n "type": "object",\n "required": [\n "status",\n "number",\n "state",\n "merged",\n "mergedAt",\n "headSha",\n "mergeSha",\n "changedFiles"\n ],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "ok" },\n "number": { "type": "integer", "minimum": 1 },\n "state": { "enum": ["open", "closed"] },\n "merged": { "type": "boolean" },\n "mergedAt": { "oneOf": [{ "$ref": "#/$defs/timestamp" }, { "type": "null" }] },\n "headSha": { "$ref": "#/$defs/commitSha" },\n "mergeSha": { "oneOf": [{ "$ref": "#/$defs/commitSha" }, { "type": "null" }] },\n "changedFiles": { "type": "integer", "minimum": 0 }\n }\n },\n "files": {\n "oneOf": [\n {\n "type": "object",\n "required": ["status", "complete", "entries"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "ok" },\n "complete": { "type": "boolean" },\n "entries": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["path", "status"],\n "additionalProperties": false,\n "properties": {\n "path": { "type": "string", "minLength": 1 },\n "status": {\n "enum": [\n "added",\n "removed",\n "modified",\n "renamed",\n "copied",\n "changed",\n "unchanged"\n ]\n },\n "previousPath": { "type": "string", "minLength": 1 }\n }\n }\n }\n }\n },\n { "$ref": "#/$defs/unreadOnly" }\n ]\n },\n "closingReferences": {\n "oneOf": [\n {\n "type": "object",\n "required": ["status", "issues"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "ok" },\n "issues": { "type": "array", "items": { "$ref": "#/$defs/issueRef" } }\n }\n },\n { "$ref": "#/$defs/unreadOnly" }\n ]\n },\n "references": {\n "type": "array",\n "items": {\n "oneOf": [\n {\n "type": "object",\n "required": ["kind", "ref", "status", "exists"],\n "additionalProperties": false,\n "properties": {\n "kind": { "const": "issue" },\n "ref": { "$ref": "#/$defs/issueRef" },\n "status": { "const": "ok" },\n "exists": { "type": "boolean" },\n "resolvedAs": {\n "description": "Spec 0.1.3: the owner/repo#N GitHub answered the read of ref with, when it is another issue (an issue transferred to another repository).",\n "$ref": "#/$defs/issueRef"\n }\n },\n "if": { "required": ["resolvedAs"] },\n "then": { "properties": { "exists": { "const": true } } }\n },\n {\n "type": "object",\n "required": ["kind", "ref", "status", "exists", "reachableFromHead"],\n "additionalProperties": false,\n "properties": {\n "kind": { "const": "commit" },\n "ref": { "$ref": "#/$defs/commitSha" },\n "status": { "const": "ok" },\n "exists": { "type": "boolean" },\n "reachableFromHead": { "type": "boolean" }\n }\n },\n {\n "type": "object",\n "required": ["kind", "ref"],\n "properties": {\n "kind": { "enum": ["issue", "commit"] },\n "ref": { "type": "string", "minLength": 1 }\n },\n "allOf": [{ "$ref": "#/$defs/unread" }]\n }\n ]\n }\n },\n "closeEvents": {\n "description": "Spec 0.1.4: who closed each closes target that exists and is not among the closing references, read under the identity it resolves to; and the pull request\'s base, its repository\'s default branch and its commit ids.",\n "type": "object",\n "required": ["pullRequest", "targets"],\n "additionalProperties": false,\n "properties": {\n "pullRequest": {\n "oneOf": [\n {\n "type": "object",\n "required": ["status", "baseRef", "defaultBranch", "commits"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "ok" },\n "baseRef": { "type": "string", "minLength": 1 },\n "defaultBranch": {\n "oneOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }]\n },\n "commits": {\n "type": "array",\n "uniqueItems": true,\n "items": { "$ref": "#/$defs/commitSha" }\n }\n }\n },\n { "$ref": "#/$defs/unreadOnly" }\n ]\n },\n "targets": {\n "type": "array",\n "items": {\n "oneOf": [\n {\n "type": "object",\n "required": ["ref", "status", "type", "state", "events"],\n "additionalProperties": false,\n "properties": {\n "ref": { "$ref": "#/$defs/issueRef" },\n "status": { "const": "ok" },\n "type": { "enum": ["issue", "pull_request"] },\n "state": { "enum": ["open", "closed", "merged"] },\n "events": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["at", "closer"],\n "additionalProperties": false,\n "properties": {\n "at": { "$ref": "#/$defs/timestamp" },\n "closer": {\n "oneOf": [\n { "type": "null" },\n {\n "type": "object",\n "required": ["kind", "ref"],\n "additionalProperties": false,\n "properties": {\n "kind": { "const": "pull_request" },\n "ref": { "$ref": "#/$defs/issueRef" }\n }\n },\n {\n "type": "object",\n "required": ["kind", "oid"],\n "additionalProperties": false,\n "properties": {\n "kind": { "const": "commit" },\n "oid": { "$ref": "#/$defs/commitSha" }\n }\n },\n {\n "type": "object",\n "required": ["kind"],\n "additionalProperties": false,\n "properties": { "kind": { "const": "other" } }\n }\n ]\n }\n }\n }\n }\n },\n "if": { "properties": { "type": { "const": "issue" } } },\n "then": { "properties": { "state": { "enum": ["open", "closed"] } } }\n },\n {\n "type": "object",\n "required": ["ref"],\n "properties": { "ref": { "$ref": "#/$defs/issueRef" } },\n "allOf": [{ "$ref": "#/$defs/unread" }]\n }\n ]\n }\n }\n }\n },\n "checkRuns": {\n "oneOf": [\n {\n "type": "object",\n "required": ["status", "commit", "excludedIds", "runs"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "ok" },\n "commit": { "$ref": "#/$defs/commitSha" },\n "excludedIds": {\n "description": "Check runs left out of the count: the run the checker itself executes in.",\n "type": "array",\n "items": { "type": "integer", "minimum": 1 }\n },\n "runs": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["id", "name", "status", "conclusion"],\n "additionalProperties": false,\n "dependentRequired": {\n "workflowPath": ["checkSuiteId", "workflowRunId", "runAttempt"],\n "workflowName": ["workflowPath"],\n "workflowRunId": ["workflowPath"],\n "runAttempt": ["workflowPath"]\n },\n "properties": {\n "id": { "type": "integer", "minimum": 1 },\n "name": { "type": "string" },\n "status": { "type": "string", "minLength": 1 },\n "conclusion": { "type": ["string", "null"] },\n "checkSuiteId": {\n "description": "The run\'s check suite (`check_suite.id`). A run without `workflowPath` is counted as the latest per check suite and name; a run without either is keyed by its name alone. Added in spec 0.1.4, amended 2026-10-12.",\n "type": "integer",\n "minimum": 1\n },\n "appSlug": {\n "description": "The slug of the app that created the run (`app.slug`). Recorded, not read by any check. Added in spec 0.1.4, amended 2026-10-12.",\n "type": "string",\n "minLength": 1\n },\n "workflowPath": {\n "description": "The `path` of the workflow run whose `check_suite_id` is the run\'s check suite, less any `@<ref>`, from the workflow runs at the head. Runs that have it are counted as the latest per workflow path and name. Added in spec 0.1.4, amended 2026-10-13.",\n "type": "string",\n "minLength": 1\n },\n "workflowName": {\n "description": "The `name` of that workflow run, as the listing gives it; absent where the listing gives none, null or the empty string. No 0.1 check reads it. Added in spec 0.1.5.",\n "type": "string",\n "minLength": 1\n },\n "workflowRunId": {\n "description": "The `id` of that workflow run. Added in spec 0.1.4, amended 2026-10-13.",\n "type": "integer",\n "minimum": 1\n },\n "runAttempt": {\n "description": "That workflow run\'s `run_attempt` as the listing gives it, its latest attempt at the read. Added in spec 0.1.4, amended 2026-10-13.",\n "type": "integer",\n "minimum": 1\n }\n }\n }\n }\n }\n },\n { "$ref": "#/$defs/unreadOnly" }\n ]\n },\n "testRecords": {\n "type": "array",\n "items": {\n "oneOf": [\n {\n "type": "object",\n "required": ["record", "status", "runs"],\n "additionalProperties": false,\n "properties": {\n "record": { "$ref": "handback-block-0.1.schema.json#/$defs/junitRecord" },\n "status": { "const": "ok" },\n "runs": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["runId", "runAttempt", "executed", "failed"],\n "additionalProperties": false,\n "properties": {\n "runId": { "type": "integer", "minimum": 1 },\n "runAttempt": { "type": "integer", "minimum": 1 },\n "executed": { "type": "integer", "minimum": 0 },\n "failed": { "type": "integer", "minimum": 0 }\n }\n }\n }\n }\n },\n {\n "type": "object",\n "required": ["record"],\n "properties": {\n "record": { "$ref": "handback-block-0.1.schema.json#/$defs/junitRecord" }\n },\n "allOf": [{ "$ref": "#/$defs/unread" }]\n }\n ]\n }\n },\n "deployments": {\n "type": "array",\n "items": {\n "oneOf": [\n {\n "type": "object",\n "required": ["environment", "status", "deployments"],\n "additionalProperties": false,\n "properties": {\n "environment": { "type": "string", "minLength": 1 },\n "status": { "const": "ok" },\n "deployments": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["id", "sha", "relation", "successAt"],\n "additionalProperties": false,\n "properties": {\n "id": { "type": "integer", "minimum": 1 },\n "sha": { "$ref": "#/$defs/commitSha" },\n "relation": {\n "enum": ["head", "merge", "descendant", "unrelated", "unknown"]\n },\n "successAt": {\n "oneOf": [{ "$ref": "#/$defs/timestamp" }, { "type": "null" }]\n }\n }\n }\n }\n }\n },\n {\n "type": "object",\n "required": ["environment"],\n "properties": { "environment": { "type": "string", "minLength": 1 } },\n "allOf": [{ "$ref": "#/$defs/unread" }]\n }\n ]\n }\n },\n "sources": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["kind", "locator", "sha256", "readAt"],\n "additionalProperties": false,\n "properties": {\n "kind": { "$ref": "#/$defs/sourceKind" },\n "locator": { "type": "string", "minLength": 1, "maxLength": 2000 },\n "sha256": { "$ref": "#/$defs/sha256" },\n "etag": { "type": "string", "minLength": 1 },\n "readAt": { "$ref": "#/$defs/timestamp" },\n "error": {\n "description": "Present when the read failed: http_<status>, network, or parse.",\n "type": "string",\n "pattern": "^(?:http_[1-5][0-9]{2}|network|parse)$"\n }\n }\n }\n }\n }\n },\n "unreadOnly": {\n "allOf": [{ "$ref": "#/$defs/unread" }],\n "unevaluatedProperties": false\n }\n }\n}\n', "record-0.2-draft.schema.json": `{
10096
+ `, "record-0.1.schema.json": '{\n "$schema": "https://json-schema.org/draft/2020-12/schema",\n "$id": "https://barglabs.ai/dunstan/spec/schema/record-0.1.schema.json",\n "title": "Dunstan record, v0.1",\n "description": "An in-toto Statement v1 whose predicate records one check of a handback block against the record. See spec/claim-format.md.",\n "type": "object",\n "required": ["_type", "subject", "predicateType", "predicate"],\n "additionalProperties": false,\n "properties": {\n "_type": { "const": "https://in-toto.io/Statement/v1" },\n "subject": {\n "type": "array",\n "minItems": 1,\n "maxItems": 2,\n "prefixItems": [{ "$ref": "#/$defs/commitSubject" }, { "$ref": "#/$defs/blockSubject" }],\n "items": false\n },\n "predicateType": { "const": "https://barglabs.ai/dunstan/record/v0.1" },\n "predicate": { "$ref": "#/$defs/predicate" }\n },\n "allOf": [\n {\n "if": {\n "properties": {\n "predicate": {\n "properties": { "block": { "properties": { "status": { "const": "found" } } } }\n }\n }\n },\n "then": { "properties": { "subject": { "minItems": 2 } } },\n "else": { "properties": { "subject": { "maxItems": 1 } } }\n }\n ],\n "$defs": {\n "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },\n "commitSha": { "type": "string", "pattern": "^[0-9a-f]{40}$" },\n "repository": {\n "description": "owner/repo",\n "type": "string",\n "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/[A-Za-z0-9._-]{1,100}$"\n },\n "issueRef": {\n "description": "owner/repo#N, always fully qualified in evidence.",\n "type": "string",\n "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/[A-Za-z0-9._-]{1,100}#[1-9][0-9]{0,9}$"\n },\n "timestamp": {\n "type": "string",\n "format": "date-time",\n "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\\\.[0-9]+)?Z$"\n },\n "sourceKind": {\n "enum": [\n "pull_request",\n "pull_request_files",\n "pull_request_commits",\n "closing_references",\n "repository",\n "issue",\n "commit",\n "compare",\n "check_runs",\n "workflow_runs",\n "artifact",\n "deployments"\n ]\n },\n "commitSubject": {\n "type": "object",\n "required": ["name", "digest"],\n "additionalProperties": false,\n "properties": {\n "name": {\n "type": "string",\n "pattern": "^git\\\\+https://github\\\\.com/[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/[A-Za-z0-9._-]{1,100}@[0-9a-f]{40}$"\n },\n "digest": {\n "type": "object",\n "required": ["gitCommit"],\n "additionalProperties": false,\n "properties": { "gitCommit": { "$ref": "#/$defs/commitSha" } }\n }\n }\n },\n "blockSubject": {\n "type": "object",\n "required": ["name", "digest"],\n "additionalProperties": false,\n "properties": {\n "name": { "const": "handback-block" },\n "digest": {\n "type": "object",\n "required": ["sha256"],\n "additionalProperties": false,\n "properties": { "sha256": { "$ref": "#/$defs/sha256" } }\n }\n }\n },\n "predicate": {\n "type": "object",\n "required": [\n "spec",\n "checker",\n "report",\n "block",\n "subject",\n "evidence",\n "claims",\n "verdict",\n "digests",\n "rerun",\n "assurance"\n ],\n "additionalProperties": false,\n "properties": {\n "spec": {\n "description": "The spec version the checker implements, semver.",\n "type": "string",\n "pattern": "^0\\\\.1\\\\.(?:0|[1-9][0-9]*)$"\n },\n "checker": {\n "type": "object",\n "required": ["name", "version", "digest"],\n "additionalProperties": false,\n "properties": {\n "name": { "type": "string", "minLength": 1, "maxLength": 214 },\n "version": { "type": "string", "minLength": 1, "maxLength": 100 },\n "digest": {\n "type": "object",\n "required": ["sha256"],\n "additionalProperties": false,\n "properties": { "sha256": { "$ref": "#/$defs/sha256" } }\n }\n }\n },\n "report": {\n "description": "The report\'s digest and where it was read. Never its text.",\n "type": "object",\n "required": ["sha256", "source"],\n "additionalProperties": false,\n "properties": {\n "sha256": { "$ref": "#/$defs/sha256" },\n "source": {\n "type": "object",\n "required": ["kind", "locator"],\n "additionalProperties": false,\n "properties": {\n "kind": { "enum": ["pr-body", "pr-comment", "file", "stdin", "api"] },\n "locator": { "type": "string", "minLength": 1, "maxLength": 2000 }\n }\n }\n }\n },\n "block": { "$ref": "#/$defs/block" },\n "subject": {\n "type": "object",\n "required": ["repository", "pullRequest", "headSha", "mergeSha"],\n "additionalProperties": false,\n "properties": {\n "repository": { "$ref": "#/$defs/repository" },\n "pullRequest": { "type": "integer", "minimum": 1 },\n "headSha": { "$ref": "#/$defs/commitSha" },\n "mergeSha": {\n "oneOf": [{ "$ref": "#/$defs/commitSha" }, { "type": "null" }]\n }\n }\n },\n "evidence": { "$ref": "#/$defs/evidence" },\n "claims": {\n "type": "array",\n "items": { "$ref": "#/$defs/claim" }\n },\n "verdict": { "$ref": "#/$defs/verdict" },\n "digests": {\n "type": "object",\n "required": ["claims", "evidence"],\n "additionalProperties": false,\n "properties": {\n "claims": { "$ref": "#/$defs/sha256" },\n "evidence": { "$ref": "#/$defs/sha256" }\n }\n },\n "rerun": {\n "type": "object",\n "required": ["offline", "online"],\n "additionalProperties": false,\n "properties": {\n "offline": { "type": "string", "minLength": 1, "maxLength": 2000 },\n "online": { "type": "string", "minLength": 1, "maxLength": 2000 }\n }\n },\n "assurance": {\n "oneOf": [\n {\n "type": "object",\n "required": ["status", "issuer"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "unsigned" },\n "issuer": { "const": "self-generated" }\n }\n },\n {\n "type": "object",\n "required": ["status", "issuer", "keyFingerprint"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "signed" },\n "issuer": { "type": "string", "minLength": 1, "maxLength": 320 },\n "keyFingerprint": { "type": "string", "pattern": "^SHA256:[A-Za-z0-9+/]{43}$" }\n }\n }\n ]\n }\n }\n },\n "verdict": { "enum": ["pass", "fail", "unverifiable"] },\n "blockError": {\n "type": "object",\n "required": ["code"],\n "additionalProperties": false,\n "properties": {\n "code": {\n "enum": [\n "unterminated_fence",\n "invalid_json",\n "duplicate_member",\n "unsupported_version",\n "schema_violation"\n ]\n },\n "pointer": { "type": "string", "pattern": "^(?:/.*)?$" },\n "keyword": { "type": "string", "minLength": 1 }\n }\n },\n "block": {\n "oneOf": [\n {\n "type": "object",\n "required": ["status", "sha256", "value"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "found" },\n "sha256": { "$ref": "#/$defs/sha256" },\n "value": { "$ref": "handback-block-0.1.schema.json" }\n }\n },\n {\n "type": "object",\n "required": ["status", "reason", "sha256", "value"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "missing" },\n "reason": { "const": "block_missing" },\n "sha256": { "type": "null" },\n "value": { "type": "null" }\n }\n },\n {\n "type": "object",\n "required": ["status", "reason", "count", "sha256", "value"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "ambiguous" },\n "reason": { "const": "block_ambiguous" },\n "count": { "type": "integer", "minimum": 2 },\n "sha256": { "type": "null" },\n "value": { "type": "null" }\n }\n },\n {\n "type": "object",\n "required": ["status", "reason", "errors", "sha256", "value"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "invalid" },\n "reason": { "const": "block_invalid" },\n "errors": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/blockError" } },\n "sha256": { "type": "null" },\n "value": { "type": "null" }\n }\n }\n ]\n },\n "claim": {\n "type": "object",\n "required": ["id", "check", "field", "declared", "observed", "verdict"],\n "additionalProperties": false,\n "properties": {\n "id": { "type": "string", "minLength": 1 },\n "check": { "enum": ["head", "scope", "reference", "count", "time"] },\n "field": {\n "description": "A JSON Pointer (RFC 6901) into the block.",\n "type": "string",\n "pattern": "^/.+$"\n },\n "declared": true,\n "observed": true,\n "verdict": { "$ref": "#/$defs/verdict" },\n "reason": { "$ref": "#/$defs/claimReason" }\n },\n "if": { "properties": { "verdict": { "const": "pass" } } },\n "then": { "not": { "required": ["reason"] } },\n "else": { "required": ["reason"] }\n },\n "claimReason": {\n "type": "string",\n "pattern": "^(?:head_mismatch|declared_not_changed|undeclared_file|file_list_truncated|not_closing|closing_link_unsettled|closing_base_not_default|closing_event_unsettled|not_found|not_reachable|count_mismatch|all_succeeded_mismatch|none_failed_mismatch|checks_incomplete|no_check_runs|no_succeeded_check_runs|check_runs_not_at_head|no_comparable_record_field|local_run_not_in_record|record_producer_changed_by_pr|record_not_found|record_ambiguous|not_merged|premature|no_deployment|evidence_field_unpopulated:[A-Za-z0-9_.]+|source_unreadable:(?:pull_request|pull_request_files|pull_request_commits|closing_references|repository|issue|commit|compare|check_runs|workflow_runs|artifact|deployments))$"\n },\n "unread": {\n "description": "A section the checker could not use: its source errored, or the source answered without the field.",\n "oneOf": [\n {\n "type": "object",\n "required": ["status", "source"],\n "properties": {\n "status": { "const": "unreadable" },\n "source": { "$ref": "#/$defs/sourceKind" }\n }\n },\n {\n "type": "object",\n "required": ["status", "field"],\n "properties": {\n "status": { "const": "unpopulated" },\n "field": { "type": "string", "pattern": "^[A-Za-z0-9_.]+$" }\n }\n }\n ]\n },\n "evidence": {\n "type": "object",\n "required": ["pullRequest", "sources"],\n "additionalProperties": false,\n "properties": {\n "pullRequest": {\n "description": "Always read: a checker that cannot read the pull request writes no record.",\n "type": "object",\n "required": [\n "status",\n "number",\n "state",\n "merged",\n "mergedAt",\n "headSha",\n "mergeSha",\n "changedFiles"\n ],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "ok" },\n "number": { "type": "integer", "minimum": 1 },\n "state": { "enum": ["open", "closed"] },\n "merged": { "type": "boolean" },\n "mergedAt": { "oneOf": [{ "$ref": "#/$defs/timestamp" }, { "type": "null" }] },\n "headSha": { "$ref": "#/$defs/commitSha" },\n "mergeSha": { "oneOf": [{ "$ref": "#/$defs/commitSha" }, { "type": "null" }] },\n "changedFiles": { "type": "integer", "minimum": 0 }\n }\n },\n "files": {\n "oneOf": [\n {\n "type": "object",\n "required": ["status", "complete", "entries"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "ok" },\n "complete": { "type": "boolean" },\n "entries": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["path", "status"],\n "additionalProperties": false,\n "properties": {\n "path": { "type": "string", "minLength": 1 },\n "status": {\n "enum": [\n "added",\n "removed",\n "modified",\n "renamed",\n "copied",\n "changed",\n "unchanged"\n ]\n },\n "previousPath": { "type": "string", "minLength": 1 }\n }\n }\n }\n }\n },\n { "$ref": "#/$defs/unreadOnly" }\n ]\n },\n "closingReferences": {\n "oneOf": [\n {\n "type": "object",\n "required": ["status", "issues"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "ok" },\n "issues": { "type": "array", "items": { "$ref": "#/$defs/issueRef" } }\n }\n },\n { "$ref": "#/$defs/unreadOnly" }\n ]\n },\n "references": {\n "type": "array",\n "items": {\n "oneOf": [\n {\n "type": "object",\n "required": ["kind", "ref", "status", "exists"],\n "additionalProperties": false,\n "properties": {\n "kind": { "const": "issue" },\n "ref": { "$ref": "#/$defs/issueRef" },\n "status": { "const": "ok" },\n "exists": { "type": "boolean" },\n "resolvedAs": {\n "description": "Spec 0.1.3: the owner/repo#N GitHub answered the read of ref with, when it is another issue (an issue transferred to another repository).",\n "$ref": "#/$defs/issueRef"\n }\n },\n "if": { "required": ["resolvedAs"] },\n "then": { "properties": { "exists": { "const": true } } }\n },\n {\n "type": "object",\n "required": ["kind", "ref", "status", "exists", "reachableFromHead"],\n "additionalProperties": false,\n "properties": {\n "kind": { "const": "commit" },\n "ref": { "$ref": "#/$defs/commitSha" },\n "status": { "const": "ok" },\n "exists": { "type": "boolean" },\n "reachableFromHead": { "type": "boolean" }\n }\n },\n {\n "type": "object",\n "required": ["kind", "ref"],\n "properties": {\n "kind": { "enum": ["issue", "commit"] },\n "ref": { "type": "string", "minLength": 1 }\n },\n "allOf": [{ "$ref": "#/$defs/unread" }]\n }\n ]\n }\n },\n "closeEvents": {\n "description": "Spec 0.1.4: who closed each closes target that exists and is not among the closing references, read under the identity it resolves to; and the pull request\'s base, its repository\'s default branch and its commit ids.",\n "type": "object",\n "required": ["pullRequest", "targets"],\n "additionalProperties": false,\n "properties": {\n "pullRequest": {\n "oneOf": [\n {\n "type": "object",\n "required": ["status", "baseRef", "defaultBranch", "commits"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "ok" },\n "baseRef": { "type": "string", "minLength": 1 },\n "defaultBranch": {\n "oneOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }]\n },\n "commits": {\n "type": "array",\n "uniqueItems": true,\n "items": { "$ref": "#/$defs/commitSha" }\n }\n }\n },\n { "$ref": "#/$defs/unreadOnly" }\n ]\n },\n "targets": {\n "type": "array",\n "items": {\n "oneOf": [\n {\n "type": "object",\n "required": ["ref", "status", "type", "state", "events"],\n "additionalProperties": false,\n "properties": {\n "ref": { "$ref": "#/$defs/issueRef" },\n "status": { "const": "ok" },\n "type": { "enum": ["issue", "pull_request"] },\n "state": { "enum": ["open", "closed", "merged"] },\n "events": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["at", "closer"],\n "additionalProperties": false,\n "properties": {\n "at": { "$ref": "#/$defs/timestamp" },\n "closer": {\n "oneOf": [\n { "type": "null" },\n {\n "type": "object",\n "required": ["kind", "ref"],\n "additionalProperties": false,\n "properties": {\n "kind": { "const": "pull_request" },\n "ref": { "$ref": "#/$defs/issueRef" }\n }\n },\n {\n "type": "object",\n "required": ["kind", "oid"],\n "additionalProperties": false,\n "properties": {\n "kind": { "const": "commit" },\n "oid": { "$ref": "#/$defs/commitSha" }\n }\n },\n {\n "type": "object",\n "required": ["kind"],\n "additionalProperties": false,\n "properties": { "kind": { "const": "other" } }\n }\n ]\n }\n }\n }\n }\n },\n "if": { "properties": { "type": { "const": "issue" } } },\n "then": { "properties": { "state": { "enum": ["open", "closed"] } } }\n },\n {\n "type": "object",\n "required": ["ref"],\n "properties": { "ref": { "$ref": "#/$defs/issueRef" } },\n "allOf": [{ "$ref": "#/$defs/unread" }]\n }\n ]\n }\n }\n }\n },\n "checkRuns": {\n "oneOf": [\n {\n "type": "object",\n "required": ["status", "commit", "excludedIds", "runs"],\n "additionalProperties": false,\n "properties": {\n "status": { "const": "ok" },\n "commit": { "$ref": "#/$defs/commitSha" },\n "excludedIds": {\n "description": "Check runs left out of the count: the run the checker itself executes in.",\n "type": "array",\n "items": { "type": "integer", "minimum": 1 }\n },\n "runs": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["id", "name", "status", "conclusion"],\n "additionalProperties": false,\n "dependentRequired": {\n "workflowPath": ["checkSuiteId", "workflowRunId", "runAttempt"],\n "workflowName": ["workflowPath"],\n "workflowRunId": ["workflowPath"],\n "runAttempt": ["workflowPath"]\n },\n "properties": {\n "id": { "type": "integer", "minimum": 1 },\n "name": { "type": "string" },\n "status": { "type": "string", "minLength": 1 },\n "conclusion": { "type": ["string", "null"] },\n "checkSuiteId": {\n "description": "The run\'s check suite (`check_suite.id`). A run without `workflowPath` is counted as the latest per check suite and name; a run without either is keyed by its name alone. Added in spec 0.1.4, amended 2026-10-12.",\n "type": "integer",\n "minimum": 1\n },\n "appSlug": {\n "description": "The slug of the app that created the run (`app.slug`). Recorded, not read by any check. Added in spec 0.1.4, amended 2026-10-12.",\n "type": "string",\n "minLength": 1\n },\n "workflowPath": {\n "description": "The `path` of the workflow run whose `check_suite_id` is the run\'s check suite, less any `@<ref>`, from the workflow runs at the head. Runs that have it are counted as the latest per workflow path and name. Added in spec 0.1.4, amended 2026-10-13.",\n "type": "string",\n "minLength": 1\n },\n "workflowName": {\n "description": "The `name` of that workflow run, as the listing gives it; absent where the listing gives none, null or the empty string. No 0.1 check reads it. Added in spec 0.1.5.",\n "type": "string",\n "minLength": 1\n },\n "workflowRunId": {\n "description": "The `id` of that workflow run. Added in spec 0.1.4, amended 2026-10-13.",\n "type": "integer",\n "minimum": 1\n },\n "runAttempt": {\n "description": "That workflow run\'s `run_attempt` as the listing gives it, its latest attempt at the read. Added in spec 0.1.4, amended 2026-10-13.",\n "type": "integer",\n "minimum": 1\n }\n }\n }\n }\n }\n },\n { "$ref": "#/$defs/unreadOnly" }\n ]\n },\n "testRecords": {\n "type": "array",\n "items": {\n "oneOf": [\n {\n "type": "object",\n "required": ["record", "status", "runs"],\n "additionalProperties": false,\n "properties": {\n "record": { "$ref": "handback-block-0.1.schema.json#/$defs/junitRecord" },\n "status": { "const": "ok" },\n "runs": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["runId", "runAttempt", "executed", "failed"],\n "additionalProperties": false,\n "properties": {\n "runId": { "type": "integer", "minimum": 1 },\n "runAttempt": { "type": "integer", "minimum": 1 },\n "executed": { "type": "integer", "minimum": 0 },\n "failed": { "type": "integer", "minimum": 0 }\n }\n }\n }\n }\n },\n {\n "type": "object",\n "required": ["record"],\n "properties": {\n "record": { "$ref": "handback-block-0.1.schema.json#/$defs/junitRecord" }\n },\n "allOf": [{ "$ref": "#/$defs/unread" }]\n }\n ]\n }\n },\n "deployments": {\n "type": "array",\n "items": {\n "oneOf": [\n {\n "type": "object",\n "required": ["environment", "status", "deployments"],\n "additionalProperties": false,\n "properties": {\n "environment": { "type": "string", "minLength": 1 },\n "status": { "const": "ok" },\n "deployments": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["id", "sha", "relation", "successAt"],\n "additionalProperties": false,\n "properties": {\n "id": { "type": "integer", "minimum": 1 },\n "sha": { "$ref": "#/$defs/commitSha" },\n "relation": {\n "enum": ["head", "merge", "descendant", "unrelated", "unknown"]\n },\n "successAt": {\n "oneOf": [{ "$ref": "#/$defs/timestamp" }, { "type": "null" }]\n }\n }\n }\n }\n }\n },\n {\n "type": "object",\n "required": ["environment"],\n "properties": { "environment": { "type": "string", "minLength": 1 } },\n "allOf": [{ "$ref": "#/$defs/unread" }]\n }\n ]\n }\n },\n "sources": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["kind", "locator", "sha256", "readAt"],\n "additionalProperties": false,\n "properties": {\n "kind": { "$ref": "#/$defs/sourceKind" },\n "locator": { "type": "string", "minLength": 1, "maxLength": 2000 },\n "sha256": { "$ref": "#/$defs/sha256" },\n "etag": { "type": "string", "minLength": 1 },\n "readAt": { "$ref": "#/$defs/timestamp" },\n "error": {\n "description": "Present when the read failed: http_<status>, network, or parse.",\n "type": "string",\n "pattern": "^(?:http_[1-5][0-9]{2}|network|parse)$"\n }\n }\n }\n }\n }\n },\n "unreadOnly": {\n "allOf": [{ "$ref": "#/$defs/unread" }],\n "unevaluatedProperties": false\n }\n }\n}\n', "record-0.2-draft.schema.json": `{
10081
10097
  "$schema": "https://json-schema.org/draft/2020-12/schema",
10082
10098
  "$id": "https://barglabs.ai/dunstan/spec/schema/record-0.2-draft.schema.json",
10083
10099
  "title": "Dunstan record, DRAFT 0.2.0",
@@ -10408,7 +10424,7 @@ var FILES = { "handback-block-0.1.schema.json": `{
10408
10424
  "additionalProperties": false,
10409
10425
  "properties": {
10410
10426
  "comparison": {
10411
- "description": "The version of the rules that turn a proposed claim and the evidence into its note, separate from the extractor's. 0.1.0 compared a file claim by exact path only; 0.2.0 also matches it by name; 0.3.0 also asks whether a path not among the changed files exists at the head (D.11.4); 0.4.0 compares a closes claim by who closed the target (spec 0.1.4).",
10427
+ "description": "The version of the rules that turn a proposed claim and the evidence into its note, separate from the extractor's. 0.1.0 compared a file claim by exact path only; 0.2.0 also matches it by name; 0.3.0 also asks whether a path not among the changed files exists at the head (D.11.4); 0.4.0 compares a closes claim by who closed the target (spec 0.1.4); 0.5.0 compares a cited commit by whether it exists only (spec 0.1.11).",
10412
10428
  "type": "object",
10413
10429
  "required": ["version"],
10414
10430
  "additionalProperties": false,
@@ -10567,6 +10583,253 @@ var FILES = { "handback-block-0.1.schema.json": `{
10567
10583
  }
10568
10584
  }
10569
10585
  }
10586
+ `, "record-gitlab-0.1-draft.schema.json": `{
10587
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
10588
+ "$id": "https://barglabs.ai/dunstan/spec/schema/record-gitlab-0.1-draft.schema.json",
10589
+ "title": "Dunstan record, DRAFT GitLab profile 0.1",
10590
+ "description": "DRAFT, not normative, for the operator to check (spec/claim-format.md, \\"DRAFT GitLab profile 0.1\\"). A 0.1 record of a GitLab merge request: the predicate's subject carries forge {kind: gitlab, host}, and the head commit's subject entry names that host. A project and an issue reference are named by the project's full path, group/subgroup/.../project, which admits every path the 0.1 repository pattern admits; the evidence sections that hold issue references are restated here with that pattern. Every other member is a reference into record-0.1.schema.json, which this file does not change.",
10591
+ "type": "object",
10592
+ "required": ["_type", "subject", "predicateType", "predicate"],
10593
+ "additionalProperties": false,
10594
+ "properties": {
10595
+ "_type": { "$ref": "record-0.1.schema.json#/properties/_type" },
10596
+ "subject": {
10597
+ "type": "array",
10598
+ "minItems": 1,
10599
+ "maxItems": 2,
10600
+ "prefixItems": [
10601
+ { "$ref": "#/$defs/commitSubject" },
10602
+ { "$ref": "record-0.1.schema.json#/$defs/blockSubject" }
10603
+ ],
10604
+ "items": false
10605
+ },
10606
+ "predicateType": { "const": "https://barglabs.ai/dunstan/record/v0.1-gitlab-draft" },
10607
+ "predicate": { "$ref": "#/$defs/predicate" }
10608
+ },
10609
+ "allOf": [{ "$ref": "record-0.1.schema.json#/allOf/0" }],
10610
+ "$defs": {
10611
+ "repository": {
10612
+ "description": "A GitLab project's full path: up to 20 levels of groups, then the project. Every path the 0.1 repository pattern admits is one.",
10613
+ "type": "string",
10614
+ "maxLength": 5375,
10615
+ "pattern": "^[A-Za-z0-9_][A-Za-z0-9._-]{0,254}(?:/[A-Za-z0-9_][A-Za-z0-9._-]{0,254}){0,19}/[A-Za-z0-9._-]{1,255}$"
10616
+ },
10617
+ "issueRef": {
10618
+ "description": "An issue of a GitLab project, <full path>#<iid>.",
10619
+ "type": "string",
10620
+ "pattern": "^[A-Za-z0-9_][A-Za-z0-9._-]{0,254}(?:/[A-Za-z0-9_][A-Za-z0-9._-]{0,254}){0,19}/[A-Za-z0-9._-]{1,255}#[1-9][0-9]{0,9}$"
10621
+ },
10622
+ "evidence": {
10623
+ "description": "0.1's evidence, with the sections that hold issue references (closingReferences, references, closeEvents) restated to name an issue by #/$defs/issueRef. Every other section is 0.1's.",
10624
+ "type": "object",
10625
+ "required": ["pullRequest", "sources"],
10626
+ "additionalProperties": false,
10627
+ "properties": {
10628
+ "pullRequest": {
10629
+ "$ref": "record-0.1.schema.json#/$defs/evidence/properties/pullRequest"
10630
+ },
10631
+ "files": { "$ref": "record-0.1.schema.json#/$defs/evidence/properties/files" },
10632
+ "closingReferences": {
10633
+ "oneOf": [
10634
+ {
10635
+ "type": "object",
10636
+ "required": ["status", "issues"],
10637
+ "additionalProperties": false,
10638
+ "properties": {
10639
+ "status": { "const": "ok" },
10640
+ "issues": { "type": "array", "items": { "$ref": "#/$defs/issueRef" } }
10641
+ }
10642
+ },
10643
+ { "$ref": "record-0.1.schema.json#/$defs/unreadOnly" }
10644
+ ]
10645
+ },
10646
+ "references": {
10647
+ "type": "array",
10648
+ "items": {
10649
+ "oneOf": [
10650
+ {
10651
+ "type": "object",
10652
+ "required": ["kind", "ref", "status", "exists"],
10653
+ "additionalProperties": false,
10654
+ "properties": {
10655
+ "kind": { "const": "issue" },
10656
+ "ref": { "$ref": "#/$defs/issueRef" },
10657
+ "status": { "const": "ok" },
10658
+ "exists": { "type": "boolean" },
10659
+ "resolvedAs": { "$ref": "#/$defs/issueRef" }
10660
+ },
10661
+ "if": { "required": ["resolvedAs"] },
10662
+ "then": { "properties": { "exists": { "const": true } } }
10663
+ },
10664
+ {
10665
+ "$ref": "record-0.1.schema.json#/$defs/evidence/properties/references/items/oneOf/1"
10666
+ },
10667
+ {
10668
+ "$ref": "record-0.1.schema.json#/$defs/evidence/properties/references/items/oneOf/2"
10669
+ }
10670
+ ]
10671
+ }
10672
+ },
10673
+ "closeEvents": {
10674
+ "type": "object",
10675
+ "required": ["pullRequest", "targets"],
10676
+ "additionalProperties": false,
10677
+ "properties": {
10678
+ "pullRequest": {
10679
+ "$ref": "record-0.1.schema.json#/$defs/evidence/properties/closeEvents/properties/pullRequest"
10680
+ },
10681
+ "targets": {
10682
+ "type": "array",
10683
+ "items": {
10684
+ "oneOf": [
10685
+ {
10686
+ "type": "object",
10687
+ "required": ["ref", "status", "type", "state", "events"],
10688
+ "additionalProperties": false,
10689
+ "properties": {
10690
+ "ref": { "$ref": "#/$defs/issueRef" },
10691
+ "status": { "const": "ok" },
10692
+ "type": { "enum": ["issue", "pull_request"] },
10693
+ "state": { "enum": ["open", "closed", "merged"] },
10694
+ "events": {
10695
+ "type": "array",
10696
+ "items": {
10697
+ "type": "object",
10698
+ "required": ["at", "closer"],
10699
+ "additionalProperties": false,
10700
+ "properties": {
10701
+ "at": { "$ref": "record-0.1.schema.json#/$defs/timestamp" },
10702
+ "closer": {
10703
+ "oneOf": [
10704
+ { "type": "null" },
10705
+ {
10706
+ "type": "object",
10707
+ "required": ["kind", "ref"],
10708
+ "additionalProperties": false,
10709
+ "properties": {
10710
+ "kind": { "const": "pull_request" },
10711
+ "ref": { "$ref": "#/$defs/issueRef" }
10712
+ }
10713
+ },
10714
+ {
10715
+ "$ref": "record-0.1.schema.json#/$defs/evidence/properties/closeEvents/properties/targets/items/oneOf/0/properties/events/items/properties/closer/oneOf/2"
10716
+ },
10717
+ {
10718
+ "$ref": "record-0.1.schema.json#/$defs/evidence/properties/closeEvents/properties/targets/items/oneOf/0/properties/events/items/properties/closer/oneOf/3"
10719
+ }
10720
+ ]
10721
+ }
10722
+ }
10723
+ }
10724
+ }
10725
+ },
10726
+ "if": { "properties": { "type": { "const": "issue" } } },
10727
+ "then": { "properties": { "state": { "enum": ["open", "closed"] } } }
10728
+ },
10729
+ {
10730
+ "type": "object",
10731
+ "required": ["ref"],
10732
+ "properties": { "ref": { "$ref": "#/$defs/issueRef" } },
10733
+ "allOf": [{ "$ref": "record-0.1.schema.json#/$defs/unread" }]
10734
+ }
10735
+ ]
10736
+ }
10737
+ }
10738
+ }
10739
+ },
10740
+ "checkRuns": { "$ref": "record-0.1.schema.json#/$defs/evidence/properties/checkRuns" },
10741
+ "testRecords": {
10742
+ "$ref": "record-0.1.schema.json#/$defs/evidence/properties/testRecords"
10743
+ },
10744
+ "deployments": {
10745
+ "$ref": "record-0.1.schema.json#/$defs/evidence/properties/deployments"
10746
+ },
10747
+ "sources": { "$ref": "record-0.1.schema.json#/$defs/evidence/properties/sources" }
10748
+ }
10749
+ },
10750
+ "host": {
10751
+ "description": "The GitLab instance's lowercase DNS name: gitlab.com, or a self-managed host.",
10752
+ "type": "string",
10753
+ "maxLength": 253,
10754
+ "pattern": "^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$"
10755
+ },
10756
+ "commitSubject": {
10757
+ "type": "object",
10758
+ "required": ["name", "digest"],
10759
+ "additionalProperties": false,
10760
+ "properties": {
10761
+ "name": {
10762
+ "type": "string",
10763
+ "pattern": "^git\\\\+https://[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*/[A-Za-z0-9_][A-Za-z0-9._-]{0,254}(?:/[A-Za-z0-9_][A-Za-z0-9._-]{0,254}){0,19}/[A-Za-z0-9._-]{1,255}@[0-9a-f]{40}$"
10764
+ },
10765
+ "digest": { "$ref": "record-0.1.schema.json#/$defs/commitSubject/properties/digest" }
10766
+ }
10767
+ },
10768
+ "predicate": {
10769
+ "type": "object",
10770
+ "required": [
10771
+ "spec",
10772
+ "checker",
10773
+ "report",
10774
+ "block",
10775
+ "subject",
10776
+ "evidence",
10777
+ "claims",
10778
+ "verdict",
10779
+ "digests",
10780
+ "rerun",
10781
+ "assurance"
10782
+ ],
10783
+ "additionalProperties": false,
10784
+ "properties": {
10785
+ "spec": {
10786
+ "description": "The 0.1 version the checker implements, with the profile: 0.1.11-gitlab-0.1-draft.",
10787
+ "type": "string",
10788
+ "pattern": "^0\\\\.1\\\\.(?:0|[1-9][0-9]*)-gitlab-0\\\\.1-draft$"
10789
+ },
10790
+ "checker": { "$ref": "record-0.1.schema.json#/$defs/predicate/properties/checker" },
10791
+ "report": { "$ref": "record-0.1.schema.json#/$defs/predicate/properties/report" },
10792
+ "block": { "$ref": "record-0.1.schema.json#/$defs/block" },
10793
+ "subject": {
10794
+ "type": "object",
10795
+ "required": ["repository", "pullRequest", "headSha", "mergeSha", "forge"],
10796
+ "additionalProperties": false,
10797
+ "properties": {
10798
+ "repository": {
10799
+ "description": "The project's full path, group/subgroup/.../project, as the merge request read names it.",
10800
+ "$ref": "#/$defs/repository"
10801
+ },
10802
+ "pullRequest": {
10803
+ "description": "The merge request's iid.",
10804
+ "type": "integer",
10805
+ "minimum": 1
10806
+ },
10807
+ "headSha": { "$ref": "record-0.1.schema.json#/$defs/commitSha" },
10808
+ "mergeSha": {
10809
+ "oneOf": [{ "$ref": "record-0.1.schema.json#/$defs/commitSha" }, { "type": "null" }]
10810
+ },
10811
+ "forge": {
10812
+ "description": "The forge the evidence was read from. A record without it is a GitHub record.",
10813
+ "type": "object",
10814
+ "required": ["kind", "host"],
10815
+ "additionalProperties": false,
10816
+ "properties": {
10817
+ "kind": { "const": "gitlab" },
10818
+ "host": { "$ref": "#/$defs/host" }
10819
+ }
10820
+ }
10821
+ }
10822
+ },
10823
+ "evidence": { "$ref": "#/$defs/evidence" },
10824
+ "claims": { "$ref": "record-0.1.schema.json#/$defs/predicate/properties/claims" },
10825
+ "verdict": { "$ref": "record-0.1.schema.json#/$defs/verdict" },
10826
+ "digests": { "$ref": "record-0.1.schema.json#/$defs/predicate/properties/digests" },
10827
+ "rerun": { "$ref": "record-0.1.schema.json#/$defs/predicate/properties/rerun" },
10828
+ "assurance": { "$ref": "record-0.1.schema.json#/$defs/predicate/properties/assurance" }
10829
+ }
10830
+ }
10831
+ }
10832
+ }
10570
10833
  `, "survey-0.1-draft.schema.json": '{\n "$schema": "https://json-schema.org/draft/2020-12/schema",\n "$id": "https://barglabs.ai/dunstan/spec/schema/survey-0.1-draft.schema.json",\n "title": "Dunstan survey record, DRAFT 0.1",\n "description": "DRAFT, informative, for the operator to check (spec/claim-format.md, \\"DRAFT survey 0.1\\"). A read-only survey of a repository\'s recent pull requests: the selection, one entry per selected pull request with the record a check wrote for it or the reason there is none, and an aggregate that is a pure function of the entries. Each embedded record is a 0.1 or DRAFT 0.2.0 record and is validated against its own schema when the survey is verified. It changes neither record schema.",\n "type": "object",\n "required": ["format", "checker", "selection", "entries", "aggregate", "rerun", "digest"],\n "additionalProperties": false,\n "properties": {\n "format": { "const": "dunstan-survey-0.1-draft" },\n "checker": { "$ref": "record-0.1.schema.json#/$defs/predicate/properties/checker" },\n "selection": {\n "type": "object",\n "required": ["parameters", "reader", "listing", "selected"],\n "additionalProperties": false,\n "properties": {\n "parameters": { "$ref": "#/$defs/parameters" },\n "reader": {\n "description": "Whether the survey read GitHub with a token (GITHUB_TOKEN was set). The aggregate\'s remedy lines are worded for it. The token is never recorded.",\n "type": "object",\n "required": ["authenticated"],\n "additionalProperties": false,\n "properties": { "authenticated": { "type": "boolean" } }\n },\n "listing": { "$ref": "#/$defs/listing" },\n "selected": {\n "type": "array",\n "uniqueItems": true,\n "items": { "$ref": "#/$defs/number" }\n }\n }\n },\n "entries": {\n "description": "One per selected pull request, in selection order. Verify checks that their pull request numbers are exactly `selection.selected`, which holds no number twice.",\n "type": "array",\n "uniqueItems": true,\n "items": { "$ref": "#/$defs/entry" }\n },\n "aggregate": { "$ref": "#/$defs/aggregate" },\n "rerun": {\n "type": "object",\n "required": ["offline", "online"],\n "additionalProperties": false,\n "properties": {\n "offline": { "type": "string", "minLength": 1 },\n "online": { "type": "string", "minLength": 1 }\n }\n },\n "digest": {\n "description": "SHA-256 of the JCS bytes of the survey record without this member.",\n "type": "object",\n "required": ["sha256"],\n "additionalProperties": false,\n "properties": { "sha256": { "$ref": "record-0.1.schema.json#/$defs/sha256" } }\n }\n },\n "$defs": {\n "number": { "type": "integer", "minimum": 1 },\n "count": { "type": "integer", "minimum": 0 },\n "login": { "type": "string", "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})(?:\\\\[bot\\\\])?$" },\n "parameters": {\n "type": "object",\n "required": ["repository", "last", "since", "authors", "report", "advisory"],\n "additionalProperties": false,\n "properties": {\n "repository": { "$ref": "record-0.1.schema.json#/$defs/repository" },\n "last": { "type": "integer", "minimum": 1, "maximum": 200 },\n "since": {\n "oneOf": [\n { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" },\n { "type": "null" }\n ]\n },\n "authors": { "type": "array", "items": { "$ref": "#/$defs/login" } },\n "report": {\n "oneOf": [\n {\n "type": "object",\n "required": ["kind"],\n "additionalProperties": false,\n "properties": { "kind": { "const": "pr-body" } }\n },\n {\n "type": "object",\n "required": ["kind", "author"],\n "additionalProperties": false,\n "properties": {\n "kind": { "const": "pr-comment" },\n "author": { "$ref": "#/$defs/login" }\n }\n }\n ]\n },\n "advisory": { "type": "boolean" }\n }\n },\n "listingPage": {\n "type": "object",\n "required": ["page", "locator", "sha256", "readAt", "items", "duplicates"],\n "additionalProperties": false,\n "properties": {\n "page": { "$ref": "#/$defs/number" },\n "locator": { "type": "string", "minLength": 1 },\n "sha256": { "$ref": "record-0.1.schema.json#/$defs/sha256" },\n "readAt": { "$ref": "record-0.1.schema.json#/$defs/timestamp" },\n "items": { "$ref": "#/$defs/count" },\n "duplicates": {\n "description": "How many of this page\'s items repeat a pull request number an earlier item of the same read already held. In the read used, they are not in `pullRequests`.",\n "$ref": "#/$defs/count"\n },\n "error": { "type": "string", "pattern": "^(?:http_[1-5][0-9]{2}|network|parse)$" }\n }\n },\n "listing": {\n "description": "The read of the listing the selection is from, the last one, and the reads abandoned before it. A read is abandoned at its first page that repeats an earlier item, unless it is the third read, which is never abandoned.",\n "type": "object",\n "required": ["pageBound", "pages", "abandoned", "pullRequests", "duplicates", "end"],\n "additionalProperties": false,\n "properties": {\n "pageBound": { "type": "integer", "minimum": 1 },\n "pages": {\n "type": "array",\n "minItems": 1,\n "items": { "$ref": "#/$defs/listingPage" }\n },\n "abandoned": {\n "description": "One item per abandoned read, in order: its pages, the last of them the page that repeated an earlier item. No pull request list; abandoned reads contribute nothing to the selection. Empty when the first read was used.",\n "type": "array",\n "maxItems": 2,\n "items": {\n "type": "object",\n "required": ["pages"],\n "additionalProperties": false,\n "properties": {\n "pages": {\n "type": "array",\n "minItems": 1,\n "items": { "$ref": "#/$defs/listingPage" }\n }\n }\n }\n },\n "duplicates": {\n "description": "How many listed items repeated an earlier one: the sum of the pages\' duplicates. The pages\' items are the length of `pullRequests` plus this.",\n "$ref": "#/$defs/count"\n },\n "pullRequests": {\n "description": "Each listed pull request once, at its first occurrence, in listing order. Verify refuses a number listed twice.",\n "type": "array",\n "uniqueItems": true,\n "items": {\n "type": "object",\n "required": ["number", "createdAt", "author", "merged"],\n "additionalProperties": false,\n "properties": {\n "number": { "$ref": "#/$defs/number" },\n "createdAt": { "$ref": "record-0.1.schema.json#/$defs/timestamp" },\n "author": { "oneOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] },\n "merged": { "type": "boolean" }\n }\n }\n },\n "end": {\n "enum": [\n "selection_full",\n "since_reached",\n "listing_ended",\n "listing_unreadable",\n "page_bound"\n ]\n }\n }\n },\n "noRecordReason": {\n "enum": [\n "pull_request_unreadable",\n "pull_request_moved",\n "rate_limited",\n "report_unavailable"\n ]\n },\n "entry": {\n "oneOf": [\n {\n "type": "object",\n "required": ["pullRequest", "record"],\n "additionalProperties": false,\n "properties": {\n "pullRequest": { "$ref": "#/$defs/number" },\n "record": {\n "description": "The record `dunstan check` writes for this pull request, as written: a 0.1 record, or a DRAFT 0.2.0 record when the survey ran the advisory reader. Verify validates it against its own schema.",\n "type": "object"\n }\n }\n },\n {\n "type": "object",\n "required": ["pullRequest", "record", "reason"],\n "additionalProperties": false,\n "properties": {\n "pullRequest": { "$ref": "#/$defs/number" },\n "record": { "type": "null" },\n "reason": { "$ref": "#/$defs/noRecordReason" }\n }\n }\n ]\n },\n "columns": {\n "type": "object",\n "required": ["supported", "unverifiable", "notRecordCheckable", "contradicted"],\n "properties": {\n "supported": { "$ref": "#/$defs/count" },\n "unverifiable": { "$ref": "#/$defs/count" },\n "notRecordCheckable": { "$ref": "#/$defs/count" },\n "contradicted": { "$ref": "#/$defs/count" }\n }\n },\n "fixed": { "type": "string", "pattern": "^[01]\\\\.[0-9]{4}$" },\n "rate": {\n "type": "object",\n "required": ["numerator", "denominator", "value", "wilson95"],\n "additionalProperties": false,\n "properties": {\n "numerator": { "$ref": "#/$defs/count" },\n "denominator": { "$ref": "#/$defs/count" },\n "value": { "oneOf": [{ "$ref": "#/$defs/fixed" }, { "type": "null" }] },\n "wilson95": {\n "oneOf": [\n {\n "type": "object",\n "required": ["low", "high"],\n "additionalProperties": false,\n "properties": {\n "low": { "$ref": "#/$defs/fixed" },\n "high": { "$ref": "#/$defs/fixed" }\n }\n },\n { "type": "null" }\n ]\n }\n }\n },\n "aggregate": {\n "type": "object",\n "required": [\n "mapping",\n "pullRequests",\n "claims",\n "byType",\n "cannotSettle",\n "contradictedOfSettled",\n "unsettled"\n ],\n "additionalProperties": false,\n "properties": {\n "mapping": {\n "type": "object",\n "required": ["version"],\n "additionalProperties": false,\n "properties": { "version": { "type": "string", "minLength": 1 } }\n },\n "pullRequests": {\n "type": "object",\n "required": [\n "listed",\n "selected",\n "withRecord",\n "blockFound",\n "blockMissing",\n "blockInvalid",\n "blockAmbiguous",\n "noRecord"\n ],\n "additionalProperties": false,\n "properties": {\n "listed": { "$ref": "#/$defs/count" },\n "selected": { "$ref": "#/$defs/count" },\n "withRecord": { "$ref": "#/$defs/count" },\n "blockFound": { "$ref": "#/$defs/count" },\n "blockMissing": { "$ref": "#/$defs/count" },\n "blockInvalid": { "$ref": "#/$defs/count" },\n "blockAmbiguous": { "$ref": "#/$defs/count" },\n "noRecord": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["reason", "count"],\n "additionalProperties": false,\n "properties": {\n "reason": { "$ref": "#/$defs/noRecordReason" },\n "count": { "$ref": "#/$defs/count" }\n }\n }\n }\n }\n },\n "claims": {\n "allOf": [{ "$ref": "#/$defs/columns" }],\n "required": ["total"],\n "properties": { "total": { "$ref": "#/$defs/count" } },\n "unevaluatedProperties": false\n },\n "byType": {\n "type": "array",\n "items": {\n "allOf": [{ "$ref": "#/$defs/columns" }],\n "required": ["type"],\n "properties": {\n "type": { "enum": ["T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8", "R"] }\n },\n "unevaluatedProperties": false\n }\n },\n "cannotSettle": { "$ref": "#/$defs/rate" },\n "contradictedOfSettled": { "$ref": "#/$defs/rate" },\n "unsettled": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["reason", "column", "count", "remedy"],\n "additionalProperties": false,\n "properties": {\n "reason": { "$ref": "record-0.1.schema.json#/$defs/claimReason" },\n "column": { "enum": ["unverifiable", "notRecordCheckable"] },\n "count": { "type": "integer", "minimum": 1 },\n "remedy": { "oneOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] }\n }\n }\n },\n "advisory": {\n "type": "object",\n "required": ["pullRequests", "notes"],\n "additionalProperties": false,\n "properties": {\n "pullRequests": { "$ref": "#/$defs/count" },\n "notes": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["note", "count"],\n "additionalProperties": false,\n "properties": {\n "note": { "type": "string", "minLength": 1 },\n "count": { "type": "integer", "minimum": 1 }\n }\n }\n }\n }\n }\n }\n }\n }\n}\n' };
10571
10834
  function schemaText(file2) {
10572
10835
  const text2 = FILES[file2];
@@ -10640,6 +10903,16 @@ function validateDraftRecord(value) {
10640
10903
  }
10641
10904
  return draftRecord(value) ? [] : toSchemaErrors(draftRecord.errors);
10642
10905
  }
10906
+ var GITLAB_DRAFT_RECORD_SCHEMA_FILE = "record-gitlab-0.1-draft.schema.json";
10907
+ var gitlabDraftRecord;
10908
+ function validateGitlabDraftRecord(value) {
10909
+ if (gitlabDraftRecord === void 0) {
10910
+ const ajv = createAjv();
10911
+ ajv.addSchema(readSchema(GITLAB_DRAFT_RECORD_SCHEMA_FILE));
10912
+ gitlabDraftRecord = ajv.getSchema(schemaId(GITLAB_DRAFT_RECORD_SCHEMA_FILE));
10913
+ }
10914
+ return gitlabDraftRecord(value) ? [] : toSchemaErrors(gitlabDraftRecord.errors);
10915
+ }
10643
10916
  var SURVEY_SCHEMA_FILE = "survey-0.1-draft.schema.json";
10644
10917
  var surveyRecord;
10645
10918
  function validateSurveyRecord(value) {
@@ -10743,6 +11016,64 @@ function extractHandbackBlock(reportText) {
10743
11016
  return readBlockContent(block.content);
10744
11017
  }
10745
11018
 
11019
+ // src/template/wording.ts
11020
+ var PHRASES = {
11021
+ // The notes of a template.
11022
+ again: {
11023
+ cli: "run dunstan template again",
11024
+ mcp: "call dunstan_template_handback again"
11025
+ },
11026
+ againCi: {
11027
+ cli: "run dunstan template --tests-ci again",
11028
+ mcp: 'call dunstan_template_handback again with tests.from "ci"'
11029
+ },
11030
+ againCiSentence: {
11031
+ cli: "Run dunstan template --tests-ci again",
11032
+ mcp: 'Call dunstan_template_handback again with tests.from "ci"'
11033
+ },
11034
+ ciOnceFinished: {
11035
+ cli: "run dunstan template --tests-ci once CI has finished",
11036
+ mcp: 'call dunstan_template_handback with tests.from "ci" once CI has finished'
11037
+ },
11038
+ noTests: {
11039
+ cli: "tests are not declared. To declare CI's counts, run dunstan template again with --tests-ci --tests-command <cmd> --tests-workflow <path> --tests-job <name> once CI has finished on your last push; it reads the JUnit record CI published (docs/ci-test-records.md). For a run made only locally, --tests-local <JUnit XML file> --tests-command <cmd> declares it as one. --tests-junit <file> with --tests-command, --tests-workflow, --tests-job, --tests-artifact and --tests-artifact-path declares a local file's counts against a CI record.",
11040
+ mcp: `tests are not declared. To declare CI's counts, call dunstan_template_handback again with tests { from: "ci", command, workflow, job } once CI has finished on your last push; it reads the JUnit record CI published (docs/ci-test-records.md). For a run made only locally, tests { from: "local", junit, command } declares it as one.`
11041
+ },
11042
+ // The errors of a template.
11043
+ base: {
11044
+ cli: "pass --base <ref>",
11045
+ mcp: "give base"
11046
+ },
11047
+ baseName: {
11048
+ cli: "--base",
11049
+ mcp: "base"
11050
+ },
11051
+ repo: {
11052
+ cli: "pass --repo <owner/name>",
11053
+ mcp: "give tests.repository (owner/name)"
11054
+ },
11055
+ localFile: {
11056
+ cli: "--tests-local",
11057
+ mcp: "tests.junit"
11058
+ },
11059
+ // The remedy lines (src/remedy/remedies.ts).
11060
+ templateWrites: {
11061
+ cli: "dunstan template writes it",
11062
+ mcp: "dunstan_template_handback writes it"
11063
+ },
11064
+ templateCiFills: {
11065
+ cli: "dunstan template --tests-ci fills them",
11066
+ mcp: 'dunstan_template_handback with tests.from "ci" fills them'
11067
+ },
11068
+ templateCiAfter: {
11069
+ cli: "run dunstan template --tests-ci after CI has finished on your last push",
11070
+ mcp: 'call dunstan_template_handback with tests.from "ci" after CI has finished on your last push'
11071
+ }
11072
+ };
11073
+ function phrase(caller, key) {
11074
+ return PHRASES[key][caller];
11075
+ }
11076
+
10746
11077
  // src/template/template.ts
10747
11078
  var TemplateError = class extends Error {
10748
11079
  constructor(message) {
@@ -10772,12 +11103,12 @@ function commitOf(cwd, ref) {
10772
11103
  const sha = r.stdout.trim();
10773
11104
  return r.ok && SHA.test(sha) ? sha : void 0;
10774
11105
  }
10775
- function defaultBase(cwd) {
11106
+ function defaultBase(cwd, caller) {
10776
11107
  const r = git(cwd, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
10777
11108
  const ref = r.stdout.trim();
10778
11109
  if (!r.ok || !ref.startsWith("refs/remotes/")) {
10779
11110
  throw new TemplateError(
10780
- "the remote's default branch cannot be resolved (refs/remotes/origin/HEAD is not set): pass --base <ref>, or run git remote set-head origin --auto"
11111
+ `the remote's default branch cannot be resolved (refs/remotes/origin/HEAD is not set): ${phrase(caller, "base")}, or run git remote set-head origin --auto`
10781
11112
  );
10782
11113
  }
10783
11114
  return ref.slice("refs/remotes/".length);
@@ -10789,12 +11120,12 @@ function localCounts(cwd, flag, file2) {
10789
11120
  throw new TemplateError(`${flag} ${file2}: ${e.message}`);
10790
11121
  }
10791
11122
  }
10792
- function testsFrom(cwd, tests) {
11123
+ function testsFrom(cwd, tests, caller) {
10793
11124
  if (tests.kind === "ci") {
10794
11125
  return tests.read.status === "settled" ? { run: tests.read.run, notes: tests.read.notes } : { notes: tests.read.notes };
10795
11126
  }
10796
11127
  if (tests.kind === "local") {
10797
- const counts2 = localCounts(cwd, "--tests-local", tests.junit);
11128
+ const counts2 = localCounts(cwd, phrase(caller, "localFile"), tests.junit);
10798
11129
  return {
10799
11130
  run: {
10800
11131
  command: tests.command,
@@ -10803,7 +11134,7 @@ function testsFrom(cwd, tests) {
10803
11134
  record: { kind: "local" }
10804
11135
  },
10805
11136
  notes: [
10806
- `tests: ${counts2.executed} executed and ${counts2.failed} failed in ${tests.junit}, declared as a local run (record {"kind": "local"}). The gate reports them unverifiable (local_run_not_in_record) until CI publishes a record: publish one (docs/ci-test-records.md), then run dunstan template --tests-ci once CI has finished. Until then, say in the report that CI publishes no test record, and state no count in prose that the block does not declare.`
11137
+ `tests: ${counts2.executed} executed and ${counts2.failed} failed in ${tests.junit}, declared as a local run (record {"kind": "local"}). The gate reports them unverifiable (local_run_not_in_record) until CI publishes a record: publish one (docs/ci-test-records.md), then ${phrase(caller, "ciOnceFinished")}. Until then, say in the report that CI publishes no test record, and state no count in prose that the block does not declare.`
10807
11138
  ]
10808
11139
  };
10809
11140
  }
@@ -10821,18 +11152,18 @@ function testsFrom(cwd, tests) {
10821
11152
  ]
10822
11153
  };
10823
11154
  }
10824
- function originRepository(cwd) {
11155
+ function originRepository(cwd, caller) {
10825
11156
  const r = git(cwd, ["remote", "get-url", "origin"]);
10826
11157
  const url2 = r.stdout.trim();
10827
11158
  if (!r.ok || url2 === "") {
10828
- throw new TemplateError("there is no origin remote: pass --repo <owner/name>");
11159
+ throw new TemplateError(`there is no origin remote: ${phrase(caller, "repo")}`);
10829
11160
  }
10830
11161
  const m = /^(?:https:\/\/(?:[^@/]+@)?|ssh:\/\/git@|git@)github\.com[:/]([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/.exec(
10831
11162
  url2
10832
11163
  );
10833
11164
  if (m === null) {
10834
11165
  throw new TemplateError(
10835
- `origin ${url2} is not a github.com repository: pass --repo <owner/name>`
11166
+ `origin ${url2} is not a github.com repository: ${phrase(caller, "repo")}`
10836
11167
  );
10837
11168
  }
10838
11169
  return `${m[1]}/${m[2]}`;
@@ -10857,7 +11188,7 @@ function headCommit(cwd) {
10857
11188
  return head;
10858
11189
  }
10859
11190
  function templateBlock(options) {
10860
- const { cwd } = options;
11191
+ const { cwd, caller } = options;
10861
11192
  const head = headCommit(cwd);
10862
11193
  const symbolic = git(cwd, ["symbolic-ref", "--quiet", "HEAD"]);
10863
11194
  if (!symbolic.ok) {
@@ -10866,8 +11197,10 @@ function templateBlock(options) {
10866
11197
  );
10867
11198
  }
10868
11199
  const branch = symbolic.stdout.trim().replace(/^refs\/heads\//, "");
10869
- if (options.base?.startsWith("-")) throw new TemplateError(`--base ${options.base} is not a ref`);
10870
- const base = options.base ?? defaultBase(cwd);
11200
+ if (options.base?.startsWith("-")) {
11201
+ throw new TemplateError(`${phrase(caller, "baseName")} ${options.base} is not a ref`);
11202
+ }
11203
+ const base = options.base ?? defaultBase(cwd, caller);
10871
11204
  const baseCommit = commitOf(cwd, base);
10872
11205
  if (baseCommit === void 0) throw new TemplateError(`the base ${base} cannot be resolved`);
10873
11206
  const mb = git(cwd, ["merge-base", baseCommit, head]);
@@ -10890,7 +11223,7 @@ function templateBlock(options) {
10890
11223
  compareCodeUnits
10891
11224
  );
10892
11225
  const block = { dunstan: BLOCK_VERSION, headCommit: head, filesChanged };
10893
- const tests = options.tests === void 0 ? void 0 : testsFrom(cwd, options.tests);
11226
+ const tests = options.tests === void 0 ? void 0 : testsFrom(cwd, options.tests, caller);
10894
11227
  if (tests?.run !== void 0) block.tests = [tests.run];
10895
11228
  const read = readBlockContent(JSON.stringify(block));
10896
11229
  if (read.status !== "found") {
@@ -10900,7 +11233,7 @@ function templateBlock(options) {
10900
11233
  );
10901
11234
  }
10902
11235
  const notes = [
10903
- `this block describes the local HEAD ${head} on ${branch}, against ${base} (merge base ${mergeBase}). Pushing more commits makes it stale, and the gate then reports head_mismatch: run dunstan template again after your last push.`
11236
+ `this block describes the local HEAD ${head} on ${branch}, against ${base} (merge base ${mergeBase}). Pushing more commits makes it stale, and the gate then reports head_mismatch: ${phrase(caller, "again")} after your last push.`
10904
11237
  ];
10905
11238
  if (filesChanged.length === 0) notes.push("HEAD changes no file against the merge base.");
10906
11239
  const dirty = git(cwd, ["status", "--porcelain", "-z"]);
@@ -10908,9 +11241,7 @@ function templateBlock(options) {
10908
11241
  notes.push("the working tree has changes that are not committed; they are not in this block.");
10909
11242
  }
10910
11243
  if (tests === void 0) {
10911
- notes.push(
10912
- "tests are not declared. To declare CI's counts, run dunstan template again with --tests-ci --tests-command <cmd> --tests-workflow <path> --tests-job <name> once CI has finished on your last push; it reads the JUnit record CI published (docs/ci-test-records.md). For a run made only locally, --tests-local <JUnit XML file> --tests-command <cmd> declares it as one. --tests-junit <file> with --tests-command, --tests-workflow, --tests-job, --tests-artifact and --tests-artifact-path declares a local file's counts against a CI record."
10913
- );
11244
+ notes.push(phrase(caller, "noTests"));
10914
11245
  } else {
10915
11246
  notes.push(...tests.notes);
10916
11247
  }
@@ -10932,27 +11263,642 @@ import { readFileSync as readFileSync3, writeFileSync } from "node:fs";
10932
11263
  import { basename } from "node:path";
10933
11264
  import { parseArgs } from "node:util";
10934
11265
 
11266
+ // src/evidence/gitlab.ts
11267
+ var NO_HEAD_PIPELINE = "head_pipeline";
11268
+ var HEAD_PIPELINE_ELSEWHERE = "head_pipeline_not_at_head_commit";
11269
+ var CLOSE_EVENT_PAGES2 = 10;
11270
+ var SEGMENT = "[A-Za-z0-9_][A-Za-z0-9._-]{0,254}";
11271
+ var FULL_PATH = `${SEGMENT}(?:/${SEGMENT}){0,19}/[A-Za-z0-9._-]{1,255}`;
11272
+ var GITLAB_PROJECT = new RegExp(`^${FULL_PATH}$`);
11273
+ var ISSUE_REF2 = new RegExp(`^${FULL_PATH}#[1-9][0-9]{0,9}$`);
11274
+ var COMMIT = /^[0-9a-f]{40}$/;
11275
+ var TIMESTAMP = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z$/;
11276
+ var MERGE_REQUEST_URL = /^https?:\/\/[^/]+\/(.+)\/-\/merge_requests\/([1-9][0-9]{0,9})$/;
11277
+ var ShapeError2 = class extends Error {
11278
+ };
11279
+ function obj2(value) {
11280
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new ShapeError2();
11281
+ return value;
11282
+ }
11283
+ function arr2(value) {
11284
+ if (!Array.isArray(value)) throw new ShapeError2();
11285
+ return value;
11286
+ }
11287
+ function str2(value) {
11288
+ if (typeof value !== "string") throw new ShapeError2();
11289
+ return value;
11290
+ }
11291
+ function int2(value) {
11292
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) throw new ShapeError2();
11293
+ return value;
11294
+ }
11295
+ function bool(value) {
11296
+ if (typeof value !== "boolean") throw new ShapeError2();
11297
+ return value;
11298
+ }
11299
+ function strOrNull2(value) {
11300
+ return value === null || value === void 0 ? null : str2(value);
11301
+ }
11302
+ function timestamp2(value) {
11303
+ const text2 = str2(value);
11304
+ if (!TIMESTAMP.test(text2)) throw new ShapeError2();
11305
+ return text2;
11306
+ }
11307
+ function commitOid2(value) {
11308
+ const text2 = str2(value);
11309
+ if (!COMMIT.test(text2)) throw new ShapeError2();
11310
+ return text2;
11311
+ }
11312
+ function unreadable2(source) {
11313
+ return { status: "unreadable", source };
11314
+ }
11315
+ function byCodeUnits(a, b) {
11316
+ return a < b ? -1 : a > b ? 1 : 0;
11317
+ }
11318
+ function projectPath(project) {
11319
+ return `/api/v4/projects/${encodeURIComponent(project)}`;
11320
+ }
11321
+ function mergeRequestPath(project, iid) {
11322
+ return `${projectPath(project)}/merge_requests/${iid}`;
11323
+ }
11324
+ async function readPages(client, kind, pathFor, map2, options = {}) {
11325
+ const out = [];
11326
+ for (let page = 1; page <= (options.maxPages ?? MAX_PAGES); page++) {
11327
+ const path = pathFor(page);
11328
+ const read = await client.read(kind, path, options.record === false ? { record: false } : {});
11329
+ if (!read.ok) return null;
11330
+ let items;
11331
+ try {
11332
+ items = arr2(read.json);
11333
+ out.push(...items.map((item) => map2(item, `GET ${path}`)));
11334
+ } catch (e) {
11335
+ if (!(e instanceof ShapeError2)) throw e;
11336
+ client.markParseError(`GET ${path}`);
11337
+ return null;
11338
+ }
11339
+ if (items.length < PER_PAGE) return out;
11340
+ }
11341
+ return null;
11342
+ }
11343
+ var pages = (path) => (page) => `${path}?per_page=${PER_PAGE}&page=${page}`;
11344
+ function headPipeline(value) {
11345
+ if (value === null || value === void 0) return null;
11346
+ const p = obj2(value);
11347
+ const id = int2(p.id);
11348
+ if (id < 1) throw new ShapeError2();
11349
+ return { id, sha: commitOid2(p.sha) };
11350
+ }
11351
+ function diffEntry(item) {
11352
+ const f = obj2(item);
11353
+ const path = str2(f.new_path);
11354
+ const previous = str2(f.old_path);
11355
+ const added = bool(f.new_file);
11356
+ const removed = bool(f.deleted_file);
11357
+ const renamed = bool(f.renamed_file);
11358
+ if ([added, removed, renamed].filter(Boolean).length > 1) throw new ShapeError2();
11359
+ if (added) return { path, status: "added" };
11360
+ if (removed) return { path, status: "removed" };
11361
+ if (renamed) return { path, status: "renamed", previousPath: previous };
11362
+ return { path, status: "modified" };
11363
+ }
11364
+ function mergeCommit(mr) {
11365
+ if (mr.merge_commit_sha !== null && mr.merge_commit_sha !== void 0) {
11366
+ return commitOid2(mr.merge_commit_sha);
11367
+ }
11368
+ if (mr.squash_commit_sha !== null && mr.squash_commit_sha !== void 0) {
11369
+ return commitOid2(mr.squash_commit_sha);
11370
+ }
11371
+ return null;
11372
+ }
11373
+ var STATES = {
11374
+ opened: { state: "open", merged: false },
11375
+ closed: { state: "closed", merged: false },
11376
+ merged: { state: "closed", merged: true }
11377
+ };
11378
+ async function readMergeRequest(client, project, iid, options = {}) {
11379
+ const path = mergeRequestPath(project, iid);
11380
+ const name = `merge request ${project}!${iid}`;
11381
+ const record2 = options.record === false ? { record: false } : {};
11382
+ const read = await client.read("pull_request", path, record2);
11383
+ if (!read.ok) {
11384
+ throw new PullRequestUnreadable(`cannot read ${name} (${read.error}); no record is written`);
11385
+ }
11386
+ let head;
11387
+ try {
11388
+ const mr = obj2(read.json);
11389
+ const mapped = STATES[str2(mr.state)];
11390
+ if (mapped === void 0) throw new ShapeError2();
11391
+ const number4 = int2(mr.iid);
11392
+ const full = str2(obj2(mr.references).full);
11393
+ const suffix = `!${number4}`;
11394
+ if (!full.endsWith(suffix)) throw new ShapeError2();
11395
+ const repository = full.slice(0, -suffix.length);
11396
+ if (!GITLAB_PROJECT.test(repository)) throw new ShapeError2();
11397
+ if (repository.toLowerCase() !== project.toLowerCase()) throw new ShapeError2();
11398
+ const changesCount = mr.changes_count;
11399
+ head = {
11400
+ evidence: {
11401
+ status: "ok",
11402
+ number: number4,
11403
+ state: mapped.state,
11404
+ merged: mapped.merged,
11405
+ mergedAt: mr.merged_at === null || mr.merged_at === void 0 ? null : timestamp2(mr.merged_at),
11406
+ headSha: commitOid2(mr.sha),
11407
+ mergeSha: mapped.merged ? mergeCommit(mr) : null
11408
+ },
11409
+ repository,
11410
+ baseRef: str2(mr.target_branch),
11411
+ body: strOrNull2(mr.description),
11412
+ changesCount: strOrNull2(changesCount),
11413
+ headPipeline: headPipeline(mr.head_pipeline)
11414
+ };
11415
+ } catch (e) {
11416
+ if (e instanceof ShapeError2) {
11417
+ throw new PullRequestUnreadable(
11418
+ `${name} answered in an unexpected shape; no record is written`
11419
+ );
11420
+ }
11421
+ throw e;
11422
+ }
11423
+ const listed2 = await readPages(client, "pull_request_files", pages(`${path}/diffs`), diffEntry, {
11424
+ record: options.record !== false
11425
+ });
11426
+ if (listed2 === null) {
11427
+ throw new PullRequestUnreadable(`cannot read the diffs of ${name}; no record is written`);
11428
+ }
11429
+ const unique = new Map(listed2.map((e) => [e.path, e]));
11430
+ const entries = [...unique.values()].sort((a, b) => byCodeUnits(a.path, b.path));
11431
+ const count = head.changesCount;
11432
+ const complete = count !== null && /^[0-9]+$/.test(count) && Number(count) === entries.length;
11433
+ return {
11434
+ evidence: { ...head.evidence, changedFiles: entries.length },
11435
+ repository: head.repository,
11436
+ baseRef: head.baseRef,
11437
+ body: head.body,
11438
+ files: { status: "ok", complete, entries },
11439
+ headPipeline: head.headPipeline
11440
+ };
11441
+ }
11442
+ function reportFromMergeRequestDescription(pr) {
11443
+ return {
11444
+ bytes: new TextEncoder().encode(pr.body ?? ""),
11445
+ source: {
11446
+ kind: "pr-body",
11447
+ locator: `GET ${mergeRequestPath(pr.repository, pr.evidence.number)}#description`
11448
+ }
11449
+ };
11450
+ }
11451
+ async function readClosingReferences2(client, project, iid) {
11452
+ const issues = await readPages(
11453
+ client,
11454
+ "closing_references",
11455
+ pages(`${mergeRequestPath(project, iid)}/closes_issues`),
11456
+ (item) => {
11457
+ const ref = str2(obj2(obj2(item).references).full);
11458
+ if (!ISSUE_REF2.test(ref)) throw new ShapeError2();
11459
+ return ref;
11460
+ }
11461
+ );
11462
+ if (issues === null) return unreadable2("closing_references");
11463
+ return { status: "ok", issues: [...new Set(issues)].sort(byCodeUnits) };
11464
+ }
11465
+ function answeredIssue2(json2, ref) {
11466
+ const full = str2(obj2(obj2(json2).references).full);
11467
+ if (!sameIssue(full, ref)) throw new ShapeError2();
11468
+ }
11469
+ function issueState(json2) {
11470
+ const state = str2(obj2(json2).state);
11471
+ if (state === "opened") return "open";
11472
+ if (state === "closed") return "closed";
11473
+ throw new ShapeError2();
11474
+ }
11475
+ async function isAncestor2(client, project, commit, head) {
11476
+ const path = `${projectPath(project)}/repository/merge_base?refs[]=${commit}&refs[]=${head}`;
11477
+ const read = await client.read("compare", path);
11478
+ if (!read.ok) return null;
11479
+ try {
11480
+ return commitOid2(obj2(read.json).id) === commit;
11481
+ } catch (e) {
11482
+ if (!(e instanceof ShapeError2)) throw e;
11483
+ client.markParseError(`GET ${path}`);
11484
+ return null;
11485
+ }
11486
+ }
11487
+ async function readIssue(client, ref) {
11488
+ const [repo, num] = ref.split("#");
11489
+ const path = `${projectPath(repo)}/issues/${num}`;
11490
+ return { read: await client.read("issue", path), path };
11491
+ }
11492
+ async function readReferences2(client, block, project, headSha, closing) {
11493
+ const toRead = (block.references ?? []).filter((r) => {
11494
+ if (r.relation === "cites") return true;
11495
+ if (!("issue" in r) || closing?.status !== "ok") return false;
11496
+ const target = qualifyIssue(r.issue, project);
11497
+ return !closing.issues.some((issue2) => sameIssue(issue2, target));
11498
+ });
11499
+ if (toRead.length === 0) return void 0;
11500
+ const out = [];
11501
+ const seen = /* @__PURE__ */ new Set();
11502
+ const projectReadable = /* @__PURE__ */ new Map([[project.toLowerCase(), true]]);
11503
+ for (const reference of toRead) {
11504
+ if ("issue" in reference) {
11505
+ const ref2 = qualifyIssue(reference.issue, project);
11506
+ const key2 = `issue:${ref2.toLowerCase()}`;
11507
+ if (seen.has(key2)) continue;
11508
+ seen.add(key2);
11509
+ const repo = ref2.split("#")[0];
11510
+ let readable = projectReadable.get(repo.toLowerCase());
11511
+ if (readable === void 0) {
11512
+ readable = (await client.read("repository", projectPath(repo))).ok;
11513
+ projectReadable.set(repo.toLowerCase(), readable);
11514
+ }
11515
+ if (!readable) {
11516
+ out.push({ kind: "issue", ref: ref2, ...unreadable2("repository") });
11517
+ continue;
11518
+ }
11519
+ const { read: read2, path } = await readIssue(client, ref2);
11520
+ if (!read2.ok) {
11521
+ out.push({ kind: "issue", ref: ref2, ...unreadable2("issue") });
11522
+ continue;
11523
+ }
11524
+ try {
11525
+ answeredIssue2(read2.json, ref2);
11526
+ } catch (e) {
11527
+ if (!(e instanceof ShapeError2)) throw e;
11528
+ client.markParseError(`GET ${path}`);
11529
+ out.push({ kind: "issue", ref: ref2, ...unreadable2("issue") });
11530
+ continue;
11531
+ }
11532
+ out.push({ kind: "issue", ref: ref2, status: "ok", exists: true });
11533
+ continue;
11534
+ }
11535
+ const ref = reference.commit;
11536
+ const key = `commit:${ref}`;
11537
+ if (seen.has(key)) continue;
11538
+ seen.add(key);
11539
+ const read = await client.read("commit", `${projectPath(project)}/repository/commits/${ref}`);
11540
+ if (!read.ok && (read.status === 404 || read.status === 410)) {
11541
+ out.push({ kind: "commit", ref, status: "ok", exists: false, reachableFromHead: false });
11542
+ continue;
11543
+ }
11544
+ if (!read.ok) {
11545
+ out.push({ kind: "commit", ref, ...unreadable2("commit") });
11546
+ continue;
11547
+ }
11548
+ const reachable = ref === headSha ? true : await isAncestor2(client, project, ref, headSha);
11549
+ if (reachable === null) out.push({ kind: "commit", ref, ...unreadable2("compare") });
11550
+ else
11551
+ out.push({ kind: "commit", ref, status: "ok", exists: true, reachableFromHead: reachable });
11552
+ }
11553
+ return out;
11554
+ }
11555
+ async function readCloseBase2(client, project, mr) {
11556
+ const path = projectPath(project);
11557
+ const read = await client.read("repository", path);
11558
+ if (!read.ok) return unreadable2("repository");
11559
+ let defaultBranch;
11560
+ try {
11561
+ defaultBranch = strOrNull2(obj2(read.json).default_branch);
11562
+ } catch (e) {
11563
+ if (!(e instanceof ShapeError2)) throw e;
11564
+ client.markParseError(`GET ${path}`);
11565
+ return unreadable2("repository");
11566
+ }
11567
+ const commits = await readPages(
11568
+ client,
11569
+ "pull_request_commits",
11570
+ pages(`${mergeRequestPath(project, mr.evidence.number)}/commits`),
11571
+ (item) => commitOid2(obj2(item).id)
11572
+ );
11573
+ if (commits === null) return unreadable2("pull_request_commits");
11574
+ return {
11575
+ status: "ok",
11576
+ baseRef: mr.baseRef,
11577
+ defaultBranch,
11578
+ commits: [...new Set(commits)].sort(byCodeUnits)
11579
+ };
11580
+ }
11581
+ function closeEvent2(item) {
11582
+ const e = obj2(item);
11583
+ if (str2(e.state) !== "closed") return null;
11584
+ const at2 = timestamp2(e.created_at);
11585
+ const source = e.source_merge_request;
11586
+ if (source !== null && source !== void 0) {
11587
+ const m = obj2(source);
11588
+ const iid = int2(m.iid);
11589
+ const url2 = MERGE_REQUEST_URL.exec(str2(m.web_url));
11590
+ if (url2 === null || Number(url2[2]) !== iid) throw new ShapeError2();
11591
+ const ref = `${url2[1]}#${iid}`;
11592
+ if (!ISSUE_REF2.test(ref)) throw new ShapeError2();
11593
+ return { at: at2, closer: { kind: "pull_request", ref } };
11594
+ }
11595
+ if (e.source_commit !== null && e.source_commit !== void 0) {
11596
+ return { at: at2, closer: { kind: "commit", oid: commitOid2(e.source_commit) } };
11597
+ }
11598
+ return { at: at2, closer: null };
11599
+ }
11600
+ async function readCloseTarget2(client, ref) {
11601
+ const { read, path } = await readIssue(client, ref);
11602
+ if (!read.ok) return { ref, ...unreadable2("issue") };
11603
+ let state;
11604
+ try {
11605
+ answeredIssue2(read.json, ref);
11606
+ state = issueState(read.json);
11607
+ } catch (e) {
11608
+ if (!(e instanceof ShapeError2)) throw e;
11609
+ client.markParseError(`GET ${path}`);
11610
+ return { ref, ...unreadable2("issue") };
11611
+ }
11612
+ const events = await readPages(
11613
+ client,
11614
+ "issue",
11615
+ pages(`${path}/resource_state_events`),
11616
+ closeEvent2,
11617
+ {
11618
+ maxPages: CLOSE_EVENT_PAGES2
11619
+ }
11620
+ );
11621
+ if (events === null) return { ref, ...unreadable2("issue") };
11622
+ return {
11623
+ ref,
11624
+ status: "ok",
11625
+ type: "issue",
11626
+ state,
11627
+ events: events.filter((e) => e !== null)
11628
+ };
11629
+ }
11630
+ async function readCloseEvents2(client, project, mr, targets) {
11631
+ const pullRequest = await readCloseBase2(client, project, mr);
11632
+ const read = [];
11633
+ for (const target of targets) read.push(await readCloseTarget2(client, target));
11634
+ return { pullRequest, targets: read };
11635
+ }
11636
+ function pipelineAt(mr, headCommit2) {
11637
+ if (mr.headPipeline === null) return { status: "unpopulated", field: NO_HEAD_PIPELINE };
11638
+ if (mr.headPipeline.sha !== headCommit2) {
11639
+ return { status: "unpopulated", field: HEAD_PIPELINE_ELSEWHERE };
11640
+ }
11641
+ return mr.headPipeline;
11642
+ }
11643
+ function listedJob(item, locator) {
11644
+ const j = obj2(item);
11645
+ const id = int2(j.id);
11646
+ if (id < 1) throw new ShapeError2();
11647
+ const files = j.artifacts === null || j.artifacts === void 0 ? [] : arr2(j.artifacts);
11648
+ const archive = files.some((f) => str2(obj2(f).file_type) === "archive");
11649
+ const expires = j.artifacts_expire_at;
11650
+ return {
11651
+ id,
11652
+ name: str2(j.name),
11653
+ status: str2(j.status),
11654
+ archive,
11655
+ expireAt: expires === null || expires === void 0 ? null : timestamp2(expires),
11656
+ locator
11657
+ };
11658
+ }
11659
+ var GITLAB_JOB_STATUSES = {
11660
+ success: { status: "completed", conclusion: "success" },
11661
+ failed: { status: "completed", conclusion: "failure" },
11662
+ canceled: { status: "completed", conclusion: "cancelled" },
11663
+ skipped: { status: "completed", conclusion: "skipped" },
11664
+ manual: { status: "completed", conclusion: "skipped" },
11665
+ created: { status: "queued", conclusion: null },
11666
+ pending: { status: "queued", conclusion: null },
11667
+ preparing: { status: "queued", conclusion: null },
11668
+ waiting_for_resource: { status: "queued", conclusion: null },
11669
+ scheduled: { status: "queued", conclusion: null },
11670
+ running: { status: "in_progress", conclusion: null }
11671
+ };
11672
+ function checkRunOf(job) {
11673
+ if (!Object.hasOwn(GITLAB_JOB_STATUSES, job.status)) return null;
11674
+ const mapped = GITLAB_JOB_STATUSES[job.status];
11675
+ return { id: job.id, name: job.name, ...mapped };
11676
+ }
11677
+ function jobsReader(client, project, pipeline) {
11678
+ let read;
11679
+ return () => {
11680
+ read ??= readPages(
11681
+ client,
11682
+ "check_runs",
11683
+ (page) => `${projectPath(project)}/pipelines/${pipeline}/jobs?include_retried=false&per_page=${PER_PAGE}&page=${page}`,
11684
+ listedJob
11685
+ );
11686
+ return read;
11687
+ };
11688
+ }
11689
+ async function readCheckRuns2(client, project, pipeline, jobs, excludedIds) {
11690
+ const listed2 = await jobs();
11691
+ if (listed2 === null) return unreadable2("check_runs");
11692
+ const bridgesPath = `${projectPath(project)}/pipelines/${pipeline.id}/bridges`;
11693
+ const bridges = await readPages(client, "check_runs", pages(bridgesPath), listedJob);
11694
+ if (bridges === null) return unreadable2("check_runs");
11695
+ const runs = [];
11696
+ for (const job of [...listed2, ...bridges]) {
11697
+ const run = checkRunOf(job);
11698
+ if (run === null) {
11699
+ client.markParseError(job.locator);
11700
+ return unreadable2("check_runs");
11701
+ }
11702
+ runs.push(run);
11703
+ }
11704
+ return {
11705
+ status: "ok",
11706
+ commit: pipeline.sha,
11707
+ excludedIds: [...new Set(excludedIds)].sort((a, b) => a - b),
11708
+ runs: [...new Map(runs.map((r) => [r.id, r])).values()].sort((a, b) => a.id - b.id)
11709
+ };
11710
+ }
11711
+ async function readCiConfigPath(client, project) {
11712
+ const path = projectPath(project);
11713
+ const read = await client.read("repository", path);
11714
+ if (!read.ok) return null;
11715
+ try {
11716
+ const p = obj2(read.json);
11717
+ if (!("ci_config_path" in p)) throw new ShapeError2();
11718
+ const config2 = strOrNull2(p.ci_config_path);
11719
+ return config2 === null || config2 === "" ? ".gitlab-ci.yml" : config2;
11720
+ } catch (e) {
11721
+ if (!(e instanceof ShapeError2)) throw e;
11722
+ client.markParseError(`GET ${path}`);
11723
+ return null;
11724
+ }
11725
+ }
11726
+ async function readTestRecord2(client, project, record2, jobs, ciConfig) {
11727
+ const config2 = await ciConfig();
11728
+ if (config2 === null) return { record: record2, ...unreadable2("repository") };
11729
+ if (config2 !== record2.workflow) return { record: record2, status: "ok", runs: [] };
11730
+ const listed2 = await jobs();
11731
+ if (listed2 === null) return { record: record2, ...unreadable2("check_runs") };
11732
+ const runs = [];
11733
+ const named = listed2.filter((j) => j.name === record2.job).sort((a, b) => a.id - b.id);
11734
+ for (const job of named) {
11735
+ if (record2.artifact !== "archive") continue;
11736
+ if (!job.archive) {
11737
+ if (job.expireAt !== null) return { record: record2, ...unreadable2("artifact") };
11738
+ continue;
11739
+ }
11740
+ const zipPath = `${projectPath(project)}/jobs/${job.id}/artifacts`;
11741
+ const download = await client.read("artifact", zipPath, {
11742
+ json: false,
11743
+ locatorSuffix: `#${record2.path}`
11744
+ });
11745
+ if (!download.ok) return { record: record2, ...unreadable2("artifact") };
11746
+ try {
11747
+ const file2 = readZipEntry(download.body, record2.path, client.maxEntryBytes);
11748
+ if (file2 === null) continue;
11749
+ const counts = countJunit(new TextDecoder("utf-8").decode(file2));
11750
+ runs.push({ runId: job.id, runAttempt: 1, ...counts });
11751
+ } catch (e) {
11752
+ if (!(e instanceof ZipError || e instanceof JunitError)) throw e;
11753
+ client.markParseError(`GET ${zipPath}#${record2.path}`);
11754
+ return { record: record2, ...unreadable2("artifact") };
11755
+ }
11756
+ }
11757
+ return { record: record2, status: "ok", runs };
11758
+ }
11759
+ var GITLAB_DEPLOYMENT_STATUSES = {
11760
+ success: true,
11761
+ created: false,
11762
+ running: false,
11763
+ failed: false,
11764
+ canceled: false,
11765
+ skipped: false,
11766
+ blocked: false
11767
+ };
11768
+ function second2(t) {
11769
+ return t.slice(0, 19);
11770
+ }
11771
+ function listedDeployment(environment) {
11772
+ return (item) => {
11773
+ const d = obj2(item);
11774
+ const id = int2(d.id);
11775
+ if (id < 1) throw new ShapeError2();
11776
+ if (str2(obj2(d.environment).name) !== environment) throw new ShapeError2();
11777
+ const status = str2(d.status);
11778
+ if (!Object.hasOwn(GITLAB_DEPLOYMENT_STATUSES, status)) throw new ShapeError2();
11779
+ const updatedAt = timestamp2(d.updated_at);
11780
+ return {
11781
+ id,
11782
+ sha: commitOid2(d.sha),
11783
+ createdAt: timestamp2(d.created_at),
11784
+ successAt: GITLAB_DEPLOYMENT_STATUSES[status] === true ? updatedAt : null
11785
+ };
11786
+ };
11787
+ }
11788
+ async function readDeployments2(client, project, pr, deployedAt) {
11789
+ const environment = deployedAt.environment;
11790
+ const listed2 = await readPages(
11791
+ client,
11792
+ "deployments",
11793
+ (page) => `${projectPath(project)}/deployments?environment=${encodeURIComponent(environment)}&order_by=id&sort=desc&per_page=${PER_PAGE}&page=${page}`,
11794
+ listedDeployment(environment)
11795
+ );
11796
+ if (listed2 === null) return { environment, ...unreadable2("deployments") };
11797
+ const window = pr.merged && pr.mergedAt !== null && pr.mergeSha !== null ? { from: second2(pr.mergedAt), to: second2(deployedAt.at) } : null;
11798
+ const candidates = /* @__PURE__ */ new Map();
11799
+ for (const d of listed2) {
11800
+ const created = second2(d.createdAt);
11801
+ const inWindow = window !== null && created >= window.from && created <= window.to;
11802
+ if (d.sha === pr.headSha || inWindow) candidates.set(d.id, d);
11803
+ }
11804
+ const deployments = [];
11805
+ const related = /* @__PURE__ */ new Map();
11806
+ for (const d of [...candidates.values()].sort((a, b) => a.id - b.id)) {
11807
+ let relation;
11808
+ if (d.sha === pr.headSha) relation = "head";
11809
+ else if (d.sha === pr.mergeSha) relation = "merge";
11810
+ else if (!pr.merged || pr.mergeSha === null) relation = "unrelated";
11811
+ else {
11812
+ let known = related.get(d.sha);
11813
+ if (known === void 0) {
11814
+ const ancestor = await isAncestor2(client, project, pr.mergeSha, d.sha);
11815
+ known = ancestor === null ? "unknown" : ancestor ? "descendant" : "unrelated";
11816
+ related.set(d.sha, known);
11817
+ }
11818
+ relation = known;
11819
+ }
11820
+ deployments.push({ id: d.id, sha: d.sha, relation, successAt: d.successAt });
11821
+ }
11822
+ return { environment, status: "ok", deployments };
11823
+ }
11824
+ async function readGitLabEvidence(client, input2) {
11825
+ const { block, repository } = input2;
11826
+ const mr = input2.pullRequest;
11827
+ if (mr.files === void 0) throw new Error("the GitLab reader reads a merge request it read");
11828
+ const pr = mr.evidence;
11829
+ const evidence = { pullRequest: pr, sources: [] };
11830
+ if (block !== null) {
11831
+ evidence.files = mr.files;
11832
+ if ((block.references ?? []).some((r) => r.relation === "closes")) {
11833
+ evidence.closingReferences = await readClosingReferences2(client, repository, pr.number);
11834
+ }
11835
+ const references = await readReferences2(
11836
+ client,
11837
+ block,
11838
+ repository,
11839
+ pr.headSha,
11840
+ evidence.closingReferences
11841
+ );
11842
+ if (references !== void 0) evidence.references = references;
11843
+ const targets = closeTargets(block, repository, evidence.closingReferences, references);
11844
+ if (targets.length > 0) {
11845
+ evidence.closeEvents = await readCloseEvents2(client, repository, mr, targets);
11846
+ }
11847
+ const pipeline = pipelineAt(mr, block.headCommit);
11848
+ const jobs = "id" in pipeline ? jobsReader(client, repository, pipeline.id) : null;
11849
+ if (block.checks !== void 0) {
11850
+ evidence.checkRuns = "id" in pipeline && jobs !== null ? await readCheckRuns2(client, repository, pipeline, jobs, input2.excludedCheckRunIds ?? []) : pipeline;
11851
+ }
11852
+ const records = /* @__PURE__ */ new Map();
11853
+ for (const test of block.tests ?? []) {
11854
+ if (test.record.kind !== "junit") continue;
11855
+ const record2 = test.record;
11856
+ records.set(canonicalize(record2), record2);
11857
+ }
11858
+ if (records.size > 0) {
11859
+ evidence.testRecords = [];
11860
+ let config2;
11861
+ const ciConfig = () => {
11862
+ config2 ??= readCiConfigPath(client, repository);
11863
+ return config2;
11864
+ };
11865
+ for (const record2 of records.values()) {
11866
+ evidence.testRecords.push(
11867
+ "id" in pipeline && jobs !== null ? await readTestRecord2(client, repository, record2, jobs, ciConfig) : { record: record2, ...pipeline }
11868
+ );
11869
+ }
11870
+ }
11871
+ if (block.deployedAt !== void 0) {
11872
+ evidence.deployments = [await readDeployments2(client, repository, pr, block.deployedAt)];
11873
+ }
11874
+ }
11875
+ evidence.sources = client.sortedSources();
11876
+ return evidence;
11877
+ }
11878
+
10935
11879
  // src/evidence/http.ts
10936
11880
  var DEFAULT_API = "https://api.github.com";
10937
11881
  var MAX_ARCHIVE_BYTES = MAX_ENTRY_BYTES + 8 * 1024 * 1024;
10938
11882
  var MAX_BODY_BYTES = 48 * 1024 * 1024;
10939
11883
  var EMPTY = new Uint8Array();
10940
- var GitHubClient = class {
11884
+ var ForgeClient = class {
10941
11885
  sources = /* @__PURE__ */ new Map();
10942
11886
  maxArchiveBytes;
10943
11887
  maxEntryBytes;
10944
11888
  #allocate;
10945
11889
  #token;
10946
11890
  #baseUrl;
11891
+ #profileHeaders;
10947
11892
  #fetch;
10948
11893
  #now;
10949
11894
  #sleep;
10950
11895
  #warn;
10951
11896
  #maxAttempts;
10952
11897
  #maxWaitMs;
10953
- constructor(options = {}) {
11898
+ constructor(options, profile) {
10954
11899
  this.#token = options.token;
10955
- this.#baseUrl = options.baseUrl ?? DEFAULT_API;
11900
+ this.#baseUrl = options.baseUrl ?? profile.baseUrl;
11901
+ this.#profileHeaders = profile.headers;
10956
11902
  this.#fetch = options.fetch ?? fetch;
10957
11903
  this.#now = options.now ?? (() => /* @__PURE__ */ new Date());
10958
11904
  this.#sleep = options.sleep ?? ((ms) => new Promise((resolve5) => setTimeout(resolve5, ms)));
@@ -11029,8 +11975,7 @@ var GitHubClient = class {
11029
11975
  }
11030
11976
  async #fetchWithRetry(method, path, body, locator, budget) {
11031
11977
  const headers = {
11032
- accept: "application/vnd.github+json",
11033
- "x-github-api-version": "2022-11-28",
11978
+ ...this.#profileHeaders,
11034
11979
  "user-agent": `dunstan/${CHECKER_VERSION}`
11035
11980
  };
11036
11981
  if (this.hasToken) headers.authorization = `Bearer ${this.#token}`;
@@ -11073,6 +12018,25 @@ var GitHubClient = class {
11073
12018
  }
11074
12019
  }
11075
12020
  };
12021
+ var GitHubClient = class extends ForgeClient {
12022
+ constructor(options = {}) {
12023
+ super(options, {
12024
+ baseUrl: DEFAULT_API,
12025
+ headers: { accept: "application/vnd.github+json", "x-github-api-version": "2022-11-28" }
12026
+ });
12027
+ }
12028
+ };
12029
+ var DEFAULT_GITLAB_HOST = "gitlab.com";
12030
+ var GITLAB_HOST = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
12031
+ var GitLabClient = class extends ForgeClient {
12032
+ host;
12033
+ constructor(options = {}) {
12034
+ const host = options.host ?? DEFAULT_GITLAB_HOST;
12035
+ if (!GITLAB_HOST.test(host)) throw new Error("a GitLab host is a lowercase DNS name");
12036
+ super(options, { baseUrl: `https://${host}`, headers: { accept: "application/json" } });
12037
+ this.host = host;
12038
+ }
12039
+ };
11076
12040
  async function cancel(body) {
11077
12041
  await body?.cancel().catch(() => {
11078
12042
  });
@@ -11684,7 +12648,7 @@ function precisionText(precision) {
11684
12648
  }
11685
12649
 
11686
12650
  // src/advisory/advise.ts
11687
- var COMPARISON_VERSION = "0.4.0";
12651
+ var COMPARISON_VERSION = "0.5.0";
11688
12652
  var COMPARISON = { version: COMPARISON_VERSION };
11689
12653
  var HEX = /^[0-9a-f]{7,40}$/;
11690
12654
  function blockFor(kind, value, headSha) {
@@ -11845,7 +12809,7 @@ function advisoryDigest(section) {
11845
12809
 
11846
12810
  // src/advisory/extract.ts
11847
12811
  var words = (list) => new Set(list);
11848
- var phrases = (list) => list.map((phrase) => phrase.split(" "));
12812
+ var phrases = (list) => list.map((phrase2) => phrase2.split(" "));
11849
12813
  var V = GRAMMAR.verbs;
11850
12814
  var FILE_VERBS = words(V.file);
11851
12815
  var CLOSE_VERBS = words(V.close);
@@ -12100,7 +13064,7 @@ function clausesOf(prep) {
12100
13064
  }
12101
13065
  var ISSUE = /^(?:[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100})?#[1-9][0-9]{0,9}$/;
12102
13066
  var ISSUE_URL = /^https?:\/\/github\.com\/([A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100})\/(?:issues|pull)\/([1-9][0-9]{0,9})\/?$/;
12103
- var TIMESTAMP = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z$/;
13067
+ var TIMESTAMP2 = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z$/;
12104
13068
  function repoPath(token, inCode) {
12105
13069
  const path = token.replace(/^\.\//, "").replace(/(?::[0-9]+){1,2}$/, "").replace(/\([0-9]+(?:,[0-9]+)?\)?$/, "");
12106
13070
  if (!/^\.?[A-Za-z0-9_][A-Za-z0-9_.+/-]*$/.test(path)) return void 0;
@@ -12128,7 +13092,7 @@ function classify(word) {
12128
13092
  if (word.atom === "code" && /\s/.test(word.text)) return { cls: "word" };
12129
13093
  const t = word.text;
12130
13094
  if (ISSUE.test(t)) return { cls: "issue", value: t };
12131
- if (TIMESTAMP.test(t)) return { cls: "timestamp", value: t };
13095
+ if (TIMESTAMP2.test(t)) return { cls: "timestamp", value: t };
12132
13096
  if (/^[0-9a-fA-F]{7,40}$/.test(t) && /[0-9]/.test(t) && /[a-fA-F]/.test(t)) {
12133
13097
  return { cls: "sha", value: t.toLowerCase() };
12134
13098
  }
@@ -12156,7 +13120,7 @@ var Clause = class {
12156
13120
  }
12157
13121
  // One of the phrases starts at k.
12158
13122
  phraseAt(k, list) {
12159
- return list.some((phrase) => phrase.every((w, n) => this.lower(k + n) === w));
13123
+ return list.some((phrase2) => phrase2.every((w, n) => this.lower(k + n) === w));
12160
13124
  }
12161
13125
  // One of the phrases starts at or after `from` and before `to`.
12162
13126
  holds(list, from = 0, to = this.words.length) {
@@ -12193,7 +13157,7 @@ var Clause = class {
12193
13157
  // An analogue phrase starts at k and ends before j.
12194
13158
  analogue(k, j) {
12195
13159
  return ANALOGUES.some(
12196
- (phrase) => k + phrase.length <= j && phrase.every((w, n) => this.lower(k + n) === w)
13160
+ (phrase2) => k + phrase2.length <= j && phrase2.every((w, n) => this.lower(k + n) === w)
12197
13161
  );
12198
13162
  }
12199
13163
  // The nearest verb to the left of j, if no negation, modal or infinitive stands between, and it
@@ -13071,7 +14035,7 @@ function lookup(type, evidence) {
13071
14035
  }
13072
14036
  return { entries, duplicates };
13073
14037
  }
13074
- var ISSUE_REF2 = /^(?:[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100})?#[1-9][0-9]{0,9}$/;
14038
+ var ISSUE_REF3 = /^(?:[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100})?#[1-9][0-9]{0,9}$/;
13075
14039
  function combinedCandidates(typed, floor) {
13076
14040
  const exact = typed.filter((c) => c.score === null);
13077
14041
  return exact.length > 0 ? exact : typed.filter((c) => c.score !== null && c.score >= floor);
@@ -13145,7 +14109,7 @@ function checkReaderClaim(claim2, evidence, repository) {
13145
14109
  return unverifiable2("no_comparable_record_field");
13146
14110
  }
13147
14111
  if (claim2.kind === "reference_closes") {
13148
- if (!ISSUE_REF2.test(declared)) return unverifiable2("no_comparable_record_field");
14112
+ if (!ISSUE_REF3.test(declared)) return unverifiable2("no_comparable_record_field");
13149
14113
  const head = evidence.pullRequest.headSha;
13150
14114
  const row2 = checkReferences(closesBlock(declared, head), evidence, repository)[0];
13151
14115
  if (row2 === void 0) return unverifiable2("no_comparable_record_field");
@@ -13188,7 +14152,7 @@ function checkReaderClaim(claim2, evidence, repository) {
13188
14152
  case "timeline_event": {
13189
14153
  const ref = declared;
13190
14154
  const events = observed;
13191
- const match = ISSUE_REF2.test(ref) ? events.find((e) => e.ref !== void 0 && sameIssue(qualifyIssue(ref, repository), e.ref)) : void 0;
14155
+ const match = ISSUE_REF3.test(ref) ? events.find((e) => e.ref !== void 0 && sameIssue(qualifyIssue(ref, repository), e.ref)) : void 0;
13192
14156
  return match === void 0 ? unverifiable2("declared_item_not_among_candidates") : verdictOf("pass", { event: match.event, ref: match.ref ?? null });
13193
14157
  }
13194
14158
  case "check_run": {
@@ -13277,7 +14241,7 @@ function claimsFor(block, evidence, repository) {
13277
14241
  function statementSubjects(block, subject) {
13278
14242
  const subjects = [
13279
14243
  {
13280
- name: `git+https://github.com/${subject.repository}@${subject.headSha}`,
14244
+ name: `git+https://${subject.forge?.host ?? "github.com"}/${subject.repository}@${subject.headSha}`,
13281
14245
  digest: { gitCommit: subject.headSha }
13282
14246
  }
13283
14247
  ];
@@ -13301,6 +14265,7 @@ function buildRecord(input2) {
13301
14265
  headSha: pr.headSha,
13302
14266
  mergeSha: pr.mergeSha
13303
14267
  };
14268
+ if (input2.forge !== void 0) subject.forge = { kind: input2.forge.kind, host: input2.forge.host };
13304
14269
  const claims = claimsFor(block, evidence, repository);
13305
14270
  const readerClaims = input2.readerClaims?.map((c) => readReaderClaim(c, evidence, repository));
13306
14271
  const advisory = input2.advisory === void 0 ? void 0 : advisorySection(input2.advisory, evidence, repository, input2.pathsAtHead ?? []);
@@ -13325,6 +14290,13 @@ function buildRecord(input2) {
13325
14290
  assurance: input2.assurance ?? { status: "unsigned", issuer: "self-generated" }
13326
14291
  }
13327
14292
  };
14293
+ if (input2.forge !== void 0) {
14294
+ if (readerClaims !== void 0 || advisory !== void 0) {
14295
+ throw new Error("a GitLab record carries no reader claims or advisories in this draft");
14296
+ }
14297
+ record2.predicateType = GITLAB_DRAFT_PREDICATE_TYPE;
14298
+ record2.predicate.spec = GITLAB_DRAFT_SPEC_VERSION;
14299
+ }
13328
14300
  if (readerClaims !== void 0 || advisory !== void 0) {
13329
14301
  record2.predicateType = DRAFT_PREDICATE_TYPE;
13330
14302
  record2.predicate.spec = DRAFT_SPEC_VERSION;
@@ -13353,6 +14325,16 @@ function githubReaders(client) {
13353
14325
  restart: () => client.sources.clear()
13354
14326
  };
13355
14327
  }
14328
+ function gitlabReaders(client) {
14329
+ return {
14330
+ pullRequest: (project, iid, options) => readMergeRequest(client, project, iid, options),
14331
+ evidence: (input2) => readGitLabEvidence(client, input2),
14332
+ pathObjects: async () => {
14333
+ throw new Error("the advisory is not read from GitLab in this draft");
14334
+ },
14335
+ restart: () => client.sources.clear()
14336
+ };
14337
+ }
13356
14338
  async function atOnePullRequest(readers, repository, number4, first, collect) {
13357
14339
  let pr = first ?? await readers.pullRequest(repository, number4);
13358
14340
  for (let run = 1; ; run++) {
@@ -13398,7 +14380,8 @@ async function checkAt(input2, pr) {
13398
14380
  evidence,
13399
14381
  rerun: rerunCommands(input2.recordFile),
13400
14382
  ...input2.assurance === void 0 ? {} : { assurance: input2.assurance },
13401
- ...proposed === void 0 ? {} : { advisory: proposed, pathsAtHead }
14383
+ ...proposed === void 0 ? {} : { advisory: proposed, pathsAtHead },
14384
+ ...input2.forge === void 0 ? {} : { forge: input2.forge }
13402
14385
  });
13403
14386
  }
13404
14387
 
@@ -13484,10 +14467,14 @@ function line(text2) {
13484
14467
  return () => text2;
13485
14468
  }
13486
14469
  var decided = line(NO_REMEDY);
14470
+ function worded(key, text2) {
14471
+ return ({ caller }) => text2(phrase(caller, key));
14472
+ }
13487
14473
  var REMEDIES = {
13488
14474
  // The block (spec section 5). No claim row carries these; the survey counts them per pull request.
13489
- block_missing: line(
13490
- "The report has no dunstan-handback block: declare the claims in one (dunstan template writes it)."
14475
+ block_missing: worded(
14476
+ "templateWrites",
14477
+ (writes) => `The report has no dunstan-handback block: declare the claims in one (${writes}).`
13491
14478
  ),
13492
14479
  block_ambiguous: line("The report has more than one dunstan-handback block: keep exactly one."),
13493
14480
  block_invalid: line(
@@ -13495,14 +14482,20 @@ var REMEDIES = {
13495
14482
  ),
13496
14483
  // Unverifiable, a tests row: the record holds no count to compare, or could not read it. These are
13497
14484
  // TESTS_REMEDY_REASONS, the lines the claims table prints under a tests row.
13498
- record_not_found: line(
13499
- `no run of the named workflow at the head uploaded that artifact from that job. Publish the JUnit record from CI (${RECIPE}), then check that the record's workflow, job, artifact and path are the ones the run used: dunstan template --tests-ci fills them.`
14485
+ record_not_found: worded(
14486
+ "templateCiFills",
14487
+ (fills) => `no run of the named workflow at the head uploaded that artifact from that job. Publish the JUnit record from CI (${RECIPE}), then check that the record's workflow, job, artifact and path are the ones the run used: ${fills}.`
13500
14488
  ),
13501
14489
  no_comparable_record_field: line(
13502
14490
  `this record's kind has nothing Dunstan can compare. Name a junit record a CI run uploaded (${RECIPE}); for a run that left no record in CI, declare {"kind": "local"}.`
13503
14491
  ),
13504
- local_run_not_in_record: line(
13505
- `these counts are from a local run. Publish the JUnit record from CI (${RECIPE}), and run dunstan template --tests-ci after CI has finished on your last push.`
14492
+ local_run_not_in_record: worded(
14493
+ "templateCiAfter",
14494
+ (after) => `these counts are from a local run. Publish the JUnit record from CI (${RECIPE}), and ${after}.`
14495
+ ),
14496
+ // Spec 0.1.10: the pull request changed the workflow that wrote the record.
14497
+ record_producer_changed_by_pr: line(
14498
+ "This pull request changes the workflow that produced the test record, so the record is its own evidence. Land the workflow change separately, then declare the counts."
13506
14499
  ),
13507
14500
  "source_unreadable:artifact": ({ hasToken }) => hasToken ? "the run uploaded the record, but it could not be read: it expired, was uploaded outside the latest attempt of the named job, the job had not completed, or the file is not well-formed JUnit. Re-run the job to upload it again, or check again once the job completes." : "GitHub answers an artifact download only with a token (401 without one, even on a public repository). Check again with GITHUB_TOKEN set to a token that can read the repository, with actions: read.",
13508
14501
  // Unverifiable, any other row: the record could not settle the claim.
@@ -13558,6 +14551,7 @@ var TESTS_REMEDY_REASONS = [
13558
14551
  "record_not_found",
13559
14552
  "no_comparable_record_field",
13560
14553
  "local_run_not_in_record",
14554
+ "record_producer_changed_by_pr",
13561
14555
  "source_unreadable:artifact"
13562
14556
  ];
13563
14557
  function remedyForReason(reason, context) {
@@ -13674,7 +14668,7 @@ async function signingAssurance(signKey, signer) {
13674
14668
  return { status: "signed", issuer: signer, keyFingerprint: keyFingerprint(signKey) };
13675
14669
  }
13676
14670
  function defaultRecordFile(repository, number4) {
13677
- return `dunstan-${repository.replace("/", "-")}-${number4}.json`;
14671
+ return `dunstan-${repository.replaceAll("/", "-")}-${number4}.json`;
13678
14672
  }
13679
14673
  async function check(args, io) {
13680
14674
  const { values } = parseArgs({
@@ -13691,9 +14685,17 @@ async function check(args, io) {
13691
14685
  "sign-key": { type: "string" },
13692
14686
  signer: { type: "string" },
13693
14687
  "exclude-check-run": { type: "string", multiple: true },
13694
- advisory: { type: "boolean" }
14688
+ "exclude-gitlab-job": { type: "string", multiple: true },
14689
+ advisory: { type: "boolean" },
14690
+ "gitlab-project": { type: "string" },
14691
+ mr: { type: "string" },
14692
+ "gitlab-host": { type: "string" },
14693
+ "report-mr-description": { type: "boolean" }
13695
14694
  }
13696
14695
  });
14696
+ if (values["gitlab-project"] !== void 0 || values.mr !== void 0 || values["gitlab-host"] !== void 0 || values["report-mr-description"] === true || values["exclude-gitlab-job"] !== void 0) {
14697
+ return checkGitLab(values, io);
14698
+ }
13697
14699
  if (values.repo === void 0 || !REPOSITORY.test(values.repo)) {
13698
14700
  throw new UsageError("--repo <owner/name> is required");
13699
14701
  }
@@ -13739,7 +14741,75 @@ async function check(args, io) {
13739
14741
  advisory: values.advisory === true
13740
14742
  });
13741
14743
  writeFileSync(out, serializeRecord(record2));
13742
- io.out(renderRecord(record2, { hasToken: client.hasToken }));
14744
+ io.out(renderRecord(record2, { hasToken: client.hasToken, caller: "cli" }));
14745
+ io.out(`record: ${out}
14746
+ `);
14747
+ if (assurance !== void 0) {
14748
+ io.out(`signature: ${signRecordFile(out, values["sign-key"])}
14749
+ `);
14750
+ }
14751
+ return exitFor(record2.predicate.verdict);
14752
+ }
14753
+ async function checkGitLab(values, io) {
14754
+ const refused = [
14755
+ ["--repo", values.repo !== void 0],
14756
+ ["--pr", values.pr !== void 0],
14757
+ ["--report-pr-body (use --report-mr-description)", values["report-pr-body"] === true],
14758
+ ["--report-comment-author", values["report-comment-author"] !== void 0],
14759
+ ["--exclude-check-run", values["exclude-check-run"] !== void 0],
14760
+ ["--advisory", values.advisory === true]
14761
+ ];
14762
+ const named = refused.filter(([, given]) => given).map(([flag]) => flag);
14763
+ if (named.length > 0) {
14764
+ throw new UsageError(`a GitLab merge request is checked without ${named.join(", ")}`);
14765
+ }
14766
+ const project = values["gitlab-project"];
14767
+ if (project === void 0 || !GITLAB_PROJECT.test(project)) {
14768
+ throw new UsageError(
14769
+ "--gitlab-project <namespace/project> is required: the project's full path, group/subgroup/.../project"
14770
+ );
14771
+ }
14772
+ if (values.mr === void 0) throw new UsageError("--mr <iid> is required");
14773
+ const iid = parsePositiveInt(values.mr, "--mr");
14774
+ const host = values["gitlab-host"] ?? DEFAULT_GITLAB_HOST;
14775
+ if (!GITLAB_HOST.test(host)) {
14776
+ throw new UsageError("--gitlab-host is a lowercase host name, such as gitlab.example.com");
14777
+ }
14778
+ const file2 = values["report-file"];
14779
+ if (file2 !== void 0 === (values["report-mr-description"] === true)) {
14780
+ throw new UsageError(
14781
+ "name exactly one report: --report-file <path|-> or --report-mr-description"
14782
+ );
14783
+ }
14784
+ const excluded = (values["exclude-gitlab-job"] ?? []).map(
14785
+ (v) => parsePositiveInt(v, "--exclude-gitlab-job")
14786
+ );
14787
+ const assurance = await signingAssurance(values["sign-key"], values.signer);
14788
+ const client = new GitLabClient({
14789
+ host,
14790
+ token: io.env.GITLAB_TOKEN,
14791
+ ...io.fetch === void 0 ? {} : { fetch: io.fetch },
14792
+ warn: (message) => io.err(`dunstan: ${message}
14793
+ `)
14794
+ });
14795
+ if (!client.hasToken) io.err("dunstan: GITLAB_TOKEN is not set; reading anonymously\n");
14796
+ const out = values.out ?? defaultRecordFile(project, iid);
14797
+ const record2 = await checkPullRequest({
14798
+ readers: gitlabReaders(client),
14799
+ repository: project,
14800
+ number: iid,
14801
+ report: async (pr) => {
14802
+ if (file2 === void 0) return reportFromMergeRequestDescription(pr);
14803
+ return file2 === "-" ? { bytes: io.readStdin(), source: { kind: "stdin", locator: "stdin" } } : { bytes: readFileSync3(file2), source: { kind: "file", locator: file2 } };
14804
+ },
14805
+ excludedCheckRunIds: excluded,
14806
+ checker: checkerIdentity(io.artifact),
14807
+ recordFile: basename(out),
14808
+ ...assurance === void 0 ? {} : { assurance },
14809
+ forge: { kind: "gitlab", host }
14810
+ });
14811
+ writeFileSync(out, serializeRecord(record2));
14812
+ io.out(renderRecord(record2, { hasToken: client.hasToken, caller: "cli" }));
13743
14813
  io.out(`record: ${out}
13744
14814
  `);
13745
14815
  if (assurance !== void 0) {
@@ -13991,7 +15061,7 @@ __export(external_exports, {
13991
15061
  includes: () => _includes,
13992
15062
  input: () => input,
13993
15063
  instanceof: () => _instanceof,
13994
- int: () => int2,
15064
+ int: () => int3,
13995
15065
  int32: () => int32,
13996
15066
  int64: () => int64,
13997
15067
  intersection: () => intersection,
@@ -14585,8 +15655,8 @@ function defineLazy(object3, key, getter) {
14585
15655
  configurable: true
14586
15656
  });
14587
15657
  }
14588
- function objectClone(obj2) {
14589
- return Object.create(Object.getPrototypeOf(obj2), Object.getOwnPropertyDescriptors(obj2));
15658
+ function objectClone(obj3) {
15659
+ return Object.create(Object.getPrototypeOf(obj3), Object.getOwnPropertyDescriptors(obj3));
14590
15660
  }
14591
15661
  function assignProp(target, prop, value) {
14592
15662
  Object.defineProperty(target, prop, {
@@ -14657,10 +15727,10 @@ function mergeDefs(...defs) {
14657
15727
  function cloneDef(schema) {
14658
15728
  return mergeDefs(schema._zod.def);
14659
15729
  }
14660
- function getElementAtPath(obj2, path) {
15730
+ function getElementAtPath(obj3, path) {
14661
15731
  if (!path)
14662
- return obj2;
14663
- return path.reduce((acc, key) => acc?.[key], obj2);
15732
+ return obj3;
15733
+ return path.reduce((acc, key) => acc?.[key], obj3);
14664
15734
  }
14665
15735
  function promiseAllObject(promisesObj) {
14666
15736
  const keys = Object.keys(promisesObj);
@@ -14675,14 +15745,14 @@ function promiseAllObject(promisesObj) {
14675
15745
  }
14676
15746
  function randomString(length = 10) {
14677
15747
  const chars = "abcdefghijklmnopqrstuvwxyz";
14678
- let str3 = "";
15748
+ let str4 = "";
14679
15749
  for (let i = 0; i < length; i++) {
14680
- str3 += chars[Math.floor(Math.random() * chars.length)];
15750
+ str4 += chars[Math.floor(Math.random() * chars.length)];
14681
15751
  }
14682
- return str3;
15752
+ return str4;
14683
15753
  }
14684
- function esc(str3) {
14685
- return JSON.stringify(str3);
15754
+ function esc(str4) {
15755
+ return JSON.stringify(str4);
14686
15756
  }
14687
15757
  function slugify(input2) {
14688
15758
  return input2.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
@@ -14796,8 +15866,8 @@ var primitiveTypes = /* @__PURE__ */ new Set([
14796
15866
  "symbol",
14797
15867
  "undefined"
14798
15868
  ]);
14799
- function escapeRegex(str3) {
14800
- return str3.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15869
+ function escapeRegex(str4) {
15870
+ return str4.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14801
15871
  }
14802
15872
  function clone(inst, def, params) {
14803
15873
  const cl = new inst._zod.constr(def ?? inst._zod.def);
@@ -15051,13 +16121,13 @@ function getSizableOrigin(input2) {
15051
16121
  return "unknown";
15052
16122
  }
15053
16123
  var highSurrogate = /[\uD800-\uDBFF]/;
15054
- function codePointLength(str3) {
15055
- const units = str3.length;
15056
- if (!highSurrogate.test(str3))
16124
+ function codePointLength(str4) {
16125
+ const units = str4.length;
16126
+ if (!highSurrogate.test(str4))
15057
16127
  return units;
15058
16128
  let count = units;
15059
16129
  for (let i = 0; i < units - 1; i++) {
15060
- if ((str3.charCodeAt(i) & 64512) === 55296 && (str3.charCodeAt(i + 1) & 64512) === 56320) {
16130
+ if ((str4.charCodeAt(i) & 64512) === 55296 && (str4.charCodeAt(i + 1) & 64512) === 56320) {
15061
16131
  count--;
15062
16132
  i++;
15063
16133
  }
@@ -15084,9 +16154,9 @@ function parsedType(data) {
15084
16154
  if (Array.isArray(data)) {
15085
16155
  return "array";
15086
16156
  }
15087
- const obj2 = data;
15088
- if (obj2 && Object.getPrototypeOf(obj2) !== Object.prototype && "constructor" in obj2 && obj2.constructor) {
15089
- return obj2.constructor.name;
16157
+ const obj3 = data;
16158
+ if (obj3 && Object.getPrototypeOf(obj3) !== Object.prototype && "constructor" in obj3 && obj3.constructor) {
16159
+ return obj3.constructor.name;
15090
16160
  }
15091
16161
  }
15092
16162
  }
@@ -15104,8 +16174,8 @@ function issue(...args) {
15104
16174
  }
15105
16175
  return { ...iss };
15106
16176
  }
15107
- function cleanEnum(obj2) {
15108
- return Object.entries(obj2).filter(([k, _]) => {
16177
+ function cleanEnum(obj3) {
16178
+ return Object.entries(obj3).filter(([k, _]) => {
15109
16179
  return Number.isNaN(Number.parseInt(k, 10));
15110
16180
  }).map((el) => el[1]);
15111
16181
  }
@@ -15430,15 +16500,15 @@ var $ZodError = $constructor("$ZodError", initializer);
15430
16500
  var $ZodRealError = $constructor("$ZodError", initializer, void 0, {
15431
16501
  Parent: Error
15432
16502
  });
15433
- function node(obj2, key, make) {
15434
- if (!Object.prototype.hasOwnProperty.call(obj2, key)) {
16503
+ function node(obj3, key, make) {
16504
+ if (!Object.prototype.hasOwnProperty.call(obj3, key)) {
15435
16505
  if (key === "__proto__") {
15436
- Object.defineProperty(obj2, key, { value: make(), writable: true, enumerable: true, configurable: true });
16506
+ Object.defineProperty(obj3, key, { value: make(), writable: true, enumerable: true, configurable: true });
15437
16507
  } else {
15438
- obj2[key] = make();
16508
+ obj3[key] = make();
15439
16509
  }
15440
16510
  }
15441
- return obj2[key];
16511
+ return obj3[key];
15442
16512
  }
15443
16513
  function flattenError(error62, mapper = (issue2) => issue2.message) {
15444
16514
  const fieldErrors = {};
@@ -30879,7 +31949,7 @@ __export(schemas_exports2, {
30879
31949
  httpUrl: () => httpUrl,
30880
31950
  iban: () => iban2,
30881
31951
  instanceof: () => _instanceof,
30882
- int: () => int2,
31952
+ int: () => int3,
30883
31953
  int32: () => int32,
30884
31954
  int64: () => int64,
30885
31955
  intersection: () => intersection,
@@ -31623,10 +32693,10 @@ var ZodNumber = /* @__PURE__ */ $constructor(
31623
32693
  return this.check(_lte(value, params));
31624
32694
  },
31625
32695
  int(params) {
31626
- return this.check(int2(params));
32696
+ return this.check(int3(params));
31627
32697
  },
31628
32698
  safe(params) {
31629
- return this.check(int2(params));
32699
+ return this.check(int3(params));
31630
32700
  },
31631
32701
  positive(params) {
31632
32702
  return this.check(_gt(0, params));
@@ -31658,7 +32728,7 @@ var ZodNumberFormat = /* @__PURE__ */ $constructor("ZodNumberFormat", (inst, def
31658
32728
  $ZodNumberFormat.init(inst, def);
31659
32729
  ZodNumber.init(inst, def);
31660
32730
  });
31661
- function int2(params) {
32731
+ function int3(params) {
31662
32732
  return _int(ZodNumberFormat, params);
31663
32733
  }
31664
32734
  function float32(params) {
@@ -35146,7 +36216,7 @@ var StdioServerTransport = class {
35146
36216
 
35147
36217
  // src/mcp/server.ts
35148
36218
  import { existsSync, writeFileSync as writeFileSync3 } from "node:fs";
35149
- import { basename as basename2, resolve as resolve3 } from "node:path";
36219
+ import { basename as basename2, isAbsolute, resolve as resolve3 } from "node:path";
35150
36220
 
35151
36221
  // node_modules/.pnpm/zod@4.6.5/node_modules/zod/v3/helpers/util.js
35152
36222
  var util;
@@ -35161,26 +36231,26 @@ var util;
35161
36231
  }
35162
36232
  util2.assertNever = assertNever2;
35163
36233
  util2.arrayToEnum = (items) => {
35164
- const obj2 = {};
36234
+ const obj3 = {};
35165
36235
  for (const item of items) {
35166
- obj2[item] = item;
36236
+ obj3[item] = item;
35167
36237
  }
35168
- return obj2;
36238
+ return obj3;
35169
36239
  };
35170
- util2.getValidEnumValues = (obj2) => {
35171
- const validKeys = util2.objectKeys(obj2).filter((k) => typeof obj2[obj2[k]] !== "number");
36240
+ util2.getValidEnumValues = (obj3) => {
36241
+ const validKeys = util2.objectKeys(obj3).filter((k) => typeof obj3[obj3[k]] !== "number");
35172
36242
  const filtered = {};
35173
36243
  for (const k of validKeys) {
35174
- filtered[k] = obj2[k];
36244
+ filtered[k] = obj3[k];
35175
36245
  }
35176
36246
  return util2.objectValues(filtered);
35177
36247
  };
35178
- util2.objectValues = (obj2) => {
35179
- return util2.objectKeys(obj2).map(function(e) {
35180
- return obj2[e];
36248
+ util2.objectValues = (obj3) => {
36249
+ return util2.objectKeys(obj3).map(function(e) {
36250
+ return obj3[e];
35181
36251
  });
35182
36252
  };
35183
- util2.objectKeys = typeof Object.keys === "function" ? (obj2) => Object.keys(obj2) : (object3) => {
36253
+ util2.objectKeys = typeof Object.keys === "function" ? (obj3) => Object.keys(obj3) : (object3) => {
35184
36254
  const keys = [];
35185
36255
  for (const key in object3) {
35186
36256
  if (Object.prototype.hasOwnProperty.call(object3, key)) {
@@ -35189,8 +36259,8 @@ var util;
35189
36259
  }
35190
36260
  return keys;
35191
36261
  };
35192
- util2.find = (arr2, checker) => {
35193
- for (const item of arr2) {
36262
+ util2.find = (arr3, checker) => {
36263
+ for (const item of arr3) {
35194
36264
  if (checker(item))
35195
36265
  return item;
35196
36266
  }
@@ -35210,10 +36280,10 @@ var util;
35210
36280
  })(util || (util = {}));
35211
36281
  var objectUtil;
35212
36282
  (function(objectUtil2) {
35213
- objectUtil2.mergeShapes = (first, second2) => {
36283
+ objectUtil2.mergeShapes = (first, second3) => {
35214
36284
  return {
35215
36285
  ...first,
35216
- ...second2
36286
+ ...second3
35217
36287
  // second overwrites first
35218
36288
  };
35219
36289
  };
@@ -38145,11 +39215,11 @@ var ZodRecord2 = class _ZodRecord extends ZodType2 {
38145
39215
  get element() {
38146
39216
  return this._def.valueType;
38147
39217
  }
38148
- static create(first, second2, third) {
38149
- if (second2 instanceof ZodType2) {
39218
+ static create(first, second3, third) {
39219
+ if (second3 instanceof ZodType2) {
38150
39220
  return new _ZodRecord({
38151
39221
  keyType: first,
38152
- valueType: second2,
39222
+ valueType: second3,
38153
39223
  typeName: ZodFirstPartyTypeKind2.ZodRecord,
38154
39224
  ...processCreateParams(third)
38155
39225
  });
@@ -38158,7 +39228,7 @@ var ZodRecord2 = class _ZodRecord extends ZodType2 {
38158
39228
  keyType: ZodString2.create(),
38159
39229
  valueType: first,
38160
39230
  typeName: ZodFirstPartyTypeKind2.ZodRecord,
38161
- ...processCreateParams(second2)
39231
+ ...processCreateParams(second3)
38162
39232
  });
38163
39233
  }
38164
39234
  };
@@ -42307,7 +43377,7 @@ function validateToolName(name) {
42307
43377
  warnings.push("Tool name starts or ends with a dot, which may cause parsing issues in some contexts");
42308
43378
  }
42309
43379
  if (!TOOL_NAME_REGEX.test(name)) {
42310
- const invalidChars = name.split("").filter((char) => !/[A-Za-z0-9._-]/.test(char)).filter((char, index, arr2) => arr2.indexOf(char) === index);
43380
+ const invalidChars = name.split("").filter((char) => !/[A-Za-z0-9._-]/.test(char)).filter((char, index, arr3) => arr3.indexOf(char) === index);
42311
43381
  warnings.push(`Tool name contains invalid characters: ${invalidChars.map((c) => `"${c}"`).join(", ")}`, "Allowed characters are: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)");
42312
43382
  return {
42313
43383
  isValid: false,
@@ -42448,8 +43518,8 @@ var McpServer = class {
42448
43518
  title: tool.title,
42449
43519
  description: tool.description,
42450
43520
  inputSchema: (() => {
42451
- const obj2 = normalizeObjectSchema(tool.inputSchema);
42452
- return obj2 ? toJsonSchemaCompat(obj2, {
43521
+ const obj3 = normalizeObjectSchema(tool.inputSchema);
43522
+ return obj3 ? toJsonSchemaCompat(obj3, {
42453
43523
  strictUnions: true,
42454
43524
  pipeStrategy: "input"
42455
43525
  }) : EMPTY_OBJECT_JSON_SCHEMA;
@@ -42459,9 +43529,9 @@ var McpServer = class {
42459
43529
  _meta: tool._meta
42460
43530
  };
42461
43531
  if (tool.outputSchema) {
42462
- const obj2 = normalizeObjectSchema(tool.outputSchema);
42463
- if (obj2) {
42464
- toolDefinition.outputSchema = toJsonSchemaCompat(obj2, {
43532
+ const obj3 = normalizeObjectSchema(tool.outputSchema);
43533
+ if (obj3) {
43534
+ toolDefinition.outputSchema = toJsonSchemaCompat(obj3, {
42465
43535
  strictUnions: true,
42466
43536
  pipeStrategy: "output"
42467
43537
  });
@@ -43114,20 +44184,20 @@ var EMPTY_OBJECT_JSON_SCHEMA = {
43114
44184
  function isZodTypeLike(value) {
43115
44185
  return value !== null && typeof value === "object" && "parse" in value && typeof value.parse === "function" && "safeParse" in value && typeof value.safeParse === "function";
43116
44186
  }
43117
- function isZodSchemaInstance(obj2) {
43118
- return "_def" in obj2 || "_zod" in obj2 || isZodTypeLike(obj2);
44187
+ function isZodSchemaInstance(obj3) {
44188
+ return "_def" in obj3 || "_zod" in obj3 || isZodTypeLike(obj3);
43119
44189
  }
43120
- function isZodRawShapeCompat(obj2) {
43121
- if (typeof obj2 !== "object" || obj2 === null) {
44190
+ function isZodRawShapeCompat(obj3) {
44191
+ if (typeof obj3 !== "object" || obj3 === null) {
43122
44192
  return false;
43123
44193
  }
43124
- if (isZodSchemaInstance(obj2)) {
44194
+ if (isZodSchemaInstance(obj3)) {
43125
44195
  return false;
43126
44196
  }
43127
- if (Object.keys(obj2).length === 0) {
44197
+ if (Object.keys(obj3).length === 0) {
43128
44198
  return true;
43129
44199
  }
43130
- return Object.values(obj2).some(isZodTypeLike);
44200
+ return Object.values(obj3).some(isZodTypeLike);
43131
44201
  }
43132
44202
  function getZodSchemaObject(schema) {
43133
44203
  if (!schema) {
@@ -43335,14 +44405,149 @@ function suggestText(report) {
43335
44405
  return renderSuggestions(suggestDeclarations(report));
43336
44406
  }
43337
44407
 
44408
+ // src/template/tests-ci.ts
44409
+ var plural2 = (n, one, many) => `${n} ${n === 1 ? one : many}`;
44410
+ function unsettled(reason, why, remedy) {
44411
+ const notes = [`tests are not declared: ${why} (${reason}).`];
44412
+ if (remedy !== void 0) notes.push(`remedy: ${remedy}`);
44413
+ return { status: "unsettled", reason, notes };
44414
+ }
44415
+ async function testsFromCi(client, input2) {
44416
+ const { repository, headCommit: head, record: record2, caller } = input2;
44417
+ const againCi = phrase(caller, "againCi");
44418
+ if (!client.hasToken) {
44419
+ return unsettled(
44420
+ "no_token",
44421
+ "GITHUB_TOKEN is not set, and GitHub answers an artifact download only with a token, even on a public repository",
44422
+ `set GITHUB_TOKEN to a token that can read the repository, with actions: read, and ${againCi}.`
44423
+ );
44424
+ }
44425
+ if (!input2.pushed) {
44426
+ return unsettled(
44427
+ "not_pushed",
44428
+ `HEAD ${head} is not on any remote-tracking branch, so no CI run can have tested it`,
44429
+ `push it, wait for CI to finish, and ${againCi} (run git fetch first if it was pushed from elsewhere).`
44430
+ );
44431
+ }
44432
+ const observed = { runsNotCompleted: 0 };
44433
+ const evidence = await readTestRecord(client, repository, record2, head, observed);
44434
+ const named = `the artifact ${record2.artifact} holding ${record2.path} from job ${record2.job}`;
44435
+ const pending = observed.runsNotCompleted > 0 ? `CI has not finished: ${plural2(observed.runsNotCompleted, "run", "runs")} of ${record2.workflow} at ${head} ${observed.runsNotCompleted === 1 ? "is" : "are"} not completed` : void 0;
44436
+ const again = `${againCi} once CI has finished on this commit.`;
44437
+ if (evidence.status !== "ok") {
44438
+ const reason = evidence.status === "unreadable" ? `source_unreadable:${evidence.source}` : `evidence_field_unpopulated:${evidence.field}`;
44439
+ if (pending !== void 0) return unsettled(reason, pending, again);
44440
+ const why = reason === "source_unreadable:artifact" ? `a run of ${record2.workflow} at ${head} uploaded ${record2.artifact}, and it could not be read` : `the workflow runs of ${record2.workflow} at ${head} could not be read`;
44441
+ return unsettled(
44442
+ reason,
44443
+ why,
44444
+ remedyForReason(reason, { hasToken: true, caller }) ?? `${againCi}.`
44445
+ );
44446
+ }
44447
+ if (evidence.runs.length === 0) {
44448
+ if (pending !== void 0) {
44449
+ return unsettled(
44450
+ "record_not_found",
44451
+ `${pending}, and no completed run uploaded ${named}`,
44452
+ again
44453
+ );
44454
+ }
44455
+ return unsettled(
44456
+ "record_not_found",
44457
+ `no run of ${record2.workflow} at ${head} uploaded ${named}`,
44458
+ remedyForReason("record_not_found", { hasToken: true, caller })
44459
+ );
44460
+ }
44461
+ const executed = new Set(evidence.runs.map((r) => r.executed));
44462
+ const failed = new Set(evidence.runs.map((r) => r.failed));
44463
+ if (executed.size > 1 || failed.size > 1) {
44464
+ const counts = evidence.runs.map((r) => `run ${r.runId}: ${r.executed} executed, ${r.failed} failed`).join("; ");
44465
+ return unsettled(
44466
+ "record_ambiguous",
44467
+ `the runs of ${record2.workflow} at ${head} that uploaded ${record2.artifact} disagree (${counts}), and the gate never picks one`,
44468
+ "make the runs agree (re-run the one that differs), or name a job only one of them runs."
44469
+ );
44470
+ }
44471
+ const run = evidence.runs[0];
44472
+ const runs = evidence.runs.map((r) => r.runId).join(", ");
44473
+ const notes = [
44474
+ `tests: ${run.executed} executed and ${run.failed} failed in ${record2.path} in the artifact ${record2.artifact} of job ${record2.job} in ${record2.workflow} at ${head}, read from CI as the gate reads it (${evidence.runs.length === 1 ? "run" : "runs"} ${runs}).`
44475
+ ];
44476
+ if (pending !== void 0) {
44477
+ notes.push(
44478
+ `${pending}; a run that finishes later with other counts makes the gate's read ambiguous. ${phrase(caller, "againCiSentence")} once CI has finished.`
44479
+ );
44480
+ }
44481
+ return {
44482
+ status: "settled",
44483
+ run: { command: input2.command, count: run.executed, failures: run.failed, record: record2 },
44484
+ notes
44485
+ };
44486
+ }
44487
+
44488
+ // src/template/run.ts
44489
+ var DEFAULT_ARTIFACT = "dunstan-junit";
44490
+ var DEFAULT_ARTIFACT_PATH = "junit.xml";
44491
+ async function testsSource(request, context) {
44492
+ const { cwd, tests } = request;
44493
+ if (tests === void 0) return void 0;
44494
+ if (tests.from === "local") return { kind: "local", junit: tests.junit, command: tests.command };
44495
+ if (tests.from === "junit") {
44496
+ return {
44497
+ kind: "junit",
44498
+ junit: tests.junit,
44499
+ command: tests.command,
44500
+ record: {
44501
+ workflow: tests.workflow,
44502
+ job: tests.job,
44503
+ artifact: tests.artifact,
44504
+ path: tests.artifactPath
44505
+ }
44506
+ };
44507
+ }
44508
+ const head = headCommit(cwd);
44509
+ const repository = tests.repository ?? originRepository(cwd, request.caller);
44510
+ const client = new GitHubClient({
44511
+ token: context.env.GITHUB_TOKEN,
44512
+ ...context.fetch === void 0 ? {} : { fetch: context.fetch },
44513
+ warn: context.warn
44514
+ });
44515
+ const read = await testsFromCi(client, {
44516
+ repository,
44517
+ headCommit: head,
44518
+ command: tests.command,
44519
+ pushed: isPushed(cwd, head),
44520
+ caller: request.caller,
44521
+ record: {
44522
+ kind: "junit",
44523
+ workflow: tests.workflow,
44524
+ job: tests.job,
44525
+ artifact: tests.artifact ?? DEFAULT_ARTIFACT,
44526
+ path: tests.artifactPath ?? DEFAULT_ARTIFACT_PATH
44527
+ }
44528
+ });
44529
+ return { kind: "ci", read };
44530
+ }
44531
+ async function runTemplate(request, context) {
44532
+ const tests = await testsSource(request, context);
44533
+ const t = templateBlock({ cwd: request.cwd, base: request.base, tests, caller: request.caller });
44534
+ return {
44535
+ fence: renderBlock(t.block),
44536
+ notes: t.notes,
44537
+ tests: tests === void 0 ? "none" : tests.kind === "ci" && tests.read.status === "unsettled" ? "not_declared" : "declared"
44538
+ };
44539
+ }
44540
+
43338
44541
  // src/mcp/server.ts
43339
44542
  var TOOL_NAME = "dunstan_check_handback";
43340
44543
  var SUGGEST_TOOL_NAME = "dunstan_suggest_declarations";
44544
+ var TEMPLATE_TOOL_NAME = "dunstan_template_handback";
43341
44545
  var InputError = class extends Error {
43342
44546
  };
43343
- var INSTRUCTIONS = `Before you say a task is done: generate your dunstan-handback block with \`dunstan template\` and add your test and reference claims; call ${SUGGEST_TOOL_NAME} with your report and declare or reword each sentence it lists; then call ${TOOL_NAME} with your pull request and the block. If the verdict is not pass, report the rows as they are; never edit the block to make it pass without changing the work. The check reads only what the block declares; the prose is yours to bring into the block.`;
44547
+ var INSTRUCTIONS = `Before you say a task is done: call ${TEMPLATE_TOOL_NAME} with the absolute path of your checkout (and tests.from "ci" once CI has finished on your last push) to generate your dunstan-handback block, or run \`dunstan template\`, the same thing from a shell; add your reference claims; call ${SUGGEST_TOOL_NAME} with your report and declare or reword each sentence it lists; then call ${TOOL_NAME} with your pull request and the block. If the verdict is not pass, report the rows as they are; never edit the block to make it pass without changing the work. The check reads only what the block declares; the prose is yours to bring into the block.`;
43344
44548
  var SUGGEST_DESCRIPTION = `List the sentences in your report's prose that look like claims your dunstan-handback block does not declare, before you say done. Give report, the report text. For each, declare it in the block, or reword it if it is not a claim about this PR. Offline: needs no pull request, reads nothing and writes nothing. Never a verdict: nothing passes or fails, and nothing is written to a record. The same text as \`dunstan suggest\`.`;
43345
44549
  var DESCRIPTION = `Check a completion handback against the repository's own record, before you say done. Give the subject (repository owner/name and pull request number) and exactly one of: block, the dunstan-handback block as a JSON object; or report, the report text that carries exactly one \`\`\`dunstan-handback fence. Of the checks claims, checks.allSucceeded is true only if every counted check run concluded success, so a skipped or neutral run makes it false; checks.noneFailed is true if none concluded other than success, skipped or neutral and at least one succeeded. Returns the verdict (pass, fail or unverifiable), the claims table, and the full record as JSON. Same checker and same verdict as \`dunstan check\` on the same evidence; no model decides. Reads GitHub metadata with GITHUB_TOKEN and writes nothing to GitHub or the repository; the record is written to a file only when you pass outPath. If the verdict is not pass, report the rows as they are; change the work, not the block.`;
44550
+ var TEMPLATE_DESCRIPTION = `Generate your dunstan-handback block from the local git checkout of your pull request's branch, after your last push: headCommit is HEAD, filesChanged is HEAD's diff against its merge base with base. Give directory, the checkout's absolute path. To declare tests, give tests.from "ci" with command, workflow and job once CI has finished on your last push: the counts are read from the JUnit record CI published, as the gate reads it, with GITHUB_TOKEN. tests.from "local" declares a local JUnit file as a local run, which the gate reports unverifiable. Returns the first line (tests: declared, tests: not declared, or tests: none asked) and the notes, then the fence, byte for byte what \`dunstan template\` prints. When the first line is tests: not declared, the block has no tests: do not add counts yourself; do what the note says. Add your references and checks yourself. Reads git and, with tests.from "ci", GitHub metadata and the JUnit artifact for its counts; writes nothing.`;
43346
44551
  function reportOfBlock(block) {
43347
44552
  const text2 = canonicalize(block);
43348
44553
  return {
@@ -43416,13 +44621,52 @@ function handbackResult(record2, outPath, notes, context) {
43416
44621
  content: [
43417
44622
  { type: "text", text: `${head.join("\n")}
43418
44623
  ` },
43419
- { type: "text", text: renderRecord(record2, context) },
44624
+ { type: "text", text: renderRecord(record2, { ...context, caller: "mcp" }) },
43420
44625
  { type: "text", text: serializeRecord(record2) }
43421
44626
  ]
43422
44627
  };
43423
44628
  }
43424
- function toolError(error62) {
43425
- const known = error62 instanceof InputError || error62 instanceof PullRequestUnreadable || error62 instanceof PullRequestMoved || error62 instanceof CanonicalizationError || typeof error62.code === "string";
44629
+ var TESTS_LINE = {
44630
+ declared: "tests: declared",
44631
+ not_declared: "tests: not declared",
44632
+ none: "tests: none asked"
44633
+ };
44634
+ async function templateHandback(input2, options) {
44635
+ if (!isAbsolute(input2.directory)) {
44636
+ throw new InputError(`directory ${input2.directory} is not an absolute path`);
44637
+ }
44638
+ const warnings = [];
44639
+ const run = await runTemplate(
44640
+ { cwd: input2.directory, base: input2.base, tests: input2.tests, caller: "mcp" },
44641
+ {
44642
+ env: options.env,
44643
+ fetch: options.fetch,
44644
+ warn: (message) => {
44645
+ warnings.push(message);
44646
+ options.warn(`dunstan: ${message}
44647
+ `);
44648
+ }
44649
+ }
44650
+ );
44651
+ const head = [TESTS_LINE[run.tests]];
44652
+ if (run.tests === "not_declared") {
44653
+ head.push("The block below has no tests. Do not add counts yourself; do what the note says.");
44654
+ }
44655
+ head.push(...[...warnings, ...run.notes].map((n) => `note: ${n}`));
44656
+ return {
44657
+ content: [
44658
+ { type: "text", text: `${head.join("\n")}
44659
+ ` },
44660
+ { type: "text", text: run.fence }
44661
+ ]
44662
+ };
44663
+ }
44664
+ var ERROR_TAIL = {
44665
+ check: "This is an error, not a verdict: nothing was checked.",
44666
+ template: "This is an error: no block was produced."
44667
+ };
44668
+ function toolError(error62, tail = "check") {
44669
+ const known = error62 instanceof InputError || error62 instanceof PullRequestUnreadable || error62 instanceof PullRequestMoved || error62 instanceof CanonicalizationError || error62 instanceof TemplateError || typeof error62.code === "string";
43426
44670
  const message = error62.message ?? String(error62);
43427
44671
  return {
43428
44672
  isError: true,
@@ -43430,12 +44674,37 @@ function toolError(error62) {
43430
44674
  {
43431
44675
  type: "text",
43432
44676
  text: `dunstan: ${known ? message : `internal error: ${message}`}
43433
- This is an error, not a verdict: nothing was checked.
44677
+ ${ERROR_TAIL[tail]}
43434
44678
  `
43435
44679
  }
43436
44680
  ]
43437
44681
  };
43438
44682
  }
44683
+ var nonEmpty = external_exports.string().min(1);
44684
+ var TEMPLATE_TESTS = external_exports.discriminatedUnion("from", [
44685
+ external_exports.object({
44686
+ from: external_exports.literal("ci"),
44687
+ command: nonEmpty.describe("The command that ran the tests, as the block declares it."),
44688
+ workflow: nonEmpty.describe(
44689
+ "The workflow file that ran them, as a path: .github/workflows/ci.yml."
44690
+ ),
44691
+ job: nonEmpty.describe("The name of the job that uploaded the JUnit artifact."),
44692
+ artifact: nonEmpty.optional().describe("The artifact holding the JUnit file. Absent: dunstan-junit."),
44693
+ artifactPath: nonEmpty.optional().describe("The JUnit file in the artifact. Absent: junit.xml."),
44694
+ repository: external_exports.string().regex(REPOSITORY, "repository must be owner/name").optional().describe("The GitHub repository, owner/name. Absent: the one the origin remote names.")
44695
+ }).strict().describe(
44696
+ "Read CI's counts at HEAD from the JUnit record CI published, as the gate reads it (dunstan template --tests-ci)."
44697
+ ),
44698
+ external_exports.object({
44699
+ from: external_exports.literal("local"),
44700
+ junit: nonEmpty.describe(
44701
+ "The local JUnit XML file; a relative path resolves against directory."
44702
+ ),
44703
+ command: nonEmpty.describe("The command that ran the tests, as the block declares it.")
44704
+ }).strict().describe(
44705
+ "Declare a local JUnit file as a local run, which the gate reports unverifiable (dunstan template --tests-local)."
44706
+ )
44707
+ ]);
43439
44708
  function createDunstanMcpServer(options) {
43440
44709
  const server = new McpServer(
43441
44710
  { name: CHECKER_NAME, version: CHECKER_VERSION },
@@ -43489,6 +44758,37 @@ function createDunstanMcpServer(options) {
43489
44758
  },
43490
44759
  async (input2) => ({ content: [{ type: "text", text: suggestText(input2.report) }] })
43491
44760
  );
44761
+ server.registerTool(
44762
+ TEMPLATE_TOOL_NAME,
44763
+ {
44764
+ title: "Generate a handback block from git",
44765
+ description: TEMPLATE_DESCRIPTION,
44766
+ inputSchema: external_exports.object({
44767
+ directory: nonEmpty.describe(
44768
+ "The absolute path of the local git checkout of the pull request's branch. Git runs there."
44769
+ ),
44770
+ base: nonEmpty.optional().describe(
44771
+ "The ref the pull request merges into, as dunstan template --base. Absent: the remote's default branch."
44772
+ ),
44773
+ tests: TEMPLATE_TESTS.optional().describe(
44774
+ "Which tests to declare. Absent: none, and the note says how."
44775
+ )
44776
+ }).strict(),
44777
+ annotations: {
44778
+ readOnlyHint: true,
44779
+ destructiveHint: false,
44780
+ idempotentHint: true,
44781
+ openWorldHint: true
44782
+ }
44783
+ },
44784
+ async (input2) => {
44785
+ try {
44786
+ return await templateHandback(input2, options);
44787
+ } catch (error62) {
44788
+ return toolError(error62, "template");
44789
+ }
44790
+ }
44791
+ );
43492
44792
  return server;
43493
44793
  }
43494
44794
 
@@ -43515,6 +44815,132 @@ async function mcp(args, io) {
43515
44815
  import { existsSync as existsSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
43516
44816
  import { basename as basename3, resolve as resolve4 } from "node:path";
43517
44817
  import { parseArgs as parseArgs3 } from "node:util";
44818
+
44819
+ // src/record/verify.ts
44820
+ function same(a, b) {
44821
+ return canonicalize(a) === canonicalize(b);
44822
+ }
44823
+ function predicateTypeOf(value) {
44824
+ return value !== null && typeof value === "object" ? value.predicateType : void 0;
44825
+ }
44826
+ function isDraft(value) {
44827
+ return predicateTypeOf(value) === DRAFT_PREDICATE_TYPE;
44828
+ }
44829
+ function isGitlabRecord(value) {
44830
+ return predicateTypeOf(value) === GITLAB_DRAFT_PREDICATE_TYPE;
44831
+ }
44832
+ function validateAnyRecord(value) {
44833
+ if (isDraft(value)) return validateDraftRecord(value);
44834
+ if (isGitlabRecord(value)) return validateGitlabDraftRecord(value);
44835
+ return validateRecord(value);
44836
+ }
44837
+ function verifyRecord(value) {
44838
+ const draft = isDraft(value);
44839
+ const schemaErrors = validateAnyRecord(value);
44840
+ if (schemaErrors.length > 0) {
44841
+ return {
44842
+ ok: false,
44843
+ problems: schemaErrors.map((e) => ({
44844
+ member: e.pointer,
44845
+ message: `schema_violation (${e.keyword}): ${e.message}`
44846
+ }))
44847
+ };
44848
+ }
44849
+ const record2 = value;
44850
+ const p = record2.predicate;
44851
+ const problems = [];
44852
+ const differ = (member, recorded, recomputed) => {
44853
+ if (!same(recorded, recomputed)) {
44854
+ problems.push({
44855
+ member,
44856
+ message: `recorded ${canonicalize(recorded)}, recomputed ${canonicalize(recomputed)}`
44857
+ });
44858
+ }
44859
+ };
44860
+ if (p.checker.name !== CHECKER_NAME || p.checker.version !== CHECKER_VERSION) {
44861
+ problems.push({
44862
+ member: "/predicate/checker",
44863
+ message: `record was written by ${p.checker.name} ${p.checker.version}; this is ${CHECKER_NAME} ${CHECKER_VERSION}`
44864
+ });
44865
+ return { ok: false, problems, record: record2 };
44866
+ }
44867
+ if (p.block.status === "found") {
44868
+ differ(
44869
+ "/predicate/block/sha256",
44870
+ p.block.sha256,
44871
+ sha256Canonical(p.block.value)
44872
+ );
44873
+ }
44874
+ const evidence = p.evidence;
44875
+ const pr = evidence.pullRequest;
44876
+ differ("/predicate/subject/pullRequest", p.subject.pullRequest, pr.number);
44877
+ differ("/predicate/subject/headSha", p.subject.headSha, pr.headSha);
44878
+ differ("/predicate/subject/mergeSha", p.subject.mergeSha, pr.mergeSha);
44879
+ differ("/subject", record2.subject, statementSubjects(p.block, p.subject));
44880
+ const claims = claimsFor(p.block, evidence, p.subject.repository);
44881
+ if (!same(p.claims, claims)) {
44882
+ const index = claims.findIndex((c, i) => !same(c, p.claims[i]));
44883
+ const at2 = index === -1 ? claims.length : index;
44884
+ problems.push({
44885
+ member: `/predicate/claims/${at2}`,
44886
+ message: `recorded ${canonicalize(p.claims[at2] ?? null)}, recomputed ${canonicalize(claims[at2] ?? null)}`
44887
+ });
44888
+ }
44889
+ differ("/predicate/verdict", p.verdict, overallVerdict(p.block.status, claims));
44890
+ differ(
44891
+ "/predicate/digests/claims",
44892
+ p.digests.claims,
44893
+ claimsDigest(claims)
44894
+ );
44895
+ differ(
44896
+ "/predicate/digests/evidence",
44897
+ p.digests.evidence,
44898
+ evidenceDigest(evidence)
44899
+ );
44900
+ if (draft && p.readerClaims !== void 0) {
44901
+ p.readerClaims.forEach((claim2, i) => {
44902
+ const { observed, verdict, reason } = claim2;
44903
+ differ(
44904
+ `/predicate/readerClaims/${i}`,
44905
+ reason === void 0 ? { observed, verdict } : { observed, verdict, reason },
44906
+ checkReaderClaim(claim2, evidence, p.subject.repository)
44907
+ );
44908
+ });
44909
+ differ(
44910
+ "/predicate/digests/readerClaims",
44911
+ p.digests.readerClaims,
44912
+ readerClaimsDigest(p.readerClaims)
44913
+ );
44914
+ }
44915
+ if (draft && p.advisory !== void 0) {
44916
+ const a = p.advisory;
44917
+ const pathsAtHead = a.pathsAtHead;
44918
+ differ("/predicate/advisory/extractor", a.extractor, EXTRACTOR);
44919
+ differ("/predicate/advisory/comparison", a.comparison, COMPARISON);
44920
+ differ("/predicate/advisory/precision", a.precision, precisionFor(EXTRACTOR.digest.sha256));
44921
+ differ(
44922
+ "/predicate/advisory/differsAccuracy",
44923
+ a.differsAccuracy,
44924
+ differsAccuracyFor(EXTRACTOR.digest.sha256, COMPARISON.version)
44925
+ );
44926
+ differ(
44927
+ "/predicate/advisory/pathsAtHead",
44928
+ pathsAtHead.map((answer) => answer.path),
44929
+ pathsToRead(a.advisories, evidence, p.subject.repository)
44930
+ );
44931
+ a.advisories.forEach((advisory, i) => {
44932
+ differ(
44933
+ `/predicate/advisory/advisories/${i}`,
44934
+ { observed: advisory.observed, note: advisory.note },
44935
+ compareAdvisory(advisory, evidence, p.subject.repository, pathsAtHead)
44936
+ );
44937
+ });
44938
+ differ("/predicate/digests/advisory", p.digests.advisory, advisoryDigest(a));
44939
+ }
44940
+ return { ok: problems.length === 0, problems, record: record2 };
44941
+ }
44942
+
44943
+ // src/cli/rerun.ts
43518
44944
  function diffPointers(a, b, pointer = "") {
43519
44945
  const isObject2 = (v) => v !== null && typeof v === "object";
43520
44946
  if (Array.isArray(a) && Array.isArray(b)) {
@@ -43548,27 +44974,45 @@ async function rerun(args, io) {
43548
44974
  if (resolve4(out) === resolve4(path))
43549
44975
  throw new UsageError("rerun never overwrites the record it re-runs");
43550
44976
  const value = parseStrictJson(readFileSync5(path, "utf8"));
43551
- const errors = validateRecord(value);
44977
+ const gitlab = isGitlabRecord(value);
44978
+ const errors = gitlab ? validateGitlabDraftRecord(value) : validateRecord(value);
43552
44979
  if (errors.length > 0) {
43553
44980
  throw new UsageError(
43554
44981
  `${path} is not a valid record: ${errors[0]?.pointer} ${errors[0]?.message}`
43555
44982
  );
43556
44983
  }
43557
44984
  const old = value.predicate;
43558
- const client = new GitHubClient({
43559
- token: io.env.GITHUB_TOKEN,
43560
- ...io.fetch === void 0 ? {} : { fetch: io.fetch },
43561
- warn: (message) => io.err(`dunstan: ${message}
43562
- `)
43563
- });
44985
+ const forge = old.subject.forge;
44986
+ if (gitlab && values["exclude-check-run"] !== void 0) {
44987
+ throw new UsageError("a GitLab record is re-run without --exclude-check-run");
44988
+ }
44989
+ const warn = (message) => io.err(`dunstan: ${message}
44990
+ `);
44991
+ const fetchOption = io.fetch === void 0 ? {} : { fetch: io.fetch };
44992
+ let client;
44993
+ let readers;
44994
+ if (forge === void 0) {
44995
+ const github = new GitHubClient({ token: io.env.GITHUB_TOKEN, ...fetchOption, warn });
44996
+ client = github;
44997
+ readers = githubReaders(github);
44998
+ } else {
44999
+ const gitlabClient = new GitLabClient({
45000
+ host: forge.host,
45001
+ token: io.env.GITLAB_TOKEN,
45002
+ ...fetchOption,
45003
+ warn
45004
+ });
45005
+ client = gitlabClient;
45006
+ readers = gitlabReaders(gitlabClient);
45007
+ }
43564
45008
  const oldExcluded = old.evidence.checkRuns?.status === "ok" ? old.evidence.checkRuns.excludedIds : [];
43565
45009
  const excluded = [...oldExcluded, ...parseCheckRunIds(values["exclude-check-run"])];
43566
45010
  const evidence = await atOnePullRequest(
43567
- githubReaders(client),
45011
+ readers,
43568
45012
  old.subject.repository,
43569
45013
  old.subject.pullRequest,
43570
45014
  void 0,
43571
- (pr) => readEvidence(client, {
45015
+ (pr) => readers.evidence({
43572
45016
  repository: old.subject.repository,
43573
45017
  pullRequest: pr,
43574
45018
  block: old.block.status === "found" ? old.block.value : null,
@@ -43581,7 +45025,8 @@ async function rerun(args, io) {
43581
45025
  block: old.block,
43582
45026
  repository: old.subject.repository,
43583
45027
  evidence,
43584
- rerun: rerunCommands(basename3(out))
45028
+ rerun: rerunCommands(basename3(out)),
45029
+ ...forge === void 0 ? {} : { forge }
43585
45030
  });
43586
45031
  if (existsSync2(out)) io.err(`dunstan: replacing ${out}
43587
45032
  `);
@@ -43604,7 +45049,7 @@ async function rerun(args, io) {
43604
45049
 
43605
45050
  `);
43606
45051
  }
43607
- io.out(renderRecord(record2, { hasToken: client.hasToken }));
45052
+ io.out(renderRecord(record2, { hasToken: client.hasToken, caller: "cli" }));
43608
45053
  io.out(`record: ${out} (the original ${path} is unchanged)
43609
45054
  `);
43610
45055
  return exitFor(p.verdict);
@@ -43639,7 +45084,7 @@ import { basename as basename4 } from "node:path";
43639
45084
  import { parseArgs as parseArgs5 } from "node:util";
43640
45085
 
43641
45086
  // src/survey/mapping.ts
43642
- var SURVEY_MAPPING_VERSION = "0.2";
45087
+ var SURVEY_MAPPING_VERSION = "0.3";
43643
45088
  var CLAIM_TYPES = [
43644
45089
  { type: "T1", label: "head commit" },
43645
45090
  { type: "T2", label: "files changed" },
@@ -43710,7 +45155,7 @@ var zero = () => ({
43710
45155
  });
43711
45156
  var byCodeUnit = (a, b) => a < b ? -1 : a > b ? 1 : 0;
43712
45157
  function aggregate(entries, listed2, advisory, reader) {
43713
- const context = { hasToken: reader.authenticated };
45158
+ const context = { hasToken: reader.authenticated, caller: "cli" };
43714
45159
  const totals = zero();
43715
45160
  const types = new Map(CLAIM_TYPES.map((t) => [t.type, zero()]));
43716
45161
  const unsettled2 = /* @__PURE__ */ new Map();
@@ -43805,8 +45250,8 @@ function selectPullRequests(parameters, listed2) {
43805
45250
  (pr) => authors.size === 0 || pr.author !== null && authors.has(pr.author.toLowerCase())
43806
45251
  ).slice(0, parameters.last).map((pr) => pr.number);
43807
45252
  }
43808
- function listingStop(parameters, pages, listed2) {
43809
- const last = pages.at(-1);
45253
+ function listingStop(parameters, pages2, listed2) {
45254
+ const last = pages2.at(-1);
43810
45255
  if (last === void 0) return null;
43811
45256
  if (last.error !== void 0) return "listing_unreadable";
43812
45257
  if (selectPullRequests(parameters, listed2).length >= parameters.last) return "selection_full";
@@ -43877,18 +45322,18 @@ function rateLimited(response) {
43877
45322
  const s = response.status;
43878
45323
  return s === 429 || s === 403 && (response.headers.get("x-ratelimit-remaining") === "0" || response.headers.get("retry-after") !== null);
43879
45324
  }
43880
- function str2(value) {
45325
+ function str3(value) {
43881
45326
  return typeof value === "string" ? value : null;
43882
45327
  }
43883
45328
  function listedOf(item) {
43884
45329
  if (item === null || typeof item !== "object") return null;
43885
45330
  const pr = item;
43886
- const createdAt = str2(pr.created_at);
45331
+ const createdAt = str3(pr.created_at);
43887
45332
  if (typeof pr.number !== "number" || !Number.isSafeInteger(pr.number) || pr.number < 1 || createdAt === null || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z$/.test(createdAt)) {
43888
45333
  return null;
43889
45334
  }
43890
45335
  const user = pr.user;
43891
- const login = str2(user?.login);
45336
+ const login = str3(user?.login);
43892
45337
  return {
43893
45338
  number: pr.number,
43894
45339
  createdAt,
@@ -43906,14 +45351,14 @@ async function readListing(client, parameters, now, pageBound) {
43906
45351
  }
43907
45352
  }
43908
45353
  async function readOnce(client, parameters, now, pageBound, abandonOnRepeat) {
43909
- const pages = [];
45354
+ const pages2 = [];
43910
45355
  const pullRequests = [];
43911
45356
  const seen = /* @__PURE__ */ new Set();
43912
45357
  const done = (end) => ({
43913
45358
  pageBound,
43914
- pages,
45359
+ pages: pages2,
43915
45360
  pullRequests,
43916
- duplicates: pages.reduce((sum, p) => sum + p.duplicates, 0),
45361
+ duplicates: pages2.reduce((sum, p) => sum + p.duplicates, 0),
43917
45362
  end
43918
45363
  });
43919
45364
  for (let page = 1; page <= pageBound; page++) {
@@ -43937,9 +45382,9 @@ async function readOnce(client, parameters, now, pageBound, abandonOnRepeat) {
43937
45382
  entry.duplicates = appendListed(pullRequests, seen, items);
43938
45383
  }
43939
45384
  }
43940
- pages.push(entry);
43941
- if (abandonOnRepeat && entry.duplicates > 0) return { pages };
43942
- const end = listingStop(parameters, pages, pullRequests);
45385
+ pages2.push(entry);
45386
+ if (abandonOnRepeat && entry.duplicates > 0) return { pages: pages2 };
45387
+ const end = listingStop(parameters, pages2, pullRequests);
43943
45388
  if (end !== null) return done(end);
43944
45389
  }
43945
45390
  return done("page_bound");
@@ -44033,7 +45478,7 @@ function share(r) {
44033
45478
  function interval(r) {
44034
45479
  return r.wilson95 === null ? "n/a" : `${r.wilson95.low}\u2013${r.wilson95.high}`;
44035
45480
  }
44036
- function plural2(n, one, many) {
45481
+ function plural3(n, one, many) {
44037
45482
  return `${n} ${n === 1 ? one : many}`;
44038
45483
  }
44039
45484
  function renderSurveySummary(record2) {
@@ -44057,7 +45502,7 @@ function renderSurveySummary(record2) {
44057
45502
  "",
44058
45503
  "## Claims the record could not settle",
44059
45504
  "",
44060
- `The record could not settle ${a.cannotSettle.numerator} of ${plural2(a.cannotSettle.denominator, "claim", "claims")}.`,
45505
+ `The record could not settle ${a.cannotSettle.numerator} of ${plural3(a.cannotSettle.denominator, "claim", "claims")}.`,
44061
45506
  ""
44062
45507
  );
44063
45508
  if (a.unsettled.length === 0) {
@@ -44129,7 +45574,7 @@ function renderSurveySummary(record2) {
44129
45574
  "",
44130
45575
  "## Advisory notes",
44131
45576
  "",
44132
- `The advisory reader read the prose of ${plural2(a.advisory.pullRequests, "pull request", "pull requests")}. Its notes never enter either rate, and a possible disagreement is unverified.`,
45577
+ `The advisory reader read the prose of ${plural3(a.advisory.pullRequests, "pull request", "pull requests")}. Its notes never enter either rate, and a possible disagreement is unverified.`,
44133
45578
  ""
44134
45579
  );
44135
45580
  if (a.advisory.notes.length === 0) lines.push("No notes.");
@@ -44143,7 +45588,7 @@ function renderSurveySummary(record2) {
44143
45588
  }
44144
45589
  }
44145
45590
  const p = a.pullRequests;
44146
- const context = { hasToken: record2.selection.reader.authenticated };
45591
+ const context = { hasToken: record2.selection.reader.authenticated, caller: "cli" };
44147
45592
  const remedy = (reason) => remedyForReason(reason, context) ?? "";
44148
45593
  lines.push(
44149
45594
  "",
@@ -44162,7 +45607,7 @@ function renderSurveySummary(record2) {
44162
45607
  ]
44163
45608
  ),
44164
45609
  "",
44165
- `The listing was read for ${plural2(listing.pages.length, "page", "pages")} and no further because ${listing.end === "page_bound" ? `it reached the bound of ${listing.pageBound} pages` : END_TEXT[listing.end]}.`
45610
+ `The listing was read for ${plural3(listing.pages.length, "page", "pages")} and no further because ${listing.end === "page_bound" ? `it reached the bound of ${listing.pageBound} pages` : END_TEXT[listing.end]}.`
44166
45611
  );
44167
45612
  const abandoned = listing.abandoned.length;
44168
45613
  if (listing.duplicates > 0) {
@@ -44170,18 +45615,18 @@ function renderSurveySummary(record2) {
44170
45615
  const reread = abandoned === 0 ? "" : ` It was read ${abandoned + 1} times, from the first page each time, and moved during every read.`;
44171
45616
  lines.push(
44172
45617
  "",
44173
- `The listing named ${plural2(listing.duplicates, "pull request", "pull requests")} again on a later page, so it changed while it was read: most likely ${plural2(listing.duplicates, "pull request", "pull requests")} closed meanwhile and ${one ? "was" : "were"} not listed, and the selection may be missing ${one ? "it" : "them"}. Each repeated pull request is listed, selected and counted once: the pages held ${listing.pullRequests.length + listing.duplicates} items for ${listing.pullRequests.length} pull requests.${reread}`
45618
+ `The listing named ${plural3(listing.duplicates, "pull request", "pull requests")} again on a later page, so it changed while it was read: most likely ${plural3(listing.duplicates, "pull request", "pull requests")} closed meanwhile and ${one ? "was" : "were"} not listed, and the selection may be missing ${one ? "it" : "them"}. Each repeated pull request is listed, selected and counted once: the pages held ${listing.pullRequests.length + listing.duplicates} items for ${listing.pullRequests.length} pull requests.${reread}`
44174
45619
  );
44175
45620
  } else if (abandoned > 0) {
44176
45621
  lines.push(
44177
45622
  "",
44178
- `The listing moved while it was read: ${plural2(abandoned, "read was", "reads were")} abandoned at a repeated pull request and the listing was read again from the first page. The read used repeated no pull request.`
45623
+ `The listing moved while it was read: ${plural3(abandoned, "read was", "reads were")} abandoned at a repeated pull request and the listing was read again from the first page. The read used repeated no pull request.`
44179
45624
  );
44180
45625
  }
44181
45626
  if (selected.length < parameters.last) {
44182
45627
  lines.push(
44183
45628
  "",
44184
- `${plural2(selected.length, "pull request was", "pull requests were")} selected, fewer than the ${parameters.last} asked for.`
45629
+ `${plural3(selected.length, "pull request was", "pull requests were")} selected, fewer than the ${parameters.last} asked for.`
44185
45630
  );
44186
45631
  }
44187
45632
  lines.push(
@@ -44268,95 +45713,12 @@ summary: ${values.summary}
44268
45713
 
44269
45714
  // src/cli/template.ts
44270
45715
  import { parseArgs as parseArgs6 } from "node:util";
44271
-
44272
- // src/template/tests-ci.ts
44273
- var plural3 = (n, one, many) => `${n} ${n === 1 ? one : many}`;
44274
- function unsettled(reason, why, remedy) {
44275
- const notes = [`tests are not declared: ${why} (${reason}).`];
44276
- if (remedy !== void 0) notes.push(`remedy: ${remedy}`);
44277
- return { status: "unsettled", reason, notes };
44278
- }
44279
- async function testsFromCi(client, input2) {
44280
- const { repository, headCommit: head, record: record2 } = input2;
44281
- if (!client.hasToken) {
44282
- return unsettled(
44283
- "no_token",
44284
- "GITHUB_TOKEN is not set, and GitHub answers an artifact download only with a token, even on a public repository",
44285
- "set GITHUB_TOKEN to a token that can read the repository, with actions: read, and run dunstan template --tests-ci again."
44286
- );
44287
- }
44288
- if (!input2.pushed) {
44289
- return unsettled(
44290
- "not_pushed",
44291
- `HEAD ${head} is not on any remote-tracking branch, so no CI run can have tested it`,
44292
- "push it, wait for CI to finish, and run dunstan template --tests-ci again (run git fetch first if it was pushed from elsewhere)."
44293
- );
44294
- }
44295
- const observed = { runsNotCompleted: 0 };
44296
- const evidence = await readTestRecord(client, repository, record2, head, observed);
44297
- const named = `the artifact ${record2.artifact} holding ${record2.path} from job ${record2.job}`;
44298
- const pending = observed.runsNotCompleted > 0 ? `CI has not finished: ${plural3(observed.runsNotCompleted, "run", "runs")} of ${record2.workflow} at ${head} ${observed.runsNotCompleted === 1 ? "is" : "are"} not completed` : void 0;
44299
- const again = "run dunstan template --tests-ci again once CI has finished on this commit.";
44300
- if (evidence.status !== "ok") {
44301
- const reason = evidence.status === "unreadable" ? `source_unreadable:${evidence.source}` : `evidence_field_unpopulated:${evidence.field}`;
44302
- if (pending !== void 0) return unsettled(reason, pending, again);
44303
- const why = reason === "source_unreadable:artifact" ? `a run of ${record2.workflow} at ${head} uploaded ${record2.artifact}, and it could not be read` : `the workflow runs of ${record2.workflow} at ${head} could not be read`;
44304
- return unsettled(
44305
- reason,
44306
- why,
44307
- remedyForReason(reason, { hasToken: true }) ?? "run dunstan template --tests-ci again."
44308
- );
44309
- }
44310
- if (evidence.runs.length === 0) {
44311
- if (pending !== void 0) {
44312
- return unsettled(
44313
- "record_not_found",
44314
- `${pending}, and no completed run uploaded ${named}`,
44315
- again
44316
- );
44317
- }
44318
- return unsettled(
44319
- "record_not_found",
44320
- `no run of ${record2.workflow} at ${head} uploaded ${named}`,
44321
- remedyForReason("record_not_found", { hasToken: true })
44322
- );
44323
- }
44324
- const executed = new Set(evidence.runs.map((r) => r.executed));
44325
- const failed = new Set(evidence.runs.map((r) => r.failed));
44326
- if (executed.size > 1 || failed.size > 1) {
44327
- const counts = evidence.runs.map((r) => `run ${r.runId}: ${r.executed} executed, ${r.failed} failed`).join("; ");
44328
- return unsettled(
44329
- "record_ambiguous",
44330
- `the runs of ${record2.workflow} at ${head} that uploaded ${record2.artifact} disagree (${counts}), and the gate never picks one`,
44331
- "make the runs agree (re-run the one that differs), or name a job only one of them runs."
44332
- );
44333
- }
44334
- const run = evidence.runs[0];
44335
- const runs = evidence.runs.map((r) => r.runId).join(", ");
44336
- const notes = [
44337
- `tests: ${run.executed} executed and ${run.failed} failed in ${record2.path} in the artifact ${record2.artifact} of job ${record2.job} in ${record2.workflow} at ${head}, read from CI as the gate reads it (${evidence.runs.length === 1 ? "run" : "runs"} ${runs}).`
44338
- ];
44339
- if (pending !== void 0) {
44340
- notes.push(
44341
- `${pending}; a run that finishes later with other counts makes the gate's read ambiguous. Run dunstan template --tests-ci again once CI has finished.`
44342
- );
44343
- }
44344
- return {
44345
- status: "settled",
44346
- run: { command: input2.command, count: run.executed, failures: run.failed, record: record2 },
44347
- notes
44348
- };
44349
- }
44350
-
44351
- // src/cli/template.ts
44352
45716
  var RECORD_FLAGS = [
44353
45717
  "tests-workflow",
44354
45718
  "tests-job",
44355
45719
  "tests-artifact",
44356
45720
  "tests-artifact-path"
44357
45721
  ];
44358
- var DEFAULT_ARTIFACT = "dunstan-junit";
44359
- var DEFAULT_ARTIFACT_PATH = "junit.xml";
44360
45722
  async function template(args, io) {
44361
45723
  const { values } = parseArgs6({
44362
45724
  args,
@@ -44381,7 +45743,6 @@ async function template(args, io) {
44381
45743
  }
44382
45744
  if (flag("repo") !== void 0 && !ci) throw new UsageError("--repo goes with --tests-ci");
44383
45745
  const given = ["tests-command", ...RECORD_FLAGS].filter((f) => flag(f) !== void 0);
44384
- const cwd = io.cwd ?? process.cwd();
44385
45746
  let tests;
44386
45747
  if (ci) {
44387
45748
  const missing = ["tests-command", "tests-workflow", "tests-job"].filter(
@@ -44394,28 +45755,15 @@ async function template(args, io) {
44394
45755
  if (repo !== void 0 && !REPOSITORY.test(repo)) {
44395
45756
  throw new UsageError("--repo must be <owner/name>");
44396
45757
  }
44397
- const head = headCommit(cwd);
44398
- const repository = repo ?? originRepository(cwd);
44399
- const client = new GitHubClient({
44400
- token: io.env.GITHUB_TOKEN,
44401
- ...io.fetch === void 0 ? {} : { fetch: io.fetch },
44402
- warn: (message) => io.err(`dunstan: ${message}
44403
- `)
44404
- });
44405
- const read = await testsFromCi(client, {
44406
- repository,
44407
- headCommit: head,
45758
+ tests = {
45759
+ from: "ci",
44408
45760
  command: flag("tests-command"),
44409
- pushed: isPushed(cwd, head),
44410
- record: {
44411
- kind: "junit",
44412
- workflow: flag("tests-workflow"),
44413
- job: flag("tests-job"),
44414
- artifact: flag("tests-artifact") ?? DEFAULT_ARTIFACT,
44415
- path: flag("tests-artifact-path") ?? DEFAULT_ARTIFACT_PATH
44416
- }
44417
- });
44418
- tests = { kind: "ci", read };
45761
+ workflow: flag("tests-workflow"),
45762
+ job: flag("tests-job"),
45763
+ artifact: flag("tests-artifact"),
45764
+ artifactPath: flag("tests-artifact-path"),
45765
+ repository: repo
45766
+ };
44419
45767
  } else if (flag("tests-local") !== void 0) {
44420
45768
  if (flag("tests-command") === void 0) {
44421
45769
  throw new UsageError("--tests-local needs --tests-command <cmd>");
@@ -44423,7 +45771,7 @@ async function template(args, io) {
44423
45771
  const extra = RECORD_FLAGS.find((f) => flag(f) !== void 0);
44424
45772
  if (extra !== void 0) throw new UsageError(`--${extra} does not go with --tests-local`);
44425
45773
  tests = {
44426
- kind: "local",
45774
+ from: "local",
44427
45775
  junit: flag("tests-local"),
44428
45776
  command: flag("tests-command")
44429
45777
  };
@@ -44437,143 +45785,32 @@ async function template(args, io) {
44437
45785
  );
44438
45786
  }
44439
45787
  tests = {
44440
- kind: "junit",
45788
+ from: "junit",
44441
45789
  junit: flag("tests-junit"),
44442
45790
  command: flag("tests-command"),
44443
- record: {
44444
- workflow: flag("tests-workflow"),
44445
- job: flag("tests-job"),
44446
- artifact: flag("tests-artifact"),
44447
- path: flag("tests-artifact-path")
44448
- }
45791
+ workflow: flag("tests-workflow"),
45792
+ job: flag("tests-job"),
45793
+ artifact: flag("tests-artifact"),
45794
+ artifactPath: flag("tests-artifact-path")
44449
45795
  };
44450
45796
  } else if (given.length > 0) {
44451
45797
  throw new UsageError(`--${given[0]} goes with --tests-ci, --tests-local or --tests-junit`);
44452
45798
  }
44453
- const t = templateBlock({ cwd, base: flag("base"), tests });
44454
- io.out(renderBlock(t.block));
44455
- for (const note of t.notes) io.err(`dunstan: ${note}
45799
+ const run = await runTemplate(
45800
+ { cwd: io.cwd ?? process.cwd(), base: flag("base"), tests, caller: "cli" },
45801
+ { env: io.env, fetch: io.fetch, warn: (message) => io.err(`dunstan: ${message}
45802
+ `) }
45803
+ );
45804
+ io.out(run.fence);
45805
+ for (const note of run.notes) io.err(`dunstan: ${note}
44456
45806
  `);
44457
- return tests?.kind === "ci" && tests.read.status === "unsettled" ? EXIT.unverifiable : 0;
45807
+ return run.tests === "not_declared" ? EXIT.unverifiable : 0;
44458
45808
  }
44459
45809
 
44460
45810
  // src/cli/verify.ts
44461
45811
  import { readFileSync as readFileSync7 } from "node:fs";
44462
45812
  import { parseArgs as parseArgs7 } from "node:util";
44463
45813
 
44464
- // src/record/verify.ts
44465
- function same(a, b) {
44466
- return canonicalize(a) === canonicalize(b);
44467
- }
44468
- function isDraft(value) {
44469
- return value !== null && typeof value === "object" && value.predicateType === DRAFT_PREDICATE_TYPE;
44470
- }
44471
- function verifyRecord(value) {
44472
- const draft = isDraft(value);
44473
- const schemaErrors = draft ? validateDraftRecord(value) : validateRecord(value);
44474
- if (schemaErrors.length > 0) {
44475
- return {
44476
- ok: false,
44477
- problems: schemaErrors.map((e) => ({
44478
- member: e.pointer,
44479
- message: `schema_violation (${e.keyword}): ${e.message}`
44480
- }))
44481
- };
44482
- }
44483
- const record2 = value;
44484
- const p = record2.predicate;
44485
- const problems = [];
44486
- const differ = (member, recorded, recomputed) => {
44487
- if (!same(recorded, recomputed)) {
44488
- problems.push({
44489
- member,
44490
- message: `recorded ${canonicalize(recorded)}, recomputed ${canonicalize(recomputed)}`
44491
- });
44492
- }
44493
- };
44494
- if (p.checker.name !== CHECKER_NAME || p.checker.version !== CHECKER_VERSION) {
44495
- problems.push({
44496
- member: "/predicate/checker",
44497
- message: `record was written by ${p.checker.name} ${p.checker.version}; this is ${CHECKER_NAME} ${CHECKER_VERSION}`
44498
- });
44499
- return { ok: false, problems, record: record2 };
44500
- }
44501
- if (p.block.status === "found") {
44502
- differ(
44503
- "/predicate/block/sha256",
44504
- p.block.sha256,
44505
- sha256Canonical(p.block.value)
44506
- );
44507
- }
44508
- const evidence = p.evidence;
44509
- const pr = evidence.pullRequest;
44510
- differ("/predicate/subject/pullRequest", p.subject.pullRequest, pr.number);
44511
- differ("/predicate/subject/headSha", p.subject.headSha, pr.headSha);
44512
- differ("/predicate/subject/mergeSha", p.subject.mergeSha, pr.mergeSha);
44513
- differ("/subject", record2.subject, statementSubjects(p.block, p.subject));
44514
- const claims = claimsFor(p.block, evidence, p.subject.repository);
44515
- if (!same(p.claims, claims)) {
44516
- const index = claims.findIndex((c, i) => !same(c, p.claims[i]));
44517
- const at2 = index === -1 ? claims.length : index;
44518
- problems.push({
44519
- member: `/predicate/claims/${at2}`,
44520
- message: `recorded ${canonicalize(p.claims[at2] ?? null)}, recomputed ${canonicalize(claims[at2] ?? null)}`
44521
- });
44522
- }
44523
- differ("/predicate/verdict", p.verdict, overallVerdict(p.block.status, claims));
44524
- differ(
44525
- "/predicate/digests/claims",
44526
- p.digests.claims,
44527
- claimsDigest(claims)
44528
- );
44529
- differ(
44530
- "/predicate/digests/evidence",
44531
- p.digests.evidence,
44532
- evidenceDigest(evidence)
44533
- );
44534
- if (draft && p.readerClaims !== void 0) {
44535
- p.readerClaims.forEach((claim2, i) => {
44536
- const { observed, verdict, reason } = claim2;
44537
- differ(
44538
- `/predicate/readerClaims/${i}`,
44539
- reason === void 0 ? { observed, verdict } : { observed, verdict, reason },
44540
- checkReaderClaim(claim2, evidence, p.subject.repository)
44541
- );
44542
- });
44543
- differ(
44544
- "/predicate/digests/readerClaims",
44545
- p.digests.readerClaims,
44546
- readerClaimsDigest(p.readerClaims)
44547
- );
44548
- }
44549
- if (draft && p.advisory !== void 0) {
44550
- const a = p.advisory;
44551
- const pathsAtHead = a.pathsAtHead;
44552
- differ("/predicate/advisory/extractor", a.extractor, EXTRACTOR);
44553
- differ("/predicate/advisory/comparison", a.comparison, COMPARISON);
44554
- differ("/predicate/advisory/precision", a.precision, precisionFor(EXTRACTOR.digest.sha256));
44555
- differ(
44556
- "/predicate/advisory/differsAccuracy",
44557
- a.differsAccuracy,
44558
- differsAccuracyFor(EXTRACTOR.digest.sha256, COMPARISON.version)
44559
- );
44560
- differ(
44561
- "/predicate/advisory/pathsAtHead",
44562
- pathsAtHead.map((answer) => answer.path),
44563
- pathsToRead(a.advisories, evidence, p.subject.repository)
44564
- );
44565
- a.advisories.forEach((advisory, i) => {
44566
- differ(
44567
- `/predicate/advisory/advisories/${i}`,
44568
- { observed: advisory.observed, note: advisory.note },
44569
- compareAdvisory(advisory, evidence, p.subject.repository, pathsAtHead)
44570
- );
44571
- });
44572
- differ("/predicate/digests/advisory", p.digests.advisory, advisoryDigest(a));
44573
- }
44574
- return { ok: problems.length === 0, problems, record: record2 };
44575
- }
44576
-
44577
45814
  // src/survey/verify.ts
44578
45815
  var text = (value) => canonicalize(value ?? null);
44579
45816
  function differences(a, b, pointer) {
@@ -44884,6 +46121,10 @@ usage:
44884
46121
  (--report-file <path|-> | --report-pr-body | --report-comment-author <login>)
44885
46122
  [--out <record.json>] [--sign-key <path> --signer <principal>]
44886
46123
  [--exclude-check-run <id>]... [--advisory]
46124
+ dunstan check --gitlab-project <namespace/project> --mr <iid> [--gitlab-host <host>]
46125
+ (--report-file <path|-> | --report-mr-description)
46126
+ [--out <record.json>] [--sign-key <path> --signer <principal>]
46127
+ [--exclude-gitlab-job <id>]...
44887
46128
  dunstan verify <record.json> [--sig <file> --allowed-signers <file>]
44888
46129
  dunstan rerun <record.json> [--out <record.json>] [--exclude-check-run <id>]...
44889
46130
  dunstan template [--base <ref>]
@@ -44903,8 +46144,13 @@ check reads the pull request, the named report and the evidence its block ne
44903
46144
  record and prints the claims table. GITHUB_TOKEN is read from the environment.
44904
46145
  --advisory also reads the report's prose for asserted claims and records them as
44905
46146
  advisories (DRAFT 0.2.0, docs/advisory.md). They never change the verdict.
44906
- verify offline: recomputes claims, verdict and digests from the record and compares them.
46147
+ GitLab (DRAFT GitLab profile 0.1): --gitlab-project and --mr check a merge request on
46148
+ gitlab.com, or on --gitlab-host; --report-mr-description reads its description.
46149
+ GITLAB_TOKEN is optional. Checks are the head pipeline's jobs and bridges, less each
46150
+ --exclude-gitlab-job (the checker's own job, CI_JOB_ID).
46151
+ verify offline: recomputes claims, verdict and digests from the record and compares them.
44907
46152
  rerun online: re-reads the sources for the recorded subject and block; writes a new record.
46153
+ A GitLab record is re-read from the host it names.
44908
46154
  template local git: prints a dunstan-handback block for HEAD, its files against the merge base
44909
46155
  with --base (default: the remote's default branch). Tests: --tests-ci reads the counts
44910
46156
  at HEAD from the CI record check reads (artifact dunstan-junit, file junit.xml unless