dunstan 0.1.16 → 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 +58 -6
  2. package/dist/dunstan.mjs +1425 -312
  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.16";
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) {
@@ -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) {
@@ -43343,26 +44413,27 @@ function unsettled(reason, why, remedy) {
43343
44413
  return { status: "unsettled", reason, notes };
43344
44414
  }
43345
44415
  async function testsFromCi(client, input2) {
43346
- const { repository, headCommit: head, record: record2 } = input2;
44416
+ const { repository, headCommit: head, record: record2, caller } = input2;
44417
+ const againCi = phrase(caller, "againCi");
43347
44418
  if (!client.hasToken) {
43348
44419
  return unsettled(
43349
44420
  "no_token",
43350
44421
  "GITHUB_TOKEN is not set, and GitHub answers an artifact download only with a token, even on a public repository",
43351
- "set GITHUB_TOKEN to a token that can read the repository, with actions: read, and run dunstan template --tests-ci again."
44422
+ `set GITHUB_TOKEN to a token that can read the repository, with actions: read, and ${againCi}.`
43352
44423
  );
43353
44424
  }
43354
44425
  if (!input2.pushed) {
43355
44426
  return unsettled(
43356
44427
  "not_pushed",
43357
44428
  `HEAD ${head} is not on any remote-tracking branch, so no CI run can have tested it`,
43358
- "push it, wait for CI to finish, and run dunstan template --tests-ci again (run git fetch first if it was pushed from elsewhere)."
44429
+ `push it, wait for CI to finish, and ${againCi} (run git fetch first if it was pushed from elsewhere).`
43359
44430
  );
43360
44431
  }
43361
44432
  const observed = { runsNotCompleted: 0 };
43362
44433
  const evidence = await readTestRecord(client, repository, record2, head, observed);
43363
44434
  const named = `the artifact ${record2.artifact} holding ${record2.path} from job ${record2.job}`;
43364
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;
43365
- const again = "run dunstan template --tests-ci again once CI has finished on this commit.";
44436
+ const again = `${againCi} once CI has finished on this commit.`;
43366
44437
  if (evidence.status !== "ok") {
43367
44438
  const reason = evidence.status === "unreadable" ? `source_unreadable:${evidence.source}` : `evidence_field_unpopulated:${evidence.field}`;
43368
44439
  if (pending !== void 0) return unsettled(reason, pending, again);
@@ -43370,7 +44441,7 @@ async function testsFromCi(client, input2) {
43370
44441
  return unsettled(
43371
44442
  reason,
43372
44443
  why,
43373
- remedyForReason(reason, { hasToken: true }) ?? "run dunstan template --tests-ci again."
44444
+ remedyForReason(reason, { hasToken: true, caller }) ?? `${againCi}.`
43374
44445
  );
43375
44446
  }
43376
44447
  if (evidence.runs.length === 0) {
@@ -43384,7 +44455,7 @@ async function testsFromCi(client, input2) {
43384
44455
  return unsettled(
43385
44456
  "record_not_found",
43386
44457
  `no run of ${record2.workflow} at ${head} uploaded ${named}`,
43387
- remedyForReason("record_not_found", { hasToken: true })
44458
+ remedyForReason("record_not_found", { hasToken: true, caller })
43388
44459
  );
43389
44460
  }
43390
44461
  const executed = new Set(evidence.runs.map((r) => r.executed));
@@ -43404,7 +44475,7 @@ async function testsFromCi(client, input2) {
43404
44475
  ];
43405
44476
  if (pending !== void 0) {
43406
44477
  notes.push(
43407
- `${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.`
44478
+ `${pending}; a run that finishes later with other counts makes the gate's read ambiguous. ${phrase(caller, "againCiSentence")} once CI has finished.`
43408
44479
  );
43409
44480
  }
43410
44481
  return {
@@ -43435,7 +44506,7 @@ async function testsSource(request, context) {
43435
44506
  };
43436
44507
  }
43437
44508
  const head = headCommit(cwd);
43438
- const repository = tests.repository ?? originRepository(cwd);
44509
+ const repository = tests.repository ?? originRepository(cwd, request.caller);
43439
44510
  const client = new GitHubClient({
43440
44511
  token: context.env.GITHUB_TOKEN,
43441
44512
  ...context.fetch === void 0 ? {} : { fetch: context.fetch },
@@ -43446,6 +44517,7 @@ async function testsSource(request, context) {
43446
44517
  headCommit: head,
43447
44518
  command: tests.command,
43448
44519
  pushed: isPushed(cwd, head),
44520
+ caller: request.caller,
43449
44521
  record: {
43450
44522
  kind: "junit",
43451
44523
  workflow: tests.workflow,
@@ -43458,7 +44530,7 @@ async function testsSource(request, context) {
43458
44530
  }
43459
44531
  async function runTemplate(request, context) {
43460
44532
  const tests = await testsSource(request, context);
43461
- const t = templateBlock({ cwd: request.cwd, base: request.base, tests });
44533
+ const t = templateBlock({ cwd: request.cwd, base: request.base, tests, caller: request.caller });
43462
44534
  return {
43463
44535
  fence: renderBlock(t.block),
43464
44536
  notes: t.notes,
@@ -43549,7 +44621,7 @@ function handbackResult(record2, outPath, notes, context) {
43549
44621
  content: [
43550
44622
  { type: "text", text: `${head.join("\n")}
43551
44623
  ` },
43552
- { type: "text", text: renderRecord(record2, context) },
44624
+ { type: "text", text: renderRecord(record2, { ...context, caller: "mcp" }) },
43553
44625
  { type: "text", text: serializeRecord(record2) }
43554
44626
  ]
43555
44627
  };
@@ -43565,7 +44637,7 @@ async function templateHandback(input2, options) {
43565
44637
  }
43566
44638
  const warnings = [];
43567
44639
  const run = await runTemplate(
43568
- { cwd: input2.directory, base: input2.base, tests: input2.tests },
44640
+ { cwd: input2.directory, base: input2.base, tests: input2.tests, caller: "mcp" },
43569
44641
  {
43570
44642
  env: options.env,
43571
44643
  fetch: options.fetch,
@@ -43743,6 +44815,132 @@ async function mcp(args, io) {
43743
44815
  import { existsSync as existsSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
43744
44816
  import { basename as basename3, resolve as resolve4 } from "node:path";
43745
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
43746
44944
  function diffPointers(a, b, pointer = "") {
43747
44945
  const isObject2 = (v) => v !== null && typeof v === "object";
43748
44946
  if (Array.isArray(a) && Array.isArray(b)) {
@@ -43776,27 +44974,45 @@ async function rerun(args, io) {
43776
44974
  if (resolve4(out) === resolve4(path))
43777
44975
  throw new UsageError("rerun never overwrites the record it re-runs");
43778
44976
  const value = parseStrictJson(readFileSync5(path, "utf8"));
43779
- const errors = validateRecord(value);
44977
+ const gitlab = isGitlabRecord(value);
44978
+ const errors = gitlab ? validateGitlabDraftRecord(value) : validateRecord(value);
43780
44979
  if (errors.length > 0) {
43781
44980
  throw new UsageError(
43782
44981
  `${path} is not a valid record: ${errors[0]?.pointer} ${errors[0]?.message}`
43783
44982
  );
43784
44983
  }
43785
44984
  const old = value.predicate;
43786
- const client = new GitHubClient({
43787
- token: io.env.GITHUB_TOKEN,
43788
- ...io.fetch === void 0 ? {} : { fetch: io.fetch },
43789
- warn: (message) => io.err(`dunstan: ${message}
43790
- `)
43791
- });
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
+ }
43792
45008
  const oldExcluded = old.evidence.checkRuns?.status === "ok" ? old.evidence.checkRuns.excludedIds : [];
43793
45009
  const excluded = [...oldExcluded, ...parseCheckRunIds(values["exclude-check-run"])];
43794
45010
  const evidence = await atOnePullRequest(
43795
- githubReaders(client),
45011
+ readers,
43796
45012
  old.subject.repository,
43797
45013
  old.subject.pullRequest,
43798
45014
  void 0,
43799
- (pr) => readEvidence(client, {
45015
+ (pr) => readers.evidence({
43800
45016
  repository: old.subject.repository,
43801
45017
  pullRequest: pr,
43802
45018
  block: old.block.status === "found" ? old.block.value : null,
@@ -43809,7 +45025,8 @@ async function rerun(args, io) {
43809
45025
  block: old.block,
43810
45026
  repository: old.subject.repository,
43811
45027
  evidence,
43812
- rerun: rerunCommands(basename3(out))
45028
+ rerun: rerunCommands(basename3(out)),
45029
+ ...forge === void 0 ? {} : { forge }
43813
45030
  });
43814
45031
  if (existsSync2(out)) io.err(`dunstan: replacing ${out}
43815
45032
  `);
@@ -43832,7 +45049,7 @@ async function rerun(args, io) {
43832
45049
 
43833
45050
  `);
43834
45051
  }
43835
- io.out(renderRecord(record2, { hasToken: client.hasToken }));
45052
+ io.out(renderRecord(record2, { hasToken: client.hasToken, caller: "cli" }));
43836
45053
  io.out(`record: ${out} (the original ${path} is unchanged)
43837
45054
  `);
43838
45055
  return exitFor(p.verdict);
@@ -43867,7 +45084,7 @@ import { basename as basename4 } from "node:path";
43867
45084
  import { parseArgs as parseArgs5 } from "node:util";
43868
45085
 
43869
45086
  // src/survey/mapping.ts
43870
- var SURVEY_MAPPING_VERSION = "0.2";
45087
+ var SURVEY_MAPPING_VERSION = "0.3";
43871
45088
  var CLAIM_TYPES = [
43872
45089
  { type: "T1", label: "head commit" },
43873
45090
  { type: "T2", label: "files changed" },
@@ -43938,7 +45155,7 @@ var zero = () => ({
43938
45155
  });
43939
45156
  var byCodeUnit = (a, b) => a < b ? -1 : a > b ? 1 : 0;
43940
45157
  function aggregate(entries, listed2, advisory, reader) {
43941
- const context = { hasToken: reader.authenticated };
45158
+ const context = { hasToken: reader.authenticated, caller: "cli" };
43942
45159
  const totals = zero();
43943
45160
  const types = new Map(CLAIM_TYPES.map((t) => [t.type, zero()]));
43944
45161
  const unsettled2 = /* @__PURE__ */ new Map();
@@ -44033,8 +45250,8 @@ function selectPullRequests(parameters, listed2) {
44033
45250
  (pr) => authors.size === 0 || pr.author !== null && authors.has(pr.author.toLowerCase())
44034
45251
  ).slice(0, parameters.last).map((pr) => pr.number);
44035
45252
  }
44036
- function listingStop(parameters, pages, listed2) {
44037
- const last = pages.at(-1);
45253
+ function listingStop(parameters, pages2, listed2) {
45254
+ const last = pages2.at(-1);
44038
45255
  if (last === void 0) return null;
44039
45256
  if (last.error !== void 0) return "listing_unreadable";
44040
45257
  if (selectPullRequests(parameters, listed2).length >= parameters.last) return "selection_full";
@@ -44105,18 +45322,18 @@ function rateLimited(response) {
44105
45322
  const s = response.status;
44106
45323
  return s === 429 || s === 403 && (response.headers.get("x-ratelimit-remaining") === "0" || response.headers.get("retry-after") !== null);
44107
45324
  }
44108
- function str2(value) {
45325
+ function str3(value) {
44109
45326
  return typeof value === "string" ? value : null;
44110
45327
  }
44111
45328
  function listedOf(item) {
44112
45329
  if (item === null || typeof item !== "object") return null;
44113
45330
  const pr = item;
44114
- const createdAt = str2(pr.created_at);
45331
+ const createdAt = str3(pr.created_at);
44115
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)) {
44116
45333
  return null;
44117
45334
  }
44118
45335
  const user = pr.user;
44119
- const login = str2(user?.login);
45336
+ const login = str3(user?.login);
44120
45337
  return {
44121
45338
  number: pr.number,
44122
45339
  createdAt,
@@ -44134,14 +45351,14 @@ async function readListing(client, parameters, now, pageBound) {
44134
45351
  }
44135
45352
  }
44136
45353
  async function readOnce(client, parameters, now, pageBound, abandonOnRepeat) {
44137
- const pages = [];
45354
+ const pages2 = [];
44138
45355
  const pullRequests = [];
44139
45356
  const seen = /* @__PURE__ */ new Set();
44140
45357
  const done = (end) => ({
44141
45358
  pageBound,
44142
- pages,
45359
+ pages: pages2,
44143
45360
  pullRequests,
44144
- duplicates: pages.reduce((sum, p) => sum + p.duplicates, 0),
45361
+ duplicates: pages2.reduce((sum, p) => sum + p.duplicates, 0),
44145
45362
  end
44146
45363
  });
44147
45364
  for (let page = 1; page <= pageBound; page++) {
@@ -44165,9 +45382,9 @@ async function readOnce(client, parameters, now, pageBound, abandonOnRepeat) {
44165
45382
  entry.duplicates = appendListed(pullRequests, seen, items);
44166
45383
  }
44167
45384
  }
44168
- pages.push(entry);
44169
- if (abandonOnRepeat && entry.duplicates > 0) return { pages };
44170
- 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);
44171
45388
  if (end !== null) return done(end);
44172
45389
  }
44173
45390
  return done("page_bound");
@@ -44371,7 +45588,7 @@ function renderSurveySummary(record2) {
44371
45588
  }
44372
45589
  }
44373
45590
  const p = a.pullRequests;
44374
- const context = { hasToken: record2.selection.reader.authenticated };
45591
+ const context = { hasToken: record2.selection.reader.authenticated, caller: "cli" };
44375
45592
  const remedy = (reason) => remedyForReason(reason, context) ?? "";
44376
45593
  lines.push(
44377
45594
  "",
@@ -44580,7 +45797,7 @@ async function template(args, io) {
44580
45797
  throw new UsageError(`--${given[0]} goes with --tests-ci, --tests-local or --tests-junit`);
44581
45798
  }
44582
45799
  const run = await runTemplate(
44583
- { cwd: io.cwd ?? process.cwd(), base: flag("base"), tests },
45800
+ { cwd: io.cwd ?? process.cwd(), base: flag("base"), tests, caller: "cli" },
44584
45801
  { env: io.env, fetch: io.fetch, warn: (message) => io.err(`dunstan: ${message}
44585
45802
  `) }
44586
45803
  );
@@ -44594,119 +45811,6 @@ async function template(args, io) {
44594
45811
  import { readFileSync as readFileSync7 } from "node:fs";
44595
45812
  import { parseArgs as parseArgs7 } from "node:util";
44596
45813
 
44597
- // src/record/verify.ts
44598
- function same(a, b) {
44599
- return canonicalize(a) === canonicalize(b);
44600
- }
44601
- function isDraft(value) {
44602
- return value !== null && typeof value === "object" && value.predicateType === DRAFT_PREDICATE_TYPE;
44603
- }
44604
- function verifyRecord(value) {
44605
- const draft = isDraft(value);
44606
- const schemaErrors = draft ? validateDraftRecord(value) : validateRecord(value);
44607
- if (schemaErrors.length > 0) {
44608
- return {
44609
- ok: false,
44610
- problems: schemaErrors.map((e) => ({
44611
- member: e.pointer,
44612
- message: `schema_violation (${e.keyword}): ${e.message}`
44613
- }))
44614
- };
44615
- }
44616
- const record2 = value;
44617
- const p = record2.predicate;
44618
- const problems = [];
44619
- const differ = (member, recorded, recomputed) => {
44620
- if (!same(recorded, recomputed)) {
44621
- problems.push({
44622
- member,
44623
- message: `recorded ${canonicalize(recorded)}, recomputed ${canonicalize(recomputed)}`
44624
- });
44625
- }
44626
- };
44627
- if (p.checker.name !== CHECKER_NAME || p.checker.version !== CHECKER_VERSION) {
44628
- problems.push({
44629
- member: "/predicate/checker",
44630
- message: `record was written by ${p.checker.name} ${p.checker.version}; this is ${CHECKER_NAME} ${CHECKER_VERSION}`
44631
- });
44632
- return { ok: false, problems, record: record2 };
44633
- }
44634
- if (p.block.status === "found") {
44635
- differ(
44636
- "/predicate/block/sha256",
44637
- p.block.sha256,
44638
- sha256Canonical(p.block.value)
44639
- );
44640
- }
44641
- const evidence = p.evidence;
44642
- const pr = evidence.pullRequest;
44643
- differ("/predicate/subject/pullRequest", p.subject.pullRequest, pr.number);
44644
- differ("/predicate/subject/headSha", p.subject.headSha, pr.headSha);
44645
- differ("/predicate/subject/mergeSha", p.subject.mergeSha, pr.mergeSha);
44646
- differ("/subject", record2.subject, statementSubjects(p.block, p.subject));
44647
- const claims = claimsFor(p.block, evidence, p.subject.repository);
44648
- if (!same(p.claims, claims)) {
44649
- const index = claims.findIndex((c, i) => !same(c, p.claims[i]));
44650
- const at2 = index === -1 ? claims.length : index;
44651
- problems.push({
44652
- member: `/predicate/claims/${at2}`,
44653
- message: `recorded ${canonicalize(p.claims[at2] ?? null)}, recomputed ${canonicalize(claims[at2] ?? null)}`
44654
- });
44655
- }
44656
- differ("/predicate/verdict", p.verdict, overallVerdict(p.block.status, claims));
44657
- differ(
44658
- "/predicate/digests/claims",
44659
- p.digests.claims,
44660
- claimsDigest(claims)
44661
- );
44662
- differ(
44663
- "/predicate/digests/evidence",
44664
- p.digests.evidence,
44665
- evidenceDigest(evidence)
44666
- );
44667
- if (draft && p.readerClaims !== void 0) {
44668
- p.readerClaims.forEach((claim2, i) => {
44669
- const { observed, verdict, reason } = claim2;
44670
- differ(
44671
- `/predicate/readerClaims/${i}`,
44672
- reason === void 0 ? { observed, verdict } : { observed, verdict, reason },
44673
- checkReaderClaim(claim2, evidence, p.subject.repository)
44674
- );
44675
- });
44676
- differ(
44677
- "/predicate/digests/readerClaims",
44678
- p.digests.readerClaims,
44679
- readerClaimsDigest(p.readerClaims)
44680
- );
44681
- }
44682
- if (draft && p.advisory !== void 0) {
44683
- const a = p.advisory;
44684
- const pathsAtHead = a.pathsAtHead;
44685
- differ("/predicate/advisory/extractor", a.extractor, EXTRACTOR);
44686
- differ("/predicate/advisory/comparison", a.comparison, COMPARISON);
44687
- differ("/predicate/advisory/precision", a.precision, precisionFor(EXTRACTOR.digest.sha256));
44688
- differ(
44689
- "/predicate/advisory/differsAccuracy",
44690
- a.differsAccuracy,
44691
- differsAccuracyFor(EXTRACTOR.digest.sha256, COMPARISON.version)
44692
- );
44693
- differ(
44694
- "/predicate/advisory/pathsAtHead",
44695
- pathsAtHead.map((answer) => answer.path),
44696
- pathsToRead(a.advisories, evidence, p.subject.repository)
44697
- );
44698
- a.advisories.forEach((advisory, i) => {
44699
- differ(
44700
- `/predicate/advisory/advisories/${i}`,
44701
- { observed: advisory.observed, note: advisory.note },
44702
- compareAdvisory(advisory, evidence, p.subject.repository, pathsAtHead)
44703
- );
44704
- });
44705
- differ("/predicate/digests/advisory", p.digests.advisory, advisoryDigest(a));
44706
- }
44707
- return { ok: problems.length === 0, problems, record: record2 };
44708
- }
44709
-
44710
45814
  // src/survey/verify.ts
44711
45815
  var text = (value) => canonicalize(value ?? null);
44712
45816
  function differences(a, b, pointer) {
@@ -45017,6 +46121,10 @@ usage:
45017
46121
  (--report-file <path|-> | --report-pr-body | --report-comment-author <login>)
45018
46122
  [--out <record.json>] [--sign-key <path> --signer <principal>]
45019
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>]...
45020
46128
  dunstan verify <record.json> [--sig <file> --allowed-signers <file>]
45021
46129
  dunstan rerun <record.json> [--out <record.json>] [--exclude-check-run <id>]...
45022
46130
  dunstan template [--base <ref>]
@@ -45036,8 +46144,13 @@ check reads the pull request, the named report and the evidence its block ne
45036
46144
  record and prints the claims table. GITHUB_TOKEN is read from the environment.
45037
46145
  --advisory also reads the report's prose for asserted claims and records them as
45038
46146
  advisories (DRAFT 0.2.0, docs/advisory.md). They never change the verdict.
45039
- 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.
45040
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.
45041
46154
  template local git: prints a dunstan-handback block for HEAD, its files against the merge base
45042
46155
  with --base (default: the remote's default branch). Tests: --tests-ci reads the counts
45043
46156
  at HEAD from the CI record check reads (artifact dunstan-junit, file junit.xml unless