dunstan 0.1.13 → 0.1.15

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 +90 -4
  2. package/dist/dunstan.mjs +1700 -376
  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 str2(strs, ...args) {
104
+ function str3(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 = str2;
115
+ exports.str = str3;
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 : str2`${c1}${c2}`;
158
+ return c2.emptyStr() ? c1 : c1.emptyStr() ? c2 : str3`${c1}${c2}`;
159
159
  }
160
160
  exports.strConcat = strConcat;
161
161
  function interpolate(x) {
@@ -246,13 +246,13 @@ var require_scope = __commonJS({
246
246
  }
247
247
  };
248
248
  exports.ValueScopeName = ValueScopeName;
249
- var line = (0, code_1._)`\n`;
249
+ var line2 = (0, code_1._)`\n`;
250
250
  var ValueScope = class extends Scope {
251
251
  constructor(opts) {
252
252
  super(opts);
253
253
  this._values = {};
254
254
  this._scope = opts.scope;
255
- this.opts = { ...opts, _n: opts.lines ? line : code_1.nil };
255
+ this.opts = { ...opts, _n: opts.lines ? line2 : code_1.nil };
256
256
  }
257
257
  get() {
258
258
  return this._scope;
@@ -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(str2) {
1121
- return unescapeJsonPointer(decodeURIComponent(str2));
1120
+ function unescapeFragment(str3) {
1121
+ return unescapeJsonPointer(decodeURIComponent(str3));
1122
1122
  }
1123
1123
  exports.unescapeFragment = unescapeFragment;
1124
- function escapeFragment(str2) {
1125
- return encodeURIComponent(escapeJsonPointer(str2));
1124
+ function escapeFragment(str3) {
1125
+ return encodeURIComponent(escapeJsonPointer(str3));
1126
1126
  }
1127
1127
  exports.escapeFragment = escapeFragment;
1128
- function escapeJsonPointer(str2) {
1129
- if (typeof str2 == "number")
1130
- return `${str2}`;
1131
- return str2.replace(/~/g, "~0").replace(/\//g, "~1");
1128
+ function escapeJsonPointer(str3) {
1129
+ if (typeof str3 == "number")
1130
+ return `${str3}`;
1131
+ return str3.replace(/~/g, "~0").replace(/\//g, "~1");
1132
1132
  }
1133
1133
  exports.escapeJsonPointer = escapeJsonPointer;
1134
- function unescapeJsonPointer(str2) {
1135
- return str2.replace(/~1/g, "/").replace(/~0/g, "~");
1134
+ function unescapeJsonPointer(str3) {
1135
+ return str3.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(str2) {
2161
- return str2.replace(/~/g, "~0").replace(/\//g, "~1");
2160
+ function escapeJsonPtr(str3) {
2161
+ return str3.replace(/~/g, "~0").replace(/\//g, "~1");
2162
2162
  }
2163
2163
  }
2164
2164
  });
@@ -2984,7 +2984,7 @@ var require_compile = __commonJS({
2984
2984
  const schOrFunc = root.refs[ref];
2985
2985
  if (schOrFunc)
2986
2986
  return schOrFunc;
2987
- let _sch = resolve4.call(this, root, ref);
2987
+ let _sch = resolve5.call(this, root, ref);
2988
2988
  if (_sch === void 0) {
2989
2989
  const schema = (_a3 = root.localRefs) === null || _a3 === void 0 ? void 0 : _a3[ref];
2990
2990
  const { schemaId: schemaId2 } = this.opts;
@@ -3011,7 +3011,7 @@ var require_compile = __commonJS({
3011
3011
  function sameSchemaEnv(s1, s2) {
3012
3012
  return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
3013
3013
  }
3014
- function resolve4(root, ref) {
3014
+ function resolve5(root, ref) {
3015
3015
  let sch;
3016
3016
  while (typeof (sch = this.refs[ref]) == "string")
3017
3017
  ref = sch;
@@ -3255,10 +3255,10 @@ var require_utils = __commonJS({
3255
3255
  isIPV6: true
3256
3256
  };
3257
3257
  }
3258
- function findToken(str2, token) {
3258
+ function findToken(str3, token) {
3259
3259
  let ind = 0;
3260
- for (let i = 0; i < str2.length; i++) {
3261
- if (str2[i] === token) ind++;
3260
+ for (let i = 0; i < str3.length; i++) {
3261
+ if (str3[i] === token) ind++;
3262
3262
  }
3263
3263
  return ind;
3264
3264
  }
@@ -3841,7 +3841,7 @@ var require_fast_uri = __commonJS({
3841
3841
  }
3842
3842
  return uri;
3843
3843
  }
3844
- function resolve4(baseURI, relativeURI, options) {
3844
+ function resolve5(baseURI, relativeURI, options) {
3845
3845
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
3846
3846
  const {
3847
3847
  parsed: baseParsed,
@@ -4210,7 +4210,7 @@ var require_fast_uri = __commonJS({
4210
4210
  var fastUri = {
4211
4211
  SCHEMES,
4212
4212
  normalize,
4213
- resolve: resolve4,
4213
+ resolve: resolve5,
4214
4214
  resolveComponent,
4215
4215
  equal,
4216
4216
  serialize: serialize2,
@@ -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 = (str2, flags) => new RegExp(str2, flags);
4275
+ var defaultRegExp = (str3, flags) => new RegExp(str3, 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([
@@ -4625,7 +4625,7 @@ var require_core = __commonJS({
4625
4625
  errorsText(errors = this.errors, { separator = ", ", dataVar = "data" } = {}) {
4626
4626
  if (!errors || errors.length === 0)
4627
4627
  return "No errors";
4628
- return errors.map((e) => `${dataVar}${e.instancePath} ${e.message}`).reduce((text, msg) => text + separator + msg);
4628
+ return errors.map((e) => `${dataVar}${e.instancePath} ${e.message}`).reduce((text2, msg) => text2 + separator + msg);
4629
4629
  }
4630
4630
  $dataMetaSchema(metaSchema, keywordsJsonPointers) {
4631
4631
  const rules = this.RULES.all;
@@ -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(str2) {
5071
- const len = str2.length;
5070
+ function ucs2length(str3) {
5071
+ const len = str3.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 = str2.charCodeAt(pos++);
5077
+ value = str3.charCodeAt(pos++);
5078
5078
  if (value >= 55296 && value <= 56319 && pos < len) {
5079
- value = str2.charCodeAt(pos);
5079
+ value = str3.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(str2) {
7538
- const matches = DATE.exec(str2);
7537
+ function date5(str3) {
7538
+ const matches = DATE.exec(str3);
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(str2) {
7558
- const matches = TIME.exec(str2);
7557
+ return function time3(str3) {
7558
+ const matches = TIME.exec(str3);
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(str2) {
7605
- const dateTime = str2.split(DATE_TIME_SEPARATOR);
7604
+ return function date_time(str3) {
7605
+ const dateTime = str3.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(str2) {
7631
- return NOT_URI_FRAGMENT.test(str2) && URI.test(str2);
7630
+ function uri(str3) {
7631
+ return NOT_URI_FRAGMENT.test(str3) && URI.test(str3);
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(str2) {
7634
+ function byte(str3) {
7635
7635
  BYTE.lastIndex = 0;
7636
- return BYTE.test(str2);
7636
+ return BYTE.test(str3);
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(str2) {
7651
- if (Z_ANCHOR.test(str2))
7650
+ function regex(str3) {
7651
+ if (Z_ANCHOR.test(str3))
7652
7652
  return false;
7653
7653
  try {
7654
- new RegExp(str2);
7654
+ new RegExp(str3);
7655
7655
  return true;
7656
7656
  } catch (e) {
7657
7657
  return false;
@@ -8024,13 +8024,13 @@ var require_dist = __commonJS({
8024
8024
  });
8025
8025
 
8026
8026
  // src/cli/bin.ts
8027
- import { readFileSync as readFileSync7 } from "node:fs";
8027
+ import { readFileSync as readFileSync8 } from "node:fs";
8028
8028
 
8029
8029
  // src/spec/jcs.ts
8030
8030
  import { createHash } from "node:crypto";
8031
8031
 
8032
8032
  // src/spec/constants.ts
8033
- var SPEC_VERSION = "0.1.8";
8033
+ var SPEC_VERSION = "0.1.9";
8034
8034
  var BLOCK_VERSION = "0.1";
8035
8035
  var SUPPORTED_BLOCK_VERSIONS = [BLOCK_VERSION];
8036
8036
  var BLOCK_INFO_STRING = "dunstan-handback";
@@ -8057,11 +8057,11 @@ var NUMBER = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/y;
8057
8057
  function escapePointerToken(token) {
8058
8058
  return token.replaceAll("~", "~0").replaceAll("/", "~1");
8059
8059
  }
8060
- function isWellFormedUtf16(text) {
8061
- for (let i = 0; i < text.length; i++) {
8062
- const unit = text.charCodeAt(i);
8060
+ function isWellFormedUtf16(text2) {
8061
+ for (let i = 0; i < text2.length; i++) {
8062
+ const unit = text2.charCodeAt(i);
8063
8063
  if (unit >= 55296 && unit <= 56319) {
8064
- const next = text.charCodeAt(i + 1);
8064
+ const next = text2.charCodeAt(i + 1);
8065
8065
  if (!(next >= 56320 && next <= 57343)) return false;
8066
8066
  i++;
8067
8067
  } else if (unit >= 56320 && unit <= 57343) {
@@ -8070,14 +8070,14 @@ function isWellFormedUtf16(text) {
8070
8070
  }
8071
8071
  return true;
8072
8072
  }
8073
- function parseStrictJson(text) {
8073
+ function parseStrictJson(text2) {
8074
8074
  let pos = 0;
8075
8075
  const fail2 = (message) => {
8076
8076
  throw new JsonReadError("invalid_json", `${message} at offset ${pos}`);
8077
8077
  };
8078
8078
  const skipWhitespace = () => {
8079
- while (pos < text.length) {
8080
- const c = text[pos];
8079
+ while (pos < text2.length) {
8080
+ const c = text2[pos];
8081
8081
  if (c === " " || c === " " || c === "\n" || c === "\r") pos++;
8082
8082
  else break;
8083
8083
  }
@@ -8086,8 +8086,8 @@ function parseStrictJson(text) {
8086
8086
  pos++;
8087
8087
  let out = "";
8088
8088
  while (true) {
8089
- if (pos >= text.length) fail2("unterminated string");
8090
- const c = text[pos];
8089
+ if (pos >= text2.length) fail2("unterminated string");
8090
+ const c = text2[pos];
8091
8091
  const unit = c.charCodeAt(0);
8092
8092
  if (c === '"') {
8093
8093
  pos++;
@@ -8099,7 +8099,7 @@ function parseStrictJson(text) {
8099
8099
  pos++;
8100
8100
  continue;
8101
8101
  }
8102
- const e = text[pos + 1];
8102
+ const e = text2[pos + 1];
8103
8103
  pos += 2;
8104
8104
  switch (e) {
8105
8105
  case '"':
@@ -8127,7 +8127,7 @@ function parseStrictJson(text) {
8127
8127
  out += " ";
8128
8128
  break;
8129
8129
  case "u": {
8130
- const hex3 = text.slice(pos, pos + 4);
8130
+ const hex3 = text2.slice(pos, pos + 4);
8131
8131
  if (!/^[0-9a-fA-F]{4}$/.test(hex3)) fail2("bad \\u escape");
8132
8132
  out += String.fromCharCode(Number.parseInt(hex3, 16));
8133
8133
  pos += 4;
@@ -8143,26 +8143,26 @@ function parseStrictJson(text) {
8143
8143
  };
8144
8144
  const readValue = (pointer, depth) => {
8145
8145
  skipWhitespace();
8146
- const c = text[pos];
8146
+ const c = text2[pos];
8147
8147
  if (c === "{" || c === "[") {
8148
8148
  if (depth + 1 > MAX_BLOCK_DEPTH) fail2(`nesting deeper than ${MAX_BLOCK_DEPTH}`);
8149
8149
  return c === "{" ? readObject(pointer, depth + 1) : readArray(pointer, depth + 1);
8150
8150
  }
8151
8151
  if (c === '"') return readString();
8152
- if (text.startsWith("true", pos)) {
8152
+ if (text2.startsWith("true", pos)) {
8153
8153
  pos += 4;
8154
8154
  return true;
8155
8155
  }
8156
- if (text.startsWith("false", pos)) {
8156
+ if (text2.startsWith("false", pos)) {
8157
8157
  pos += 5;
8158
8158
  return false;
8159
8159
  }
8160
- if (text.startsWith("null", pos)) {
8160
+ if (text2.startsWith("null", pos)) {
8161
8161
  pos += 4;
8162
8162
  return null;
8163
8163
  }
8164
8164
  NUMBER.lastIndex = pos;
8165
- const match = NUMBER.exec(text);
8165
+ const match = NUMBER.exec(text2);
8166
8166
  if (match === null) return fail2("unexpected character");
8167
8167
  pos += match[0].length;
8168
8168
  const n = Number(match[0]);
@@ -8173,14 +8173,14 @@ function parseStrictJson(text) {
8173
8173
  pos++;
8174
8174
  const out = [];
8175
8175
  skipWhitespace();
8176
- if (text[pos] === "]") {
8176
+ if (text2[pos] === "]") {
8177
8177
  pos++;
8178
8178
  return out;
8179
8179
  }
8180
8180
  while (true) {
8181
8181
  out.push(readValue(`${pointer}/${out.length}`, depth));
8182
8182
  skipWhitespace();
8183
- const c = text[pos];
8183
+ const c = text2[pos];
8184
8184
  pos++;
8185
8185
  if (c === "]") return out;
8186
8186
  if (c !== ",") {
@@ -8194,13 +8194,13 @@ function parseStrictJson(text) {
8194
8194
  const entries = [];
8195
8195
  const seen = /* @__PURE__ */ new Set();
8196
8196
  skipWhitespace();
8197
- if (text[pos] === "}") {
8197
+ if (text2[pos] === "}") {
8198
8198
  pos++;
8199
8199
  return {};
8200
8200
  }
8201
8201
  while (true) {
8202
8202
  skipWhitespace();
8203
- if (text[pos] !== '"') fail2("expected a member name");
8203
+ if (text2[pos] !== '"') fail2("expected a member name");
8204
8204
  const key = readString();
8205
8205
  const memberPointer = `${pointer}/${escapePointerToken(key)}`;
8206
8206
  if (seen.has(key)) {
@@ -8212,11 +8212,11 @@ function parseStrictJson(text) {
8212
8212
  }
8213
8213
  seen.add(key);
8214
8214
  skipWhitespace();
8215
- if (text[pos] !== ":") fail2("expected ':'");
8215
+ if (text2[pos] !== ":") fail2("expected ':'");
8216
8216
  pos++;
8217
8217
  entries.push([key, readValue(memberPointer, depth)]);
8218
8218
  skipWhitespace();
8219
- const c = text[pos];
8219
+ const c = text2[pos];
8220
8220
  pos++;
8221
8221
  if (c === "}") return Object.fromEntries(entries);
8222
8222
  if (c !== ",") {
@@ -8227,7 +8227,7 @@ function parseStrictJson(text) {
8227
8227
  };
8228
8228
  const value = readValue("", 0);
8229
8229
  skipWhitespace();
8230
- if (pos !== text.length) fail2("unexpected content after the JSON value");
8230
+ if (pos !== text2.length) fail2("unexpected content after the JSON value");
8231
8231
  return value;
8232
8232
  }
8233
8233
 
@@ -8421,10 +8421,10 @@ function findReference(evidence, kind, ref) {
8421
8421
  (r) => r.kind === kind && (kind === "issue" ? sameIssue(r.ref, ref) : r.ref === ref)
8422
8422
  );
8423
8423
  }
8424
- function closedBy(reference, field, evidence, repository, at, observed) {
8424
+ function closedBy(reference, field, evidence, repository, at2, observed) {
8425
8425
  const pr = evidence.pullRequest;
8426
8426
  const section = evidence.closeEvents;
8427
- const target = section?.targets.find((t) => sameIssue(t.ref, at));
8427
+ const target = section?.targets.find((t) => sameIssue(t.ref, at2));
8428
8428
  const side = section?.pullRequest;
8429
8429
  let unread2;
8430
8430
  if (section === void 0 || target === void 0) unread2 = absentSection("closeEvents");
@@ -8468,10 +8468,10 @@ function closedBy(reference, field, evidence, repository, at, observed) {
8468
8468
  "not_closing"
8469
8469
  );
8470
8470
  }
8471
- function closesBlock(issue2, headCommit) {
8471
+ function closesBlock(issue2, headCommit2) {
8472
8472
  return {
8473
8473
  dunstan: BLOCK_VERSION,
8474
- headCommit,
8474
+ headCommit: headCommit2,
8475
8475
  filesChanged: [],
8476
8476
  references: [{ issue: issue2, relation: "closes" }]
8477
8477
  };
@@ -8559,11 +8559,10 @@ function checkTests(block, evidence) {
8559
8559
  const countField = `/tests/${i}/count`;
8560
8560
  const failuresField = `/tests/${i}/failures`;
8561
8561
  if (test.record.kind !== "junit") {
8562
- rows.push(unverifiable("count", countField, test.count, "no_comparable_record_field"));
8562
+ const reason = test.record.kind === "local" ? "local_run_not_in_record" : "no_comparable_record_field";
8563
+ rows.push(unverifiable("count", countField, test.count, reason));
8563
8564
  if (test.failures !== void 0) {
8564
- rows.push(
8565
- unverifiable("count", failuresField, test.failures, "no_comparable_record_field")
8566
- );
8565
+ rows.push(unverifiable("count", failuresField, test.failures, reason));
8567
8566
  }
8568
8567
  return;
8569
8568
  }
@@ -8617,7 +8616,7 @@ function countedCheckRuns(runs, excludedIds) {
8617
8616
  return [...latest.values()].filter((run) => !excluded.has(run.id) && !superseded(run)).sort((a, b) => a.id - b.id);
8618
8617
  }
8619
8618
  var NOT_FAILED_CONCLUSIONS = ["success", "skipped", "neutral"];
8620
- function booleanRow(field, declared, reason, runs, failed, mismatch, unsettled) {
8619
+ function booleanRow(field, declared, reason, runs, failed, mismatch, unsettled2) {
8621
8620
  if (reason !== void 0) return unverifiable("count", field, declared, reason);
8622
8621
  if (runs.length === 0) return unverifiable("count", field, declared, "no_check_runs");
8623
8622
  let observed;
@@ -8625,7 +8624,7 @@ function booleanRow(field, declared, reason, runs, failed, mismatch, unsettled)
8625
8624
  else if (runs.some((r) => r.status !== "completed")) {
8626
8625
  return unverifiable("count", field, declared, "checks_incomplete");
8627
8626
  } else {
8628
- const why = unsettled?.(runs);
8627
+ const why = unsettled2?.(runs);
8629
8628
  if (why !== void 0) return unverifiable("count", field, declared, why);
8630
8629
  observed = true;
8631
8630
  }
@@ -8688,8 +8687,8 @@ function checkMergedAt(block, evidence) {
8688
8687
  }
8689
8688
  return toSecond(declared) < toSecond(pr.mergedAt) ? [fail("time", "/mergedAt", declared, pr.mergedAt, "premature")] : [pass("time", "/mergedAt", declared, pr.mergedAt)];
8690
8689
  }
8691
- function earliestSuccess(section, relations, at) {
8692
- const times = section.deployments.filter((d) => relations.includes(d.relation) && d.successAt !== null).map((d) => d.successAt).filter((t) => toSecond(t) <= toSecond(at)).sort((a, b) => compareCodeUnits(toSecond(a), toSecond(b)) || compareCodeUnits(a, b));
8690
+ function earliestSuccess(section, relations, at2) {
8691
+ const times = section.deployments.filter((d) => relations.includes(d.relation) && d.successAt !== null).map((d) => d.successAt).filter((t) => toSecond(t) <= toSecond(at2)).sort((a, b) => compareCodeUnits(toSecond(a), toSecond(b)) || compareCodeUnits(a, b));
8693
8692
  return times[0];
8694
8693
  }
8695
8694
  function checkDeployedAt(block, evidence) {
@@ -8731,21 +8730,27 @@ var JunitError = class extends Error {
8731
8730
  this.name = "JunitError";
8732
8731
  }
8733
8732
  };
8733
+ function countJunit(xml) {
8734
+ const { executed, failed } = scanJunit(xml);
8735
+ return { executed, failed };
8736
+ }
8734
8737
  var NAME = /[A-Za-z_:][-A-Za-z0-9_:.]*/y;
8735
8738
  var ENTITY = /&(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9A-Fa-f]+);/y;
8736
- function countJunit(xml) {
8739
+ function scanJunit(xml) {
8737
8740
  let pos = xml.startsWith("\uFEFF") ? 1 : 0;
8738
8741
  const stack = [];
8739
8742
  let roots = 0;
8740
8743
  let executed = 0;
8741
8744
  let failed = 0;
8745
+ let tagStart = 0;
8746
+ let root;
8742
8747
  const fail2 = (message) => {
8743
8748
  throw new JunitError(`${message} at offset ${pos}`);
8744
8749
  };
8745
- const checkText = (text, start) => {
8746
- for (let i = text.indexOf("&"); i !== -1; i = text.indexOf("&", i + 1)) {
8750
+ const checkText = (text2, start) => {
8751
+ for (let i = text2.indexOf("&"); i !== -1; i = text2.indexOf("&", i + 1)) {
8747
8752
  ENTITY.lastIndex = i;
8748
- if (!ENTITY.test(text)) {
8753
+ if (!ENTITY.test(text2)) {
8749
8754
  pos = start + i;
8750
8755
  fail2("bad entity reference");
8751
8756
  }
@@ -8766,6 +8771,13 @@ function countJunit(xml) {
8766
8771
  roots++;
8767
8772
  if (roots > 1) fail2("more than one root element");
8768
8773
  if (name !== "testsuites" && name !== "testsuite") fail2(`root element is <${name}>`);
8774
+ root = {
8775
+ name,
8776
+ start: tagStart,
8777
+ end: pos,
8778
+ contentStart: pos,
8779
+ contentEnd: pos
8780
+ };
8769
8781
  }
8770
8782
  const parent = stack.at(-1);
8771
8783
  if (parent?.testcase !== void 0) {
@@ -8784,16 +8796,21 @@ function countJunit(xml) {
8784
8796
  executed++;
8785
8797
  if (testcase.failed) failed++;
8786
8798
  }
8799
+ if (stack.length === 0 && root !== void 0 && root.end < pos) {
8800
+ root.contentEnd = tagStart;
8801
+ root.end = pos;
8802
+ }
8787
8803
  };
8788
8804
  while (pos < xml.length) {
8789
8805
  const lt = xml.indexOf("<", pos);
8790
8806
  const textEnd = lt === -1 ? xml.length : lt;
8791
- const text = xml.slice(pos, textEnd);
8792
- if (stack.length === 0 && text.trim() !== "") fail2("text outside the root element");
8793
- checkText(text, pos);
8794
- if (text.includes("]]>")) fail2("']]>' in text");
8807
+ const text2 = xml.slice(pos, textEnd);
8808
+ if (stack.length === 0 && text2.trim() !== "") fail2("text outside the root element");
8809
+ checkText(text2, pos);
8810
+ if (text2.includes("]]>")) fail2("']]>' in text");
8795
8811
  pos = textEnd;
8796
8812
  if (lt === -1) break;
8813
+ tagStart = pos;
8797
8814
  if (xml.startsWith("<!--", pos)) {
8798
8815
  const end = xml.indexOf("-->", pos + 4);
8799
8816
  if (end === -1) fail2("unterminated comment");
@@ -8854,8 +8871,8 @@ function countJunit(xml) {
8854
8871
  }
8855
8872
  }
8856
8873
  if (stack.length > 0) fail2(`unclosed <${stack.at(-1)?.name}>`);
8857
- if (roots === 0) fail2("no root element");
8858
- return { executed, failed };
8874
+ if (roots === 0 || root === void 0) return fail2("no root element");
8875
+ return { executed, failed, root };
8859
8876
  }
8860
8877
 
8861
8878
  // src/evidence/zip.ts
@@ -8975,11 +8992,11 @@ function splitRepo(repository) {
8975
8992
  return { owner, name };
8976
8993
  }
8977
8994
  function timestamp(value) {
8978
- const text = str(value);
8979
- if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z$/.test(text)) {
8995
+ const text2 = str(value);
8996
+ if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z$/.test(text2)) {
8980
8997
  throw new ShapeError();
8981
8998
  }
8982
- return text;
8999
+ return text2;
8983
9000
  }
8984
9001
  function timestampOrNull(value) {
8985
9002
  return value === null || value === void 0 ? null : timestamp(value);
@@ -9220,10 +9237,10 @@ async function readGraphqlPages(client, kind, locator, query, variables, budget,
9220
9237
  let head;
9221
9238
  let after = null;
9222
9239
  for (let n = 1; n <= budget; n++) {
9223
- const at = `${locator}${after === null ? "" : `(after:${after})`}`;
9240
+ const at2 = `${locator}${after === null ? "" : `(after:${after})`}`;
9224
9241
  const read = await client.read(kind, "/graphql", {
9225
9242
  method: "POST",
9226
- locator: at,
9243
+ locator: at2,
9227
9244
  requestBody: JSON.stringify({ query, variables: { ...variables, after } })
9228
9245
  });
9229
9246
  if (!read.ok) return unreadable(kind);
@@ -9239,7 +9256,7 @@ async function readGraphqlPages(client, kind, locator, query, variables, budget,
9239
9256
  after = str(info.endCursor);
9240
9257
  } catch (e) {
9241
9258
  if (!(e instanceof ShapeError)) throw e;
9242
- client.markParseError(at);
9259
+ client.markParseError(at2);
9243
9260
  return unreadable(kind);
9244
9261
  }
9245
9262
  }
@@ -9291,17 +9308,17 @@ var TARGET_STATES = {
9291
9308
  };
9292
9309
  function closeEvent(node2) {
9293
9310
  const n = obj(node2);
9294
- const at = timestamp(n.createdAt);
9295
- if (n.closer === null) return { at, closer: null };
9311
+ const at2 = timestamp(n.createdAt);
9312
+ if (n.closer === null) return { at: at2, closer: null };
9296
9313
  const closer = obj(n.closer);
9297
9314
  const type = str(closer.__typename);
9298
9315
  if (type === "PullRequest") {
9299
9316
  const ref = `${str(obj(closer.repository).nameWithOwner)}#${int(closer.number)}`;
9300
9317
  if (!ISSUE_REF.test(ref)) throw new ShapeError();
9301
- return { at, closer: { kind: "pull_request", ref } };
9318
+ return { at: at2, closer: { kind: "pull_request", ref } };
9302
9319
  }
9303
- if (type === "Commit") return { at, closer: { kind: "commit", oid: commitOid(closer.oid) } };
9304
- return { at, closer: { kind: "other" } };
9320
+ if (type === "Commit") return { at: at2, closer: { kind: "commit", oid: commitOid(closer.oid) } };
9321
+ return { at: at2, closer: { kind: "other" } };
9305
9322
  }
9306
9323
  async function readCloseTarget(client, ref) {
9307
9324
  const [repo, num] = ref.split("#");
@@ -9342,9 +9359,9 @@ function closeTargets(block, repository, closing, references) {
9342
9359
  if (listed2(target)) continue;
9343
9360
  const entry = references?.find((r) => r.kind === "issue" && sameIssue(r.ref, target));
9344
9361
  if (entry?.kind !== "issue" || entry.status !== "ok" || !entry.exists) continue;
9345
- const at = entry.resolvedAs ?? target;
9346
- if (listed2(at) || out.some((o) => sameIssue(o, at))) continue;
9347
- out.push(at);
9362
+ const at2 = entry.resolvedAs ?? target;
9363
+ if (listed2(at2) || out.some((o) => sameIssue(o, at2))) continue;
9364
+ out.push(at2);
9348
9365
  }
9349
9366
  return out;
9350
9367
  }
@@ -9584,9 +9601,9 @@ function withinLatestAttempt(jobs, run, createdAt) {
9584
9601
  (job) => (job.attempt === null || job.attempt <= run.attempt) && job.startedAt !== null && job.completedAt !== null && second(job.startedAt) <= created && created <= second(job.completedAt)
9585
9602
  );
9586
9603
  }
9587
- async function readTestRecord(client, repository, record2, headCommit) {
9604
+ async function readTestRecord(client, repository, record2, headCommit2, observed) {
9588
9605
  const workflowFile = record2.workflow.split("/").at(-1);
9589
- const runsPath = (page) => `/repos/${repository}/actions/workflows/${encodeURIComponent(workflowFile)}/runs?head_sha=${headCommit}&per_page=${PER_PAGE}&page=${page}`;
9606
+ const runsPath = (page) => `/repos/${repository}/actions/workflows/${encodeURIComponent(workflowFile)}/runs?head_sha=${headCommit2}&per_page=${PER_PAGE}&page=${page}`;
9590
9607
  const listed2 = await readAllPages(
9591
9608
  client,
9592
9609
  "workflow_runs",
@@ -9594,12 +9611,24 @@ async function readTestRecord(client, repository, record2, headCommit) {
9594
9611
  (json2) => arr(obj(json2).workflow_runs),
9595
9612
  (item) => {
9596
9613
  const r = obj(item);
9597
- return { id: int(r.id), attempt: int(r.run_attempt), path: str(r.path) };
9614
+ return {
9615
+ id: int(r.id),
9616
+ attempt: int(r.run_attempt),
9617
+ path: str(r.path),
9618
+ // Read only for `observed`, and never a shape error: a status that is not a string is not
9619
+ // `completed`.
9620
+ completed: r.status === "completed"
9621
+ };
9598
9622
  },
9599
9623
  // No workflow with that file name: there is no run, so no candidate run.
9600
9624
  { notFoundIsEmpty: true }
9601
9625
  );
9602
9626
  if (listed2 === null) return { record: record2, ...unreadable("workflow_runs") };
9627
+ if (observed !== void 0) {
9628
+ observed.runsNotCompleted = listed2.filter(
9629
+ (r) => r.path.replace(/@.*$/, "") === record2.workflow && !r.completed
9630
+ ).length;
9631
+ }
9603
9632
  const runs = [];
9604
9633
  for (const run of listed2.filter((r) => r.path.replace(/@.*$/, "") === record2.workflow)) {
9605
9634
  const jobs = await readAllPages(
@@ -9691,7 +9720,7 @@ async function readDeployments(client, repository, pr, deployedAt) {
9691
9720
  for (const d of ofHead) candidates.set(d.id, d);
9692
9721
  if (pr.merged && pr.mergedAt !== null && pr.mergeSha !== null) {
9693
9722
  const mergedAt = second(pr.mergedAt);
9694
- const at = second(deployedAt.at);
9723
+ const at2 = second(deployedAt.at);
9695
9724
  const window = await readAllPages(
9696
9725
  client,
9697
9726
  "deployments",
@@ -9703,7 +9732,7 @@ async function readDeployments(client, repository, pr, deployedAt) {
9703
9732
  if (window === null) return fail2("deployments");
9704
9733
  for (const d of window) {
9705
9734
  const created = second(d.createdAt);
9706
- if (created >= mergedAt && created <= at) candidates.set(d.id, d);
9735
+ if (created >= mergedAt && created <= at2) candidates.set(d.id, d);
9707
9736
  }
9708
9737
  }
9709
9738
  const deployments = [];
@@ -9852,7 +9881,7 @@ async function reportFromComment(client, repository, number4, login) {
9852
9881
  // src/record/checker.ts
9853
9882
  import { readFileSync } from "node:fs";
9854
9883
  var CHECKER_NAME = "dunstan";
9855
- var CHECKER_VERSION = "0.1.13";
9884
+ var CHECKER_VERSION = "0.1.15";
9856
9885
  function checkerIdentity(artifact) {
9857
9886
  return {
9858
9887
  name: CHECKER_NAME,
@@ -9962,16 +9991,35 @@ var FILES = { "handback-block-0.1.schema.json": `{
9962
9991
  }
9963
9992
  },
9964
9993
  "testRecord": {
9965
- "description": "Names the machine-readable test record a count is compared with. v0.1 defines the junit kind. Any other kind is legal and yields an unverifiable claim.",
9994
+ "description": "Names the machine-readable test record a count is compared with. v0.1 defines the junit kind and, since spec 0.1.9, the local kind. Any other kind is legal and yields an unverifiable claim.",
9966
9995
  "type": "object",
9967
9996
  "required": ["kind"],
9968
9997
  "properties": {
9969
9998
  "kind": { "type": "string", "minLength": 1, "maxLength": 100 }
9970
9999
  },
9971
- "if": {
9972
- "properties": { "kind": { "const": "junit" } }
9973
- },
9974
- "then": { "$ref": "#/$defs/junitRecord" }
10000
+ "allOf": [
10001
+ {
10002
+ "if": {
10003
+ "properties": { "kind": { "const": "junit" } }
10004
+ },
10005
+ "then": { "$ref": "#/$defs/junitRecord" }
10006
+ },
10007
+ {
10008
+ "if": {
10009
+ "properties": { "kind": { "const": "local" } }
10010
+ },
10011
+ "then": { "$ref": "#/$defs/localRecord" }
10012
+ }
10013
+ ]
10014
+ },
10015
+ "localRecord": {
10016
+ "description": "Counts from a run that left no record in CI, declared as such (spec 0.1.9). Exactly the kind; its claims are unverifiable with local_run_not_in_record.",
10017
+ "type": "object",
10018
+ "required": ["kind"],
10019
+ "additionalProperties": false,
10020
+ "properties": {
10021
+ "kind": { "const": "local" }
10022
+ }
9975
10023
  },
9976
10024
  "junitRecord": {
9977
10025
  "type": "object",
@@ -10029,7 +10077,7 @@ var FILES = { "handback-block-0.1.schema.json": `{
10029
10077
  }
10030
10078
  }
10031
10079
  }
10032
- `, "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|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": `{
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": `{
10033
10081
  "$schema": "https://json-schema.org/draft/2020-12/schema",
10034
10082
  "$id": "https://barglabs.ai/dunstan/spec/schema/record-0.2-draft.schema.json",
10035
10083
  "title": "Dunstan record, DRAFT 0.2.0",
@@ -10519,11 +10567,11 @@ var FILES = { "handback-block-0.1.schema.json": `{
10519
10567
  }
10520
10568
  }
10521
10569
  }
10522
- ` };
10570
+ `, "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' };
10523
10571
  function schemaText(file2) {
10524
- const text = FILES[file2];
10525
- if (text === void 0) throw new Error(`no schema ${file2} in this build`);
10526
- return text;
10572
+ const text2 = FILES[file2];
10573
+ if (text2 === void 0) throw new Error(`no schema ${file2} in this build`);
10574
+ return text2;
10527
10575
  }
10528
10576
 
10529
10577
  // src/spec/schema-validators.ts
@@ -10592,30 +10640,40 @@ function validateDraftRecord(value) {
10592
10640
  }
10593
10641
  return draftRecord(value) ? [] : toSchemaErrors(draftRecord.errors);
10594
10642
  }
10643
+ var SURVEY_SCHEMA_FILE = "survey-0.1-draft.schema.json";
10644
+ var surveyRecord;
10645
+ function validateSurveyRecord(value) {
10646
+ if (surveyRecord === void 0) {
10647
+ const ajv = createAjv();
10648
+ ajv.addSchema(readSchema(SURVEY_SCHEMA_FILE));
10649
+ surveyRecord = ajv.getSchema(schemaId(SURVEY_SCHEMA_FILE));
10650
+ }
10651
+ return surveyRecord(value) ? [] : toSchemaErrors(surveyRecord.errors);
10652
+ }
10595
10653
 
10596
10654
  // src/spec/extract.ts
10597
10655
  var OPENING_FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
10598
- function isClosingFence(line, fence) {
10599
- const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
10656
+ function isClosingFence(line2, fence) {
10657
+ const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line2);
10600
10658
  const run = match?.[1];
10601
10659
  return run !== void 0 && run[0] === fence.char && run.length >= fence.length;
10602
10660
  }
10603
10661
  function scanHandbackBlocks(reportText) {
10604
- const text = reportText.startsWith("\uFEFF") ? reportText.slice(1) : reportText;
10605
- const lines = text.replace(/\r\n?/g, "\n").split("\n");
10662
+ const text2 = reportText.startsWith("\uFEFF") ? reportText.slice(1) : reportText;
10663
+ const lines = text2.replace(/\r\n?/g, "\n").split("\n");
10606
10664
  const blocks = [];
10607
10665
  let open2;
10608
- for (const line of lines) {
10666
+ for (const line2 of lines) {
10609
10667
  if (open2 !== void 0) {
10610
- if (isClosingFence(line, open2)) {
10668
+ if (isClosingFence(line2, open2)) {
10611
10669
  if (open2.handback) blocks.push({ content: open2.lines.join("\n"), terminated: true });
10612
10670
  open2 = void 0;
10613
10671
  } else if (open2.handback) {
10614
- open2.lines.push(line);
10672
+ open2.lines.push(line2);
10615
10673
  }
10616
10674
  continue;
10617
10675
  }
10618
- const match = OPENING_FENCE.exec(line);
10676
+ const match = OPENING_FENCE.exec(line2);
10619
10677
  const run = match?.[1];
10620
10678
  if (run === void 0) continue;
10621
10679
  const info = (match?.[2] ?? "").replace(/^[ \t]+|[ \t]+$/g, "");
@@ -10724,29 +10782,83 @@ function defaultBase(cwd) {
10724
10782
  }
10725
10783
  return ref.slice("refs/remotes/".length);
10726
10784
  }
10727
- function testsFrom(cwd, tests) {
10728
- const path = resolve(cwd, tests.junit);
10729
- let counts;
10785
+ function localCounts(cwd, flag, file2) {
10730
10786
  try {
10731
- counts = countJunit(readFileSync2(path, "utf8"));
10787
+ return countJunit(readFileSync2(resolve(cwd, file2), "utf8"));
10732
10788
  } catch (e) {
10733
- throw new TemplateError(`--tests-junit ${tests.junit}: ${e.message}`);
10789
+ throw new TemplateError(`${flag} ${file2}: ${e.message}`);
10790
+ }
10791
+ }
10792
+ function testsFrom(cwd, tests) {
10793
+ if (tests.kind === "ci") {
10794
+ return tests.read.status === "settled" ? { run: tests.read.run, notes: tests.read.notes } : { notes: tests.read.notes };
10795
+ }
10796
+ if (tests.kind === "local") {
10797
+ const counts2 = localCounts(cwd, "--tests-local", tests.junit);
10798
+ return {
10799
+ run: {
10800
+ command: tests.command,
10801
+ count: counts2.executed,
10802
+ failures: counts2.failed,
10803
+ record: { kind: "local" }
10804
+ },
10805
+ 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.`
10807
+ ]
10808
+ };
10734
10809
  }
10810
+ const counts = localCounts(cwd, "--tests-junit", tests.junit);
10811
+ const r = tests.record;
10735
10812
  return {
10736
- command: tests.command,
10737
- count: counts.executed,
10738
- failures: counts.failed,
10739
- record: { kind: "junit", ...tests.record }
10813
+ run: {
10814
+ command: tests.command,
10815
+ count: counts.executed,
10816
+ failures: counts.failed,
10817
+ record: { kind: "junit", ...r }
10818
+ },
10819
+ notes: [
10820
+ `tests: ${counts.executed} executed and ${counts.failed} failed in ${tests.junit}. The gate checks these counts against ${r.path} in the artifact ${r.artifact} of job ${r.job} in ${r.workflow} at the head commit, not against the local file: these local counts will be compared with CI's, and fail with count_mismatch where CI's run counted otherwise. dunstan template --tests-ci declares CI's counts instead.`
10821
+ ]
10740
10822
  };
10741
10823
  }
10742
- function templateBlock(options) {
10743
- const { cwd } = options;
10824
+ function originRepository(cwd) {
10825
+ const r = git(cwd, ["remote", "get-url", "origin"]);
10826
+ const url2 = r.stdout.trim();
10827
+ if (!r.ok || url2 === "") {
10828
+ throw new TemplateError("there is no origin remote: pass --repo <owner/name>");
10829
+ }
10830
+ const m = /^(?:https:\/\/(?:[^@/]+@)?|ssh:\/\/git@|git@)github\.com[:/]([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/.exec(
10831
+ url2
10832
+ );
10833
+ if (m === null) {
10834
+ throw new TemplateError(
10835
+ `origin ${url2} is not a github.com repository: pass --repo <owner/name>`
10836
+ );
10837
+ }
10838
+ return `${m[1]}/${m[2]}`;
10839
+ }
10840
+ function isPushed(cwd, commit) {
10841
+ const r = git(cwd, [
10842
+ "for-each-ref",
10843
+ "--contains",
10844
+ commit,
10845
+ "--format=%(refname)",
10846
+ "refs/remotes/"
10847
+ ]);
10848
+ return r.ok && r.stdout.trim() !== "";
10849
+ }
10850
+ function headCommit(cwd) {
10744
10851
  const top = git(cwd, ["rev-parse", "--show-toplevel"]);
10745
10852
  if (!top.ok) throw new TemplateError(`not a git repository: ${cwd}`);
10746
10853
  const head = commitOf(cwd, "HEAD");
10747
10854
  if (head === void 0) {
10748
10855
  throw new TemplateError("HEAD cannot be read: the branch has no commit yet");
10749
10856
  }
10857
+ return head;
10858
+ }
10859
+ function templateBlock(options) {
10860
+ const { cwd } = options;
10861
+ const head = headCommit(cwd);
10750
10862
  const symbolic = git(cwd, ["symbolic-ref", "--quiet", "HEAD"]);
10751
10863
  if (!symbolic.ok) {
10752
10864
  throw new TemplateError(
@@ -10778,7 +10890,8 @@ function templateBlock(options) {
10778
10890
  compareCodeUnits
10779
10891
  );
10780
10892
  const block = { dunstan: BLOCK_VERSION, headCommit: head, filesChanged };
10781
- if (options.tests !== void 0) block.tests = [testsFrom(cwd, options.tests)];
10893
+ const tests = options.tests === void 0 ? void 0 : testsFrom(cwd, options.tests);
10894
+ if (tests?.run !== void 0) block.tests = [tests.run];
10782
10895
  const read = readBlockContent(JSON.stringify(block));
10783
10896
  if (read.status !== "found") {
10784
10897
  const errors = read.status === "invalid" ? read.errors : [];
@@ -10794,16 +10907,12 @@ function templateBlock(options) {
10794
10907
  if (dirty.ok && dirty.stdout !== "") {
10795
10908
  notes.push("the working tree has changes that are not committed; they are not in this block.");
10796
10909
  }
10797
- const t = block.tests?.[0];
10798
- if (t === void 0) {
10910
+ if (tests === void 0) {
10799
10911
  notes.push(
10800
- "tests are not declared. To declare a run, add --tests-junit <JUnit XML file> with --tests-command, --tests-workflow, --tests-job, --tests-artifact and --tests-artifact-path naming the CI record its counts are checked against."
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."
10801
10913
  );
10802
10914
  } else {
10803
- const r = t.record;
10804
- notes.push(
10805
- `tests: ${t.count} executed and ${t.failures} failed in ${options.tests?.junit}. The gate checks these counts against ${r.path} in the artifact ${r.artifact} of job ${r.job} in ${r.workflow} at the head commit, not against the local file.`
10806
- );
10915
+ notes.push(...tests.notes);
10807
10916
  }
10808
10917
  notes.push(
10809
10918
  "references and mergedAt are not filled: add each issue this pull request closes or cites yourself, and leave mergedAt out until it is merged.",
@@ -10846,7 +10955,7 @@ var GitHubClient = class {
10846
10955
  this.#baseUrl = options.baseUrl ?? DEFAULT_API;
10847
10956
  this.#fetch = options.fetch ?? fetch;
10848
10957
  this.#now = options.now ?? (() => /* @__PURE__ */ new Date());
10849
- this.#sleep = options.sleep ?? ((ms) => new Promise((resolve4) => setTimeout(resolve4, ms)));
10958
+ this.#sleep = options.sleep ?? ((ms) => new Promise((resolve5) => setTimeout(resolve5, ms)));
10850
10959
  this.#warn = options.warn ?? ((message) => process.stderr.write(`dunstan: ${message}
10851
10960
  `));
10852
10961
  this.#maxAttempts = options.maxAttempts ?? 4;
@@ -11007,8 +11116,8 @@ function retryWaitMs(response, status, now, attempt) {
11007
11116
  const retryAfter = response.headers.get("retry-after");
11008
11117
  const remaining = response.headers.get("x-ratelimit-remaining");
11009
11118
  const reset = response.headers.get("x-ratelimit-reset");
11010
- const rateLimited = status === 429 || status === 403 && (remaining === "0" || retryAfter !== null);
11011
- if (rateLimited) {
11119
+ const rateLimited2 = status === 429 || status === 403 && (remaining === "0" || retryAfter !== null);
11120
+ if (rateLimited2) {
11012
11121
  if (retryAfter !== null && /^[0-9]+$/.test(retryAfter)) return Number(retryAfter) * 1e3;
11013
11122
  if (reset !== null && /^[0-9]+$/.test(reset)) {
11014
11123
  return Math.max(0, Number(reset) * 1e3 - now.getTime()) + 1e3;
@@ -11659,22 +11768,22 @@ function answersFor(paths, answered) {
11659
11768
  });
11660
11769
  }
11661
11770
  function compareByList(claim2, evidence, repository) {
11662
- const row = rowFor(claim2.kind, claim2.value, evidence, repository);
11663
- if (row === void 0) return { observed: null, note: "unanswered:no_comparable_record_field" };
11664
- const observed = row.observed ?? null;
11665
- if (claim2.kind === "file_changed" && typeof claim2.value === "string" && row.verdict !== "unverifiable") {
11771
+ const row2 = rowFor(claim2.kind, claim2.value, evidence, repository);
11772
+ if (row2 === void 0) return { observed: null, note: "unanswered:no_comparable_record_field" };
11773
+ const observed = row2.observed ?? null;
11774
+ if (claim2.kind === "file_changed" && typeof claim2.value === "string" && row2.verdict !== "unverifiable") {
11666
11775
  const bare = !claim2.value.includes("/");
11667
- if (bare || row.verdict === "fail") {
11776
+ if (bare || row2.verdict === "fail") {
11668
11777
  const candidates = byName(claim2.value, evidence);
11669
11778
  if (candidates.length === 1)
11670
11779
  return { observed: candidates[0], note: "agrees_by_name" };
11671
11780
  if (candidates.length > 1) return { observed: candidates, note: "unanswered:ambiguous_path" };
11672
11781
  }
11673
11782
  }
11674
- if (row.verdict === "pass") return { observed, note: "agrees" };
11783
+ if (row2.verdict === "pass") return { observed, note: "agrees" };
11675
11784
  return {
11676
11785
  observed,
11677
- note: `${row.verdict === "fail" ? "differs" : "unanswered"}:${row.reason}`
11786
+ note: `${row2.verdict === "fail" ? "differs" : "unanswered"}:${row2.reason}`
11678
11787
  };
11679
11788
  }
11680
11789
  function readingBlock(block, proposed, headSha) {
@@ -11801,8 +11910,8 @@ var FINAL_STATES = phrases(GRAMMAR.finalStates);
11801
11910
  var CLOSERS = new Map(GRAMMAR.brackets.map((pair) => [pair[1], pair[0]]));
11802
11911
  var OPENERS = new Set(CLOSERS.values());
11803
11912
  var WINDOW = GRAMMAR.window;
11804
- function isLogLine(line) {
11805
- const body = line.replace(/^\s*(?:[-*+]|\d{1,3}[.)])\s+/, "").trimStart();
11913
+ function isLogLine(line2) {
11914
+ const body = line2.replace(/^\s*(?:[-*+]|\d{1,3}[.)])\s+/, "").trimStart();
11806
11915
  if (/^(?:\$\s|\[[^\]]*\]|\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}|[✓✗×✔✘❯›])/.test(body)) return true;
11807
11916
  if (/^(?:error|warning|Error|Warning)(?:\s+TS\d+)?:/.test(body)) return true;
11808
11917
  const first = /^[^\s:]+/.exec(body)?.[0] ?? "";
@@ -11815,32 +11924,32 @@ function prepare(report) {
11815
11924
  };
11816
11925
  let offset = 0;
11817
11926
  let fence;
11818
- for (const line of report.split("\n")) {
11927
+ for (const line2 of report.split("\n")) {
11819
11928
  const start = offset;
11820
- const end = offset + line.length;
11929
+ const end = offset + line2.length;
11821
11930
  offset = end + 1;
11822
11931
  if (fence !== void 0) {
11823
11932
  blank(start, end);
11824
- const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line)?.[1];
11933
+ const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line2)?.[1];
11825
11934
  if (close !== void 0 && close[0] === fence.char && close.length >= fence.length) {
11826
11935
  fence = void 0;
11827
11936
  }
11828
11937
  continue;
11829
11938
  }
11830
- const open2 = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
11939
+ const open2 = /^ {0,3}(`{3,}|~{3,})/.exec(line2)?.[1];
11831
11940
  if (open2 !== void 0) {
11832
11941
  fence = { char: open2[0], length: open2.length };
11833
11942
  blank(start, end);
11834
- } else if (/^ {0,3}>/.test(line) || isLogLine(line)) {
11943
+ } else if (/^ {0,3}>/.test(line2) || isLogLine(line2)) {
11835
11944
  blank(start, end);
11836
11945
  }
11837
11946
  }
11838
- let text = chars.join("");
11839
- for (const m of text.matchAll(/<!--[\s\S]*?(?:-->|$)/g)) blank(m.index, m.index + m[0].length);
11840
- text = chars.join("");
11947
+ let text2 = chars.join("");
11948
+ for (const m of text2.matchAll(/<!--[\s\S]*?(?:-->|$)/g)) blank(m.index, m.index + m[0].length);
11949
+ text2 = chars.join("");
11841
11950
  const atoms = /* @__PURE__ */ new Map();
11842
- const inAtom = new Uint8Array(text.length);
11843
- for (const m of text.matchAll(/`([^`\n]+)`/g)) {
11951
+ const inAtom = new Uint8Array(text2.length);
11952
+ for (const m of text2.matchAll(/`([^`\n]+)`/g)) {
11844
11953
  const content = m[1];
11845
11954
  const lead = content.length - content.trimStart().length;
11846
11955
  const trimmed = content.trim();
@@ -11851,13 +11960,13 @@ function prepare(report) {
11851
11960
  atoms.set(start, { end: start + trimmed.length, kind: "code" });
11852
11961
  inAtom.fill(1, start, start + trimmed.length);
11853
11962
  }
11854
- text = chars.join("");
11855
- for (const m of text.matchAll(/\bhttps?:\/\/[^\s<>()[\]`"']+/g)) {
11963
+ text2 = chars.join("");
11964
+ for (const m of text2.matchAll(/\bhttps?:\/\/[^\s<>()[\]`"']+/g)) {
11856
11965
  if (inAtom[m.index] === 1) continue;
11857
11966
  const url2 = m[0].replace(/[.,;:!?]+$/, "");
11858
11967
  atoms.set(m.index, { end: m.index + url2.length, kind: "url" });
11859
11968
  }
11860
- return { text, atoms };
11969
+ return { text: text2, atoms };
11861
11970
  }
11862
11971
  var LEADING = /^[(["'*<{~]+/;
11863
11972
  var TRAILING = /[)\]}"'*>~,.;:!?]+$/;
@@ -11868,23 +11977,23 @@ function makeWord(raw, start, atom, bracketAt) {
11868
11977
  return { text: raw, lower: "", start, end: rawEnd, rawEnd, bracket, comma: false, atom };
11869
11978
  }
11870
11979
  const lead = LEADING.exec(raw)?.[0].length ?? 0;
11871
- const text = raw.slice(lead).replace(TRAILING, "");
11872
- if (text === "") return void 0;
11980
+ const text2 = raw.slice(lead).replace(TRAILING, "");
11981
+ if (text2 === "") return void 0;
11873
11982
  const begin = start + lead;
11874
11983
  return {
11875
- text,
11876
- lower: text.toLowerCase(),
11984
+ text: text2,
11985
+ lower: text2.toLowerCase(),
11877
11986
  start: begin,
11878
- end: begin + text.length,
11987
+ end: begin + text2.length,
11879
11988
  rawEnd,
11880
11989
  bracket: bracketAt(begin),
11881
- comma: raw.slice(lead + text.length).includes(",")
11990
+ comma: raw.slice(lead + text2.length).includes(",")
11882
11991
  };
11883
11992
  }
11884
11993
  function bracketCursor(prep) {
11885
- const { text, atoms } = prep;
11994
+ const { text: text2, atoms } = prep;
11886
11995
  const links = /* @__PURE__ */ new Set();
11887
- for (const m of text.matchAll(/\[[^[\]\n]*\]\(/g)) {
11996
+ for (const m of text2.matchAll(/\[[^[\]\n]*\]\(/g)) {
11888
11997
  links.add(m.index);
11889
11998
  links.add(m.index + m[0].length - 2);
11890
11999
  }
@@ -11897,10 +12006,10 @@ function bracketCursor(prep) {
11897
12006
  p = atom.end;
11898
12007
  continue;
11899
12008
  }
11900
- const ch = text[p];
12009
+ const ch = text2[p];
11901
12010
  if (!links.has(p)) {
11902
12011
  if (OPENERS.has(ch)) open2.push(p);
11903
- else if (CLOSERS.has(ch) && text[open2.at(-1) ?? -1] === CLOSERS.get(ch)) open2.pop();
12012
+ else if (CLOSERS.has(ch) && text2[open2.at(-1) ?? -1] === CLOSERS.get(ch)) open2.pop();
11904
12013
  }
11905
12014
  p++;
11906
12015
  }
@@ -11908,7 +12017,7 @@ function bracketCursor(prep) {
11908
12017
  };
11909
12018
  }
11910
12019
  function clausesOf(prep) {
11911
- const { text, atoms } = prep;
12020
+ const { text: text2, atoms } = prep;
11912
12021
  const bracketAt = bracketCursor(prep);
11913
12022
  const clauses = [];
11914
12023
  let current = [];
@@ -11925,20 +12034,20 @@ function clausesOf(prep) {
11925
12034
  let blockLine2 = false;
11926
12035
  let i = 0;
11927
12036
  let gapStart = 0;
11928
- while (i < text.length) {
12037
+ while (i < text2.length) {
11929
12038
  const atom = atoms.get(i);
11930
- if (atom === void 0 && /\s/.test(text[i])) {
12039
+ if (atom === void 0 && /\s/.test(text2[i])) {
11931
12040
  i++;
11932
12041
  continue;
11933
12042
  }
11934
12043
  let end = atom?.end ?? i;
11935
12044
  if (atom === void 0) {
11936
- while (end < text.length && !/\s/.test(text[end]) && !atoms.has(end)) end++;
12045
+ while (end < text2.length && !/\s/.test(text2[end]) && !atoms.has(end)) end++;
11937
12046
  }
11938
- const gap = text.slice(gapStart, i);
12047
+ const gap = text2.slice(gapStart, i);
11939
12048
  let back = i;
11940
- while (back > 0 && /[ \t\r]/.test(text[back - 1])) back--;
11941
- const lineStart = back === 0 || text[back - 1] === "\n";
12049
+ while (back > 0 && /[ \t\r]/.test(text2[back - 1])) back--;
12050
+ const lineStart = back === 0 || text2[back - 1] === "\n";
11942
12051
  if (/\n[ \t\r]*\n/.test(gap)) {
11943
12052
  flush();
11944
12053
  item = void 0;
@@ -11947,14 +12056,14 @@ function clausesOf(prep) {
11947
12056
  if (blockLine2) flush();
11948
12057
  blockLine2 = false;
11949
12058
  }
11950
- const raw = text.slice(i, end);
12059
+ const raw = text2.slice(i, end);
11951
12060
  if (atom === void 0) {
11952
12061
  const marker = lineStart && /^(?:[-*+]|\d{1,3}[.)]|#{1,6})$/.test(raw);
11953
12062
  if (marker || /^\|+$/.test(raw)) {
11954
12063
  flush();
11955
12064
  if (lineStart && /^[#|]/.test(raw)) blockLine2 = true;
11956
12065
  if (marker && !raw.startsWith("#")) {
11957
- item = { column: i - (text.lastIndexOf("\n", i - 1) + 1) };
12066
+ item = { column: i - (text2.lastIndexOf("\n", i - 1) + 1) };
11958
12067
  fresh = true;
11959
12068
  } else {
11960
12069
  item = void 0;
@@ -12116,8 +12225,8 @@ var Clause = class {
12116
12225
  function clauseText(report, words2) {
12117
12226
  const first = words2[0];
12118
12227
  const last = words2[words2.length - 1];
12119
- const text = report.slice(first.start, last.rawEnd).replace(/\s+/g, " ").trim();
12120
- return text.length > 300 ? `${text.slice(0, 299)}\u2026` : text;
12228
+ const text2 = report.slice(first.start, last.rawEnd).replace(/\s+/g, " ").trim();
12229
+ return text2.length > 300 ? `${text2.slice(0, 299)}\u2026` : text2;
12121
12230
  }
12122
12231
  function bindPath(c, j, value, emit) {
12123
12232
  for (let k2 = j - 1; k2 >= Math.max(0, j - 3) && !c.isVerb(k2); k2--) {
@@ -12249,9 +12358,9 @@ function subjectOf(c, r, k) {
12249
12358
  }
12250
12359
  function baselineBefore(c, j) {
12251
12360
  for (let k = 0; k < j; k++) {
12252
- const text = c.words[k].text.toLowerCase();
12361
+ const text2 = c.words[k].text.toLowerCase();
12253
12362
  if (BASELINES.has(c.lower(k))) return true;
12254
- if (GRAMMAR.remotePrefixes.some((p) => text.startsWith(p))) return true;
12363
+ if (GRAMMAR.remotePrefixes.some((p) => text2.startsWith(p))) return true;
12255
12364
  }
12256
12365
  return false;
12257
12366
  }
@@ -12519,85 +12628,85 @@ function claimTexts(claim2) {
12519
12628
  function boundedAt(haystack, needle, inside) {
12520
12629
  const out = [];
12521
12630
  if (needle.length === 0) return out;
12522
- for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + 1)) {
12523
- const before = haystack[at - 1];
12524
- const after = haystack[at + needle.length];
12525
- const next = haystack[at + needle.length + 1];
12631
+ for (let at2 = haystack.indexOf(needle); at2 !== -1; at2 = haystack.indexOf(needle, at2 + 1)) {
12632
+ const before = haystack[at2 - 1];
12633
+ const after = haystack[at2 + needle.length];
12634
+ const next = haystack[at2 + needle.length + 1];
12526
12635
  const openBefore = before === void 0 || !inside.test(before);
12527
12636
  const openAfter = after === void 0 || !inside.test(after) || after === "." && (next === void 0 || !/[A-Za-z0-9]/.test(next));
12528
- if (openBefore && openAfter) out.push(at);
12637
+ if (openBefore && openAfter) out.push(at2);
12529
12638
  }
12530
12639
  return out;
12531
12640
  }
12532
12641
  function occursBounded(haystack, needle, inside) {
12533
12642
  return boundedAt(haystack, needle, inside).length > 0;
12534
12643
  }
12535
- function classnameSpans(text, classname) {
12644
+ function classnameSpans(text2, classname) {
12536
12645
  const forms = /* @__PURE__ */ new Set([classname, classname.replaceAll(".", " ")]);
12537
12646
  return [...forms].flatMap(
12538
- (form) => boundedAt(text, form, WORD).map((at) => [at, at + form.length])
12647
+ (form) => boundedAt(text2, form, WORD).map((at2) => [at2, at2 + form.length])
12539
12648
  );
12540
12649
  }
12541
- function classnameNamed(text, classname, classnames) {
12542
- const longer = classnames.filter((c) => c.length > classname.length).flatMap((c) => classnameSpans(text, c));
12543
- return classnameSpans(text, classname).some(
12650
+ function classnameNamed(text2, classname, classnames) {
12651
+ const longer = classnames.filter((c) => c.length > classname.length).flatMap((c) => classnameSpans(text2, c));
12652
+ return classnameSpans(text2, classname).some(
12544
12653
  ([from, to]) => !longer.some(([l, r]) => l <= from && to <= r)
12545
12654
  );
12546
12655
  }
12547
12656
  var CHECK_RUN_EVENTS = ["push", "pull_request", "pull_request_target", "merge_group"];
12548
12657
  var EVENT_SUFFIX = new RegExp(`^ \\((?:${CHECK_RUN_EVENTS.join("|")})\\)`);
12549
12658
  var WORKFLOW_JOB = /(\S+) \/ /g;
12550
- function afterWorkflows(text) {
12551
- return [...text.matchAll(WORKFLOW_JOB)].map((m) => m.index + m[0].length);
12552
- }
12553
- function jobNamed(text, name, longer) {
12554
- if (text === name) return true;
12555
- const workflowed = afterWorkflows(text);
12556
- return boundedAt(text, name, JOB_NAME_CHAR).some(
12557
- (at) => jobNamedAt(text, name, at, longer, workflowed.includes(at))
12659
+ function afterWorkflows(text2) {
12660
+ return [...text2.matchAll(WORKFLOW_JOB)].map((m) => m.index + m[0].length);
12661
+ }
12662
+ function jobNamed(text2, name, longer) {
12663
+ if (text2 === name) return true;
12664
+ const workflowed = afterWorkflows(text2);
12665
+ return boundedAt(text2, name, JOB_NAME_CHAR).some(
12666
+ (at2) => jobNamedAt(text2, name, at2, longer, workflowed.includes(at2))
12558
12667
  );
12559
12668
  }
12560
- function jobNamedAt(text, name, at, longer, withWorkflow) {
12561
- const end = at + name.length;
12562
- return (!text.startsWith(" (", end) || withWorkflow && EVENT_SUFFIX.test(text.slice(end))) && !longer.some(
12563
- (l) => boundedAt(text, l, JOB_NAME_CHAR).some((from) => from <= at && end <= from + l.length)
12669
+ function jobNamedAt(text2, name, at2, longer, withWorkflow) {
12670
+ const end = at2 + name.length;
12671
+ return (!text2.startsWith(" (", end) || withWorkflow && EVENT_SUFFIX.test(text2.slice(end))) && !longer.some(
12672
+ (l) => boundedAt(text2, l, JOB_NAME_CHAR).some((from) => from <= at2 && end <= from + l.length)
12564
12673
  );
12565
12674
  }
12566
12675
  var longest = (names) => [...names].sort((a, b) => b.length - a.length)[0];
12567
- function jobAt(text, at, jobNames) {
12676
+ function jobAt(text2, at2, jobNames) {
12568
12677
  return longest(
12569
12678
  jobNames.filter((name) => {
12570
12679
  const longer = jobNames.filter((n) => n.length > name.length && n.includes(name));
12571
- return boundedAt(text, name, JOB_NAME_CHAR).includes(at) && jobNamedAt(text, name, at, longer, true);
12680
+ return boundedAt(text2, name, JOB_NAME_CHAR).includes(at2) && jobNamedAt(text2, name, at2, longer, true);
12572
12681
  })
12573
12682
  );
12574
12683
  }
12575
- function workflowBefore(text, slash, token, workflows) {
12684
+ function workflowBefore(text2, slash, token, workflows) {
12576
12685
  const whole = workflows.filter((w) => {
12577
12686
  const from = slash - w.length;
12578
- return w.length > 0 && from >= 0 && text.startsWith(w, from) && !PATH_CHAR.test(text[from - 1] ?? " ");
12687
+ return w.length > 0 && from >= 0 && text2.startsWith(w, from) && !PATH_CHAR.test(text2[from - 1] ?? " ");
12579
12688
  });
12580
12689
  return longest(whole) ?? token;
12581
12690
  }
12582
- function workflowJobs(text, jobNames, workflows = []) {
12691
+ function workflowJobs(text2, jobNames, workflows = []) {
12583
12692
  const out = [];
12584
- for (const m of text.matchAll(WORKFLOW_JOB)) {
12585
- const at = m.index + m[0].length;
12586
- const job = jobAt(text, at, jobNames);
12693
+ for (const m of text2.matchAll(WORKFLOW_JOB)) {
12694
+ const at2 = m.index + m[0].length;
12695
+ const job = jobAt(text2, at2, jobNames);
12587
12696
  if (job === void 0) continue;
12588
- out.push({ workflow: workflowBefore(text, at - 3, m[1], workflows), job });
12697
+ out.push({ workflow: workflowBefore(text2, at2 - 3, m[1], workflows), job });
12589
12698
  }
12590
12699
  return out;
12591
12700
  }
12592
12701
  var PARENTHESIS = /^ \([^()]*\)/;
12593
- function unheldSuffixJobs(text, jobNames) {
12702
+ function unheldSuffixJobs(text2, jobNames) {
12594
12703
  const out = [];
12595
- for (const at of afterWorkflows(text)) {
12596
- if (jobAt(text, at, jobNames) !== void 0) continue;
12704
+ for (const at2 of afterWorkflows(text2)) {
12705
+ if (jobAt(text2, at2, jobNames) !== void 0) continue;
12597
12706
  const job = longest(
12598
12707
  jobNames.filter((name) => {
12599
- const rest = text.slice(at + name.length);
12600
- return boundedAt(text, name, JOB_NAME_CHAR).includes(at) && PARENTHESIS.test(rest) && !EVENT_SUFFIX.test(rest);
12708
+ const rest = text2.slice(at2 + name.length);
12709
+ return boundedAt(text2, name, JOB_NAME_CHAR).includes(at2) && PARENTHESIS.test(rest) && !EVENT_SUFFIX.test(rest);
12601
12710
  })
12602
12711
  );
12603
12712
  if (job !== void 0) out.push(job);
@@ -12606,8 +12715,8 @@ function unheldSuffixJobs(text, jobNames) {
12606
12715
  }
12607
12716
  function shaTokens(texts) {
12608
12717
  const out = [];
12609
- for (const text of texts) {
12610
- for (const m of text.matchAll(SHA2)) {
12718
+ for (const text2 of texts) {
12719
+ for (const m of text2.matchAll(SHA2)) {
12611
12720
  const token = m[0].toLowerCase();
12612
12721
  if (token.length === 40 || /[0-9]/.test(token)) out.push(token);
12613
12722
  }
@@ -12616,8 +12725,8 @@ function shaTokens(texts) {
12616
12725
  }
12617
12726
  function issueRefs(texts, repository) {
12618
12727
  const out = [];
12619
- for (const text of texts) {
12620
- for (const m of text.matchAll(ISSUE2)) out.push(qualifyIssue(m[1], repository));
12728
+ for (const text2 of texts) {
12729
+ for (const m of text2.matchAll(ISSUE2)) out.push(qualifyIssue(m[1], repository));
12621
12730
  }
12622
12731
  return out;
12623
12732
  }
@@ -12789,8 +12898,8 @@ var STOPWORDS = /* @__PURE__ */ new Set([
12789
12898
  function stem(token) {
12790
12899
  return token.length > 3 && token.endsWith("s") && !token.endsWith("ss") ? token.slice(0, -1) : token;
12791
12900
  }
12792
- function tokenize(text) {
12793
- return text.replace(new RegExp("([\\p{Ll}\\p{N}])(\\p{Lu})", "gu"), "$1 $2").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((t) => t.length > 1 && !STOPWORDS.has(t)).map(stem);
12901
+ function tokenize(text2) {
12902
+ return text2.replace(new RegExp("([\\p{Ll}\\p{N}])(\\p{Lu})", "gu"), "$1 $2").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((t) => t.length > 1 && !STOPWORDS.has(t)).map(stem);
12794
12903
  }
12795
12904
  var Bm25Index = class {
12796
12905
  termFrequencies;
@@ -12845,9 +12954,9 @@ function roundScore(score) {
12845
12954
  function bestField(item, terms, index) {
12846
12955
  let best = item.fields[0]?.[0] ?? "id";
12847
12956
  let bestWeight = -1;
12848
- for (const [name, text] of item.fields) {
12957
+ for (const [name, text2] of item.fields) {
12849
12958
  let weight = 0;
12850
- for (const t of new Set(tokenize(text))) if (terms.has(t)) weight += index.idf(t);
12959
+ for (const t of new Set(tokenize(text2))) if (terms.has(t)) weight += index.idf(t);
12851
12960
  if (weight > bestWeight) {
12852
12961
  best = name;
12853
12962
  bestWeight = weight;
@@ -12856,7 +12965,7 @@ function bestField(item, terms, index) {
12856
12965
  return best;
12857
12966
  }
12858
12967
  function itemTokens(item) {
12859
- return item.fields.flatMap(([, text]) => tokenize(text));
12968
+ return item.fields.flatMap(([, text2]) => tokenize(text2));
12860
12969
  }
12861
12970
  function compareRanked(a, b) {
12862
12971
  const sa = a.candidate.score;
@@ -12980,17 +13089,17 @@ function ambiguousCandidates(combined, outcome) {
12980
13089
  const tied = combined.filter((_, i) => scores[i] === top);
12981
13090
  return tied.length >= 2 && new Set(tied.map(outcome)).size > 1;
12982
13091
  }
12983
- function namedWorkflowRuns(text, combined, evidence) {
13092
+ function namedWorkflowRuns(text2, combined, evidence) {
12984
13093
  const section = evidence.checkRuns;
12985
13094
  if (section?.status !== "ok") return [...combined];
12986
13095
  const jobNames = [...new Set(section.runs.map((r) => r.name))];
12987
- if (unheldSuffixJobs(text, jobNames).length > 0) return { reason: "no_matching_record_item" };
13096
+ if (unheldSuffixJobs(text2, jobNames).length > 0) return { reason: "no_matching_record_item" };
12988
13097
  const recordedWorkflows = [
12989
13098
  ...new Set(
12990
13099
  section.runs.flatMap((r) => [r.workflowPath, r.workflowName]).filter((w) => w !== void 0)
12991
13100
  )
12992
13101
  ];
12993
- const named = workflowJobs(text, jobNames, recordedWorkflows);
13102
+ const named = workflowJobs(text2, jobNames, recordedWorkflows);
12994
13103
  if (named.length === 0) return [...combined];
12995
13104
  const counted = new Map(
12996
13105
  countedCheckRuns(section.runs, section.excludedIds).map((r) => [String(r.id), r])
@@ -13038,9 +13147,9 @@ function checkReaderClaim(claim2, evidence, repository) {
13038
13147
  if (claim2.kind === "reference_closes") {
13039
13148
  if (!ISSUE_REF2.test(declared)) return unverifiable2("no_comparable_record_field");
13040
13149
  const head = evidence.pullRequest.headSha;
13041
- const row = checkReferences(closesBlock(declared, head), evidence, repository)[0];
13042
- if (row === void 0) return unverifiable2("no_comparable_record_field");
13043
- return verdictOf(row.verdict, row.observed ?? null, row.reason);
13150
+ const row2 = checkReferences(closesBlock(declared, head), evidence, repository)[0];
13151
+ if (row2 === void 0) return unverifiable2("no_comparable_record_field");
13152
+ return verdictOf(row2.verdict, row2.observed ?? null, row2.reason);
13044
13153
  }
13045
13154
  const missing = sectionReason(type, evidence);
13046
13155
  if (missing !== void 0) return unverifiable2(missing);
@@ -13269,10 +13378,10 @@ async function checkAt(input2, pr) {
13269
13378
  const repository = pr.repository;
13270
13379
  const ownRuns = await input2.afterPullRequest?.(pr) ?? [];
13271
13380
  const report = await input2.report(pr);
13272
- const text = new TextDecoder("utf-8").decode(report.bytes);
13273
- const block = input2.block ?? recordBlock(extractHandbackBlock(text));
13381
+ const text2 = new TextDecoder("utf-8").decode(report.bytes);
13382
+ const block = input2.block ?? recordBlock(extractHandbackBlock(text2));
13274
13383
  const found = block.status === "found" ? block.value : null;
13275
- const proposed = input2.advisory === true ? extractClaims(text) : void 0;
13384
+ const proposed = input2.advisory === true ? extractClaims(text2) : void 0;
13276
13385
  const evidence = await input2.readers.evidence({
13277
13386
  repository,
13278
13387
  pullRequest: pr,
@@ -13368,6 +13477,102 @@ function noteText(note) {
13368
13477
  return `${POSSIBLE_DISAGREEMENT}: ${shows}`;
13369
13478
  }
13370
13479
 
13480
+ // src/remedy/remedies.ts
13481
+ var NO_REMEDY = "no remedy: the record decides";
13482
+ var RECIPE = "docs/ci-test-records.md";
13483
+ function line(text2) {
13484
+ return () => text2;
13485
+ }
13486
+ var decided = line(NO_REMEDY);
13487
+ var REMEDIES = {
13488
+ // 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)."
13491
+ ),
13492
+ block_ambiguous: line("The report has more than one dunstan-handback block: keep exactly one."),
13493
+ block_invalid: line(
13494
+ "The block failed validation: check it against spec/schema/handback-block-0.1.schema.json before handing back."
13495
+ ),
13496
+ // Unverifiable, a tests row: the record holds no count to compare, or could not read it. These are
13497
+ // 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.`
13500
+ ),
13501
+ no_comparable_record_field: line(
13502
+ `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
+ ),
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.`
13506
+ ),
13507
+ "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
+ // Unverifiable, any other row: the record could not settle the claim.
13509
+ record_ambiguous: line("Several runs at the head disagree on the count."),
13510
+ checks_incomplete: line("CI had not finished when the record was read: check again once it has."),
13511
+ no_check_runs: line("There were no check runs at the head: declare checks only where CI runs."),
13512
+ no_succeeded_check_runs: line(
13513
+ "No check run at the head succeeded and none failed: nothing ran to settle the claim."
13514
+ ),
13515
+ check_runs_not_at_head: line(
13516
+ "The check runs were read at another commit: declare the pull request head as headCommit."
13517
+ ),
13518
+ file_list_truncated: line(
13519
+ "GitHub lists at most 3,000 changed files: split the change, or declare no more than it lists."
13520
+ ),
13521
+ closing_link_unsettled: line(
13522
+ "GitHub had not yet linked the issue to the open pull request: check again once it has."
13523
+ ),
13524
+ closing_base_not_default: line(
13525
+ "The pull request merged into a branch other than the default: the issue may close on a later merge."
13526
+ ),
13527
+ closing_event_unsettled: line(
13528
+ "The issue is closed but its timeline does not yet show who closed it: check again later."
13529
+ ),
13530
+ "source_unreadable:*": line("The source did not answer: rerun later or with a token."),
13531
+ "evidence_field_unpopulated:*": line("GitHub answered without the field the claim needs."),
13532
+ // Fail: the record decided the claim; nothing in the record is missing.
13533
+ head_mismatch: decided,
13534
+ declared_not_changed: decided,
13535
+ undeclared_file: decided,
13536
+ not_closing: decided,
13537
+ not_found: decided,
13538
+ not_reachable: decided,
13539
+ count_mismatch: decided,
13540
+ all_succeeded_mismatch: decided,
13541
+ none_failed_mismatch: decided,
13542
+ not_merged: decided,
13543
+ premature: decided,
13544
+ no_deployment: decided,
13545
+ // The survey's own reasons for a pull request it has no record of (docs/survey.md).
13546
+ pull_request_unreadable: line("The pull request could not be read: rerun later or with a token."),
13547
+ pull_request_moved: line(
13548
+ "The pull request moved twice while it was checked: rerun once it holds still."
13549
+ ),
13550
+ rate_limited: line(
13551
+ "GitHub rate-limited the reads: rerun later, or with a token for a higher limit."
13552
+ ),
13553
+ report_unavailable: line(
13554
+ "The named report could not be read: the author has no comment on the pull request, or the comments could not be listed."
13555
+ )
13556
+ };
13557
+ var TESTS_REMEDY_REASONS = [
13558
+ "record_not_found",
13559
+ "no_comparable_record_field",
13560
+ "local_run_not_in_record",
13561
+ "source_unreadable:artifact"
13562
+ ];
13563
+ function remedyForReason(reason, context) {
13564
+ const exact = REMEDIES[reason];
13565
+ if (exact !== void 0) return exact(context);
13566
+ const colon = reason.indexOf(":");
13567
+ return colon === -1 ? void 0 : REMEDIES[`${reason.slice(0, colon)}:*`]?.(context);
13568
+ }
13569
+ function remedyFor(claim2, context) {
13570
+ if (claim2.verdict !== "unverifiable" || claim2.reason === void 0) return void 0;
13571
+ if (!TESTS_REMEDY_REASONS.includes(claim2.reason)) return void 0;
13572
+ if (!/^\/tests\/[0-9]+\/(?:count|failures)$/.test(claim2.field)) return void 0;
13573
+ return remedyForReason(claim2.reason, context);
13574
+ }
13575
+
13371
13576
  // src/cli/output.ts
13372
13577
  var EXIT = { pass: 0, fail: 1, unverifiable: 2, error: 3 };
13373
13578
  function exitFor(verdict) {
@@ -13382,8 +13587,8 @@ var UsageError = class extends Error {
13382
13587
  var WIDTH = 48;
13383
13588
  function cell(value) {
13384
13589
  if (value === void 0) return "";
13385
- const text = typeof value === "string" ? value : JSON.stringify(value);
13386
- return text.length > WIDTH ? `${text.slice(0, WIDTH - 1)}\u2026` : text;
13590
+ const text2 = typeof value === "string" ? value : JSON.stringify(value);
13591
+ return text2.length > WIDTH ? `${text2.slice(0, WIDTH - 1)}\u2026` : text2;
13387
13592
  }
13388
13593
  function table(rows) {
13389
13594
  const widths = rows[0]?.map((_, i) => Math.max(...rows.map((r) => (r[i] ?? "").length))) ?? [];
@@ -13391,7 +13596,7 @@ function table(rows) {
13391
13596
  (r) => r.map((c, i) => i === r.length - 1 ? c : c.padEnd(widths[i] ?? 0)).join(" ").trimEnd()
13392
13597
  ).join("\n");
13393
13598
  }
13394
- function renderRecord(record2) {
13599
+ function renderRecord(record2, context) {
13395
13600
  const p = record2.predicate;
13396
13601
  const lines = [
13397
13602
  `${p.checker.name} ${p.checker.version} ${p.subject.repository}#${p.subject.pullRequest} head ${p.subject.headSha}`,
@@ -13406,19 +13611,16 @@ function renderRecord(record2) {
13406
13611
  lines.push(`block: invalid (${b.reason}: ${[...new Set(errors)].join("; ")})`);
13407
13612
  } else lines.push(`block: missing (${b.reason})`);
13408
13613
  if (p.claims.length > 0) {
13409
- lines.push(
13410
- "",
13411
- table([
13412
- ["VERDICT", "CLAIM", "DECLARED", "OBSERVED", "REASON"],
13413
- ...p.claims.map((c) => [
13414
- c.verdict,
13415
- c.id,
13416
- cell(c.declared),
13417
- cell(c.observed),
13418
- c.reason ?? ""
13419
- ])
13420
- ])
13421
- );
13614
+ const rows = table([
13615
+ ["VERDICT", "CLAIM", "DECLARED", "OBSERVED", "REASON"],
13616
+ ...p.claims.map((c) => [c.verdict, c.id, cell(c.declared), cell(c.observed), c.reason ?? ""])
13617
+ ]).split("\n");
13618
+ lines.push("", rows[0]);
13619
+ p.claims.forEach((c, i) => {
13620
+ lines.push(rows[i + 1]);
13621
+ const remedy = remedyFor(c, context);
13622
+ if (remedy !== void 0) lines.push(` remedy: ${remedy}`);
13623
+ });
13422
13624
  }
13423
13625
  const unread2 = p.evidence.sources.filter((s) => s.error !== void 0);
13424
13626
  if (unread2.length > 0) {
@@ -13537,7 +13739,7 @@ async function check(args, io) {
13537
13739
  advisory: values.advisory === true
13538
13740
  });
13539
13741
  writeFileSync(out, serializeRecord(record2));
13540
- io.out(renderRecord(record2));
13742
+ io.out(renderRecord(record2, { hasToken: client.hasToken }));
13541
13743
  io.out(`record: ${out}
13542
13744
  `);
13543
13745
  if (assurance !== void 0) {
@@ -13547,6 +13749,95 @@ async function check(args, io) {
13547
13749
  return exitFor(record2.predicate.verdict);
13548
13750
  }
13549
13751
 
13752
+ // src/cli/junit-merge.ts
13753
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "node:fs";
13754
+ import { resolve as resolve2 } from "node:path";
13755
+ import { parseArgs as parseArgs2 } from "node:util";
13756
+
13757
+ // src/junit/merge.ts
13758
+ var JunitMergeError = class extends Error {
13759
+ constructor(message) {
13760
+ super(message);
13761
+ this.name = "JunitMergeError";
13762
+ }
13763
+ };
13764
+ var HEADER = '<?xml version="1.0" encoding="UTF-8"?>';
13765
+ function mergeJunit(inputs, count = (xml) => scanJunit(xml)) {
13766
+ if (inputs.length === 0) throw new JunitMergeError("no input: name one or more JUnit files");
13767
+ const sorted = [...inputs].sort((a, b) => compareCodeUnits(a.path, b.path));
13768
+ sorted.forEach((input2, i) => {
13769
+ if (i > 0 && sorted[i - 1]?.path === input2.path) {
13770
+ throw new JunitMergeError(`${input2.path} is given twice`);
13771
+ }
13772
+ });
13773
+ const sum = { executed: 0, failed: 0 };
13774
+ const parts = sorted.map((input2) => {
13775
+ let scan;
13776
+ try {
13777
+ scan = scanJunit(input2.xml);
13778
+ } catch (e) {
13779
+ if (e instanceof JunitError) throw new JunitMergeError(`${input2.path}: ${e.message}`);
13780
+ throw e;
13781
+ }
13782
+ sum.executed += scan.executed;
13783
+ sum.failed += scan.failed;
13784
+ const { root } = scan;
13785
+ return root.name === "testsuites" ? input2.xml.slice(root.contentStart, root.contentEnd) : input2.xml.slice(root.start, root.end);
13786
+ });
13787
+ const xml = [HEADER, "<testsuites>", ...parts, "</testsuites>", ""].join("\n");
13788
+ let merged;
13789
+ try {
13790
+ merged = count(xml);
13791
+ } catch (e) {
13792
+ if (e instanceof JunitError) throw new JunitMergeError(`the merged file: ${e.message}`);
13793
+ throw e;
13794
+ }
13795
+ if (merged.executed !== sum.executed || merged.failed !== sum.failed) {
13796
+ throw new JunitMergeError(
13797
+ `the merged file counts ${merged.executed} executed and ${merged.failed} failed; the inputs sum to ${sum.executed} and ${sum.failed}`
13798
+ );
13799
+ }
13800
+ return { xml, counts: sum };
13801
+ }
13802
+
13803
+ // src/cli/junit-merge.ts
13804
+ async function junitMerge(args, io) {
13805
+ const { values, positionals } = parseArgs2({
13806
+ args,
13807
+ strict: true,
13808
+ allowPositionals: true,
13809
+ options: { out: { type: "string" } }
13810
+ });
13811
+ if (values.out === void 0) throw new UsageError("--out <file> is required");
13812
+ const cwd = io.cwd ?? process.cwd();
13813
+ const out = resolve2(cwd, values.out);
13814
+ if (positionals.some((p) => resolve2(cwd, p) === out)) {
13815
+ throw new UsageError(`--out ${values.out} is one of the inputs`);
13816
+ }
13817
+ try {
13818
+ const merged = mergeJunit(
13819
+ positionals.map((path) => {
13820
+ try {
13821
+ return { path, xml: readFileSync4(resolve2(cwd, path), "utf8") };
13822
+ } catch (e) {
13823
+ throw new JunitMergeError(`${path}: ${e.message}`);
13824
+ }
13825
+ })
13826
+ );
13827
+ writeFileSync2(out, merged.xml);
13828
+ io.err(
13829
+ `dunstan: ${values.out}: ${positionals.length} ${positionals.length === 1 ? "file" : "files"}, ${merged.counts.executed} test cases executed, ${merged.counts.failed} failed
13830
+ `
13831
+ );
13832
+ return 0;
13833
+ } catch (e) {
13834
+ if (!(e instanceof JunitMergeError)) throw e;
13835
+ io.err(`dunstan: junit-merge refused, nothing written: ${e.message}
13836
+ `);
13837
+ return EXIT.error;
13838
+ }
13839
+ }
13840
+
13550
13841
  // node_modules/.pnpm/@modelcontextprotocol+sdk@1.32.0_zod@4.6.5/node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js
13551
13842
  import process2 from "node:process";
13552
13843
 
@@ -14384,14 +14675,14 @@ function promiseAllObject(promisesObj) {
14384
14675
  }
14385
14676
  function randomString(length = 10) {
14386
14677
  const chars = "abcdefghijklmnopqrstuvwxyz";
14387
- let str2 = "";
14678
+ let str3 = "";
14388
14679
  for (let i = 0; i < length; i++) {
14389
- str2 += chars[Math.floor(Math.random() * chars.length)];
14680
+ str3 += chars[Math.floor(Math.random() * chars.length)];
14390
14681
  }
14391
- return str2;
14682
+ return str3;
14392
14683
  }
14393
- function esc(str2) {
14394
- return JSON.stringify(str2);
14684
+ function esc(str3) {
14685
+ return JSON.stringify(str3);
14395
14686
  }
14396
14687
  function slugify(input2) {
14397
14688
  return input2.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
@@ -14505,8 +14796,8 @@ var primitiveTypes = /* @__PURE__ */ new Set([
14505
14796
  "symbol",
14506
14797
  "undefined"
14507
14798
  ]);
14508
- function escapeRegex(str2) {
14509
- return str2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14799
+ function escapeRegex(str3) {
14800
+ return str3.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14510
14801
  }
14511
14802
  function clone(inst, def, params) {
14512
14803
  const cl = new inst._zod.constr(def ?? inst._zod.def);
@@ -14760,13 +15051,13 @@ function getSizableOrigin(input2) {
14760
15051
  return "unknown";
14761
15052
  }
14762
15053
  var highSurrogate = /[\uD800-\uDBFF]/;
14763
- function codePointLength(str2) {
14764
- const units = str2.length;
14765
- if (!highSurrogate.test(str2))
15054
+ function codePointLength(str3) {
15055
+ const units = str3.length;
15056
+ if (!highSurrogate.test(str3))
14766
15057
  return units;
14767
15058
  let count = units;
14768
15059
  for (let i = 0; i < units - 1; i++) {
14769
- if ((str2.charCodeAt(i) & 64512) === 55296 && (str2.charCodeAt(i + 1) & 64512) === 56320) {
15060
+ if ((str3.charCodeAt(i) & 64512) === 55296 && (str3.charCodeAt(i + 1) & 64512) === 56320) {
14770
15061
  count--;
14771
15062
  i++;
14772
15063
  }
@@ -14859,9 +15150,9 @@ var Class = class {
14859
15150
  constructor(..._args) {
14860
15151
  }
14861
15152
  };
14862
- function members(proto, table2) {
14863
- for (const key in table2) {
14864
- const desc = Object.getOwnPropertyDescriptor(table2, key);
15153
+ function members(proto, table3) {
15154
+ for (const key in table3) {
15155
+ const desc = Object.getOwnPropertyDescriptor(table3, key);
14865
15156
  if (desc.get)
14866
15157
  Object.defineProperty(proto, key, { ...desc, enumerable: false });
14867
15158
  else
@@ -14876,10 +15167,10 @@ function hide(inst, key, value) {
14876
15167
  return own(inst, key, value, false);
14877
15168
  }
14878
15169
  // @__NO_SIDE_EFFECTS__
14879
- function derived(computes, table2) {
15170
+ function derived(computes, table3) {
14880
15171
  for (const key in computes) {
14881
15172
  const compute = computes[key];
14882
- Object.defineProperty(table2, key, {
15173
+ Object.defineProperty(table3, key, {
14883
15174
  configurable: true,
14884
15175
  enumerable: true,
14885
15176
  get() {
@@ -14890,7 +15181,7 @@ function derived(computes, table2) {
14890
15181
  }
14891
15182
  });
14892
15183
  }
14893
- return table2;
15184
+ return table3;
14894
15185
  }
14895
15186
  function defineBound(proto, key, fn) {
14896
15187
  Object.defineProperty(proto, key, {
@@ -16122,8 +16413,8 @@ var Doc = class {
16122
16413
  const lines = content.split("\n").filter((x) => x);
16123
16414
  const minIndent = Math.min(...lines.map((x) => x.length - x.trimStart().length));
16124
16415
  const dedented = lines.map((x) => x.slice(minIndent)).map((x) => " ".repeat(this.indent * 2) + x);
16125
- for (const line of dedented) {
16126
- this.content.push(line);
16416
+ for (const line2 of dedented) {
16417
+ this.content.push(line2);
16127
16418
  }
16128
16419
  }
16129
16420
  compile() {
@@ -18576,7 +18867,7 @@ var recursive = /* @__PURE__ */ new WeakMap();
18576
18867
  var NONE = 0;
18577
18868
  var ASSUMED = 1;
18578
18869
  var PROVEN = 2;
18579
- function isRecursive(inst, stack, resolve4) {
18870
+ function isRecursive(inst, stack, resolve5) {
18580
18871
  const cached2 = recursive.get(inst);
18581
18872
  if (cached2 !== void 0)
18582
18873
  return cached2 ? PROVEN : NONE;
@@ -18586,7 +18877,7 @@ function isRecursive(inst, stack, resolve4) {
18586
18877
  let result = NONE;
18587
18878
  const check3 = (child) => {
18588
18879
  if (result !== PROVEN && child?._zod) {
18589
- const answer = isRecursive(child, stack, resolve4);
18880
+ const answer = isRecursive(child, stack, resolve5);
18590
18881
  if (answer > result)
18591
18882
  result = answer;
18592
18883
  }
@@ -18597,7 +18888,7 @@ function isRecursive(inst, stack, resolve4) {
18597
18888
  const desc = Object.getOwnPropertyDescriptor(sh, key);
18598
18889
  if (spread && !desc.enumerable)
18599
18890
  continue;
18600
- const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve4) : NONE;
18891
+ const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve5) : NONE;
18601
18892
  if (child > answer)
18602
18893
  answer = child;
18603
18894
  }
@@ -18661,7 +18952,7 @@ function isRecursive(inst, stack, resolve4) {
18661
18952
  break;
18662
18953
  // `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly
18663
18954
  case "lazy": {
18664
- const inner = def._cachedInner ?? (resolve4 ? inst._zod.innerType : void 0);
18955
+ const inner = def._cachedInner ?? (resolve5 ? inst._zod.innerType : void 0);
18665
18956
  merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
18666
18957
  break;
18667
18958
  }
@@ -22859,8 +23150,8 @@ function ko_default() {
22859
23150
  }
22860
23151
 
22861
23152
  // node_modules/.pnpm/zod@4.6.5/node_modules/zod/v4/locales/lt.js
22862
- var capitalizeFirstCharacter = (text) => {
22863
- return text.charAt(0).toUpperCase() + text.slice(1);
23153
+ var capitalizeFirstCharacter = (text2) => {
23154
+ return text2.charAt(0).toUpperCase() + text2.slice(1);
22864
23155
  };
22865
23156
  function getUnitTypeFromNumber(number4) {
22866
23157
  const abs = Math.abs(number4);
@@ -24220,8 +24511,8 @@ var error43 = () => {
24220
24511
  case "not_multiple_of":
24221
24512
  return `N\xFAmero inv\xE1lido: deve ser m\xFAltiplo de ${issue2.divisor}`;
24222
24513
  case "unrecognized_keys": {
24223
- const plural2 = issue2.keys.length > 1 ? "s" : "";
24224
- return `Chave${plural2} inv\xE1lida${plural2}: ${joinValues(issue2.keys, ", ")}`;
24514
+ const plural4 = issue2.keys.length > 1 ? "s" : "";
24515
+ return `Chave${plural4} inv\xE1lida${plural4}: ${joinValues(issue2.keys, ", ")}`;
24225
24516
  }
24226
24517
  case "invalid_key":
24227
24518
  return `Entrada inv\xE1lida n${translateOriginWithArticle(issue2.origin, "definite")}`;
@@ -24365,8 +24656,8 @@ var error44 = () => {
24365
24656
  case "not_multiple_of":
24366
24657
  return `N\xFAmero inv\xE1lido: deve ser m\xFAltiplo de ${issue2.divisor}`;
24367
24658
  case "unrecognized_keys": {
24368
- const plural2 = issue2.keys.length > 1 ? "s" : "";
24369
- return `Chave${plural2} inv\xE1lida${plural2}: ${joinValues(issue2.keys, ", ")}`;
24659
+ const plural4 = issue2.keys.length > 1 ? "s" : "";
24660
+ return `Chave${plural4} inv\xE1lida${plural4}: ${joinValues(issue2.keys, ", ")}`;
24370
24661
  }
24371
24662
  case "invalid_key":
24372
24663
  return `Entrada inv\xE1lida n${translateOriginWithArticle(issue2.origin, "definite")}`;
@@ -34771,16 +35062,16 @@ var ReadBuffer = class {
34771
35062
  if (index === -1) {
34772
35063
  return null;
34773
35064
  }
34774
- const line = this._buffer.toString("utf8", 0, index).replace(/\r$/, "");
35065
+ const line2 = this._buffer.toString("utf8", 0, index).replace(/\r$/, "");
34775
35066
  this._buffer = this._buffer.subarray(index + 1);
34776
- return deserializeMessage(line);
35067
+ return deserializeMessage(line2);
34777
35068
  }
34778
35069
  clear() {
34779
35070
  this._buffer = void 0;
34780
35071
  }
34781
35072
  };
34782
- function deserializeMessage(line) {
34783
- return JSONRPCMessageSchema.parse(JSON.parse(line));
35073
+ function deserializeMessage(line2) {
35074
+ return JSONRPCMessageSchema.parse(JSON.parse(line2));
34784
35075
  }
34785
35076
  function serializeMessage(message) {
34786
35077
  return JSON.stringify(message) + "\n";
@@ -34842,20 +35133,20 @@ var StdioServerTransport = class {
34842
35133
  this.onclose?.();
34843
35134
  }
34844
35135
  send(message) {
34845
- return new Promise((resolve4) => {
35136
+ return new Promise((resolve5) => {
34846
35137
  const json2 = serializeMessage(message);
34847
35138
  if (this._stdout.write(json2)) {
34848
- resolve4();
35139
+ resolve5();
34849
35140
  } else {
34850
- this._stdout.once("drain", resolve4);
35141
+ this._stdout.once("drain", resolve5);
34851
35142
  }
34852
35143
  });
34853
35144
  }
34854
35145
  };
34855
35146
 
34856
35147
  // src/mcp/server.ts
34857
- import { existsSync, writeFileSync as writeFileSync2 } from "node:fs";
34858
- import { basename as basename2, resolve as resolve2 } from "node:path";
35148
+ import { existsSync, writeFileSync as writeFileSync3 } from "node:fs";
35149
+ import { basename as basename2, resolve as resolve3 } from "node:path";
34859
35150
 
34860
35151
  // node_modules/.pnpm/zod@4.6.5/node_modules/zod/v3/helpers/util.js
34861
35152
  var util;
@@ -40831,7 +41122,7 @@ var Protocol = class {
40831
41122
  return;
40832
41123
  }
40833
41124
  const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
40834
- await new Promise((resolve4) => setTimeout(resolve4, pollInterval));
41125
+ await new Promise((resolve5) => setTimeout(resolve5, pollInterval));
40835
41126
  options?.signal?.throwIfAborted();
40836
41127
  }
40837
41128
  } catch (error62) {
@@ -40848,7 +41139,7 @@ var Protocol = class {
40848
41139
  */
40849
41140
  request(request, resultSchema, options) {
40850
41141
  const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
40851
- return new Promise((resolve4, reject) => {
41142
+ return new Promise((resolve5, reject) => {
40852
41143
  const earlyReject = (error62) => {
40853
41144
  reject(error62);
40854
41145
  };
@@ -40926,7 +41217,7 @@ var Protocol = class {
40926
41217
  if (!parseResult.success) {
40927
41218
  reject(parseResult.error);
40928
41219
  } else {
40929
- resolve4(parseResult.data);
41220
+ resolve5(parseResult.data);
40930
41221
  }
40931
41222
  } catch (error62) {
40932
41223
  reject(error62);
@@ -41180,20 +41471,20 @@ var Protocol = class {
41180
41471
  * @returns Promise that resolves when an update occurs or rejects if aborted
41181
41472
  */
41182
41473
  async _waitForTaskUpdate(taskId, signal, sessionId) {
41183
- let interval = this._options?.defaultTaskPollInterval ?? 1e3;
41474
+ let interval2 = this._options?.defaultTaskPollInterval ?? 1e3;
41184
41475
  try {
41185
41476
  const task = await this._taskStore?.getTask(taskId, sessionId);
41186
41477
  if (task?.pollInterval) {
41187
- interval = task.pollInterval;
41478
+ interval2 = task.pollInterval;
41188
41479
  }
41189
41480
  } catch {
41190
41481
  }
41191
- return new Promise((resolve4, reject) => {
41482
+ return new Promise((resolve5, reject) => {
41192
41483
  if (signal.aborted) {
41193
41484
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
41194
41485
  return;
41195
41486
  }
41196
- const timeoutId = setTimeout(resolve4, interval);
41487
+ const timeoutId = setTimeout(resolve5, interval2);
41197
41488
  signal.addEventListener("abort", () => {
41198
41489
  clearTimeout(timeoutId);
41199
41490
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
@@ -42324,7 +42615,7 @@ var McpServer = class {
42324
42615
  let task = createTaskResult.task;
42325
42616
  const pollInterval = task.pollInterval ?? 5e3;
42326
42617
  while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
42327
- await new Promise((resolve4) => setTimeout(resolve4, pollInterval));
42618
+ await new Promise((resolve5) => setTimeout(resolve5, pollInterval));
42328
42619
  const updatedTask = await extra.taskStore.getTask(taskId);
42329
42620
  if (!updatedTask) {
42330
42621
  throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
@@ -42944,13 +43235,13 @@ function sentenceAt(report, span) {
42944
43235
  const lines = report.split("\n");
42945
43236
  const starts = [];
42946
43237
  let offset = 0;
42947
- for (const line of lines) {
43238
+ for (const line2 of lines) {
42948
43239
  starts.push(offset);
42949
- offset += line.length + 1;
43240
+ offset += line2.length + 1;
42950
43241
  }
42951
- const lineOf = (at) => {
43242
+ const lineOf = (at2) => {
42952
43243
  let i = 0;
42953
- while (i + 1 < starts.length && starts[i + 1] <= at) i++;
43244
+ while (i + 1 < starts.length && starts[i + 1] <= at2) i++;
42954
43245
  return i;
42955
43246
  };
42956
43247
  const blank = (i) => (lines[i] ?? "").trim() === "";
@@ -42973,8 +43264,8 @@ function sentenceAt(report, span) {
42973
43264
  break;
42974
43265
  }
42975
43266
  }
42976
- const text = unit.slice(start, end).replace(/^\s*(?:[-*+]|\d{1,3}[.)]|#{1,6}|>)\s+/, "").replace(/\s+/g, " ").trim();
42977
- return text.length > 300 ? `${text.slice(0, 299)}\u2026` : text;
43267
+ const text2 = unit.slice(start, end).replace(/^\s*(?:[-*+]|\d{1,3}[.)]|#{1,6}|>)\s+/, "").replace(/\s+/g, " ").trim();
43268
+ return text2.length > 300 ? `${text2.slice(0, 299)}\u2026` : text2;
42978
43269
  }
42979
43270
  function suggestDeclarations(report) {
42980
43271
  const block = extractHandbackBlock(report);
@@ -43053,19 +43344,19 @@ var INSTRUCTIONS = `Before you say a task is done: generate your dunstan-handbac
43053
43344
  var SUGGEST_DESCRIPTION = `List the sentences in your report's prose that look like claims your dunstan-handback block does not declare, before you say done. Give report, the report text. For each, declare it in the block, or reword it if it is not a claim about this PR. Offline: needs no pull request, reads nothing and writes nothing. Never a verdict: nothing passes or fails, and nothing is written to a record. The same text as \`dunstan suggest\`.`;
43054
43345
  var DESCRIPTION = `Check a completion handback against the repository's own record, before you say done. Give the subject (repository owner/name and pull request number) and exactly one of: block, the dunstan-handback block as a JSON object; or report, the report text that carries exactly one \`\`\`dunstan-handback fence. Of the checks claims, checks.allSucceeded is true only if every counted check run concluded success, so a skipped or neutral run makes it false; checks.noneFailed is true if none concluded other than success, skipped or neutral and at least one succeeded. Returns the verdict (pass, fail or unverifiable), the claims table, and the full record as JSON. Same checker and same verdict as \`dunstan check\` on the same evidence; no model decides. Reads GitHub metadata with GITHUB_TOKEN and writes nothing to GitHub or the repository; the record is written to a file only when you pass outPath. If the verdict is not pass, report the rows as they are; change the work, not the block.`;
43055
43346
  function reportOfBlock(block) {
43056
- const text = canonicalize(block);
43347
+ const text2 = canonicalize(block);
43057
43348
  return {
43058
43349
  report: {
43059
- bytes: new TextEncoder().encode(text),
43350
+ bytes: new TextEncoder().encode(text2),
43060
43351
  source: { kind: "api", locator: `mcp ${TOOL_NAME}#block` }
43061
43352
  },
43062
- block: recordBlock(readBlockContent(text))
43353
+ block: recordBlock(readBlockContent(text2))
43063
43354
  };
43064
43355
  }
43065
- function reportOfText(text) {
43356
+ function reportOfText(text2) {
43066
43357
  return {
43067
43358
  report: {
43068
- bytes: new TextEncoder().encode(text),
43359
+ bytes: new TextEncoder().encode(text2),
43069
43360
  source: { kind: "api", locator: `mcp ${TOOL_NAME}#report` }
43070
43361
  }
43071
43362
  };
@@ -43076,7 +43367,7 @@ async function checkHandback(input2, options) {
43076
43367
  "pass exactly one of block (the handback block as a JSON object) or report (text carrying one dunstan-handback fence)"
43077
43368
  );
43078
43369
  }
43079
- const outPath = input2.outPath === void 0 ? void 0 : resolve2(input2.outPath);
43370
+ const outPath = input2.outPath === void 0 ? void 0 : resolve3(input2.outPath);
43080
43371
  if (outPath !== void 0 && existsSync(outPath)) {
43081
43372
  throw new InputError(`${outPath} exists; the tool never overwrites a file`);
43082
43373
  }
@@ -43108,10 +43399,10 @@ async function checkHandback(input2, options) {
43108
43399
  recordFile: basename2(recordFile)
43109
43400
  });
43110
43401
  const serialized = serializeRecord(record2);
43111
- if (outPath !== void 0) writeFileSync2(outPath, serialized, { flag: "wx" });
43112
- return handbackResult(record2, outPath, notes);
43402
+ if (outPath !== void 0) writeFileSync3(outPath, serialized, { flag: "wx" });
43403
+ return handbackResult(record2, outPath, notes, { hasToken: client.hasToken });
43113
43404
  }
43114
- function handbackResult(record2, outPath, notes) {
43405
+ function handbackResult(record2, outPath, notes, context) {
43115
43406
  const verdict = record2.predicate.verdict;
43116
43407
  const head = [`verdict: ${verdict}`];
43117
43408
  if (verdict !== "pass") {
@@ -43125,7 +43416,7 @@ function handbackResult(record2, outPath, notes) {
43125
43416
  content: [
43126
43417
  { type: "text", text: `${head.join("\n")}
43127
43418
  ` },
43128
- { type: "text", text: renderRecord(record2) },
43419
+ { type: "text", text: renderRecord(record2, context) },
43129
43420
  { type: "text", text: serializeRecord(record2) }
43130
43421
  ]
43131
43422
  };
@@ -43211,8 +43502,8 @@ async function mcp(args, io) {
43211
43502
  warn: io.err
43212
43503
  });
43213
43504
  const stdin = io.stdin ?? process.stdin;
43214
- const closed = new Promise((resolve4) => {
43215
- server.server.onclose = resolve4;
43505
+ const closed = new Promise((resolve5) => {
43506
+ server.server.onclose = resolve5;
43216
43507
  });
43217
43508
  stdin.once("end", () => void server.close());
43218
43509
  await server.connect(new StdioServerTransport(stdin, io.stdout ?? process.stdout));
@@ -43221,9 +43512,9 @@ async function mcp(args, io) {
43221
43512
  }
43222
43513
 
43223
43514
  // src/cli/rerun.ts
43224
- import { existsSync as existsSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
43225
- import { basename as basename3, resolve as resolve3 } from "node:path";
43226
- import { parseArgs as parseArgs2 } from "node:util";
43515
+ import { existsSync as existsSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
43516
+ import { basename as basename3, resolve as resolve4 } from "node:path";
43517
+ import { parseArgs as parseArgs3 } from "node:util";
43227
43518
  function diffPointers(a, b, pointer = "") {
43228
43519
  const isObject2 = (v) => v !== null && typeof v === "object";
43229
43520
  if (Array.isArray(a) && Array.isArray(b)) {
@@ -43245,7 +43536,7 @@ function defaultOut(path) {
43245
43536
  return path.endsWith(".json") ? `${path.slice(0, -5)}.rerun.json` : `${path}.rerun.json`;
43246
43537
  }
43247
43538
  async function rerun(args, io) {
43248
- const { values, positionals } = parseArgs2({
43539
+ const { values, positionals } = parseArgs3({
43249
43540
  args,
43250
43541
  strict: true,
43251
43542
  allowPositionals: true,
@@ -43254,9 +43545,9 @@ async function rerun(args, io) {
43254
43545
  if (positionals.length !== 1) throw new UsageError("dunstan rerun <record.json> [--out <path>]");
43255
43546
  const path = positionals[0];
43256
43547
  const out = values.out ?? defaultOut(path);
43257
- if (resolve3(out) === resolve3(path))
43548
+ if (resolve4(out) === resolve4(path))
43258
43549
  throw new UsageError("rerun never overwrites the record it re-runs");
43259
- const value = parseStrictJson(readFileSync4(path, "utf8"));
43550
+ const value = parseStrictJson(readFileSync5(path, "utf8"));
43260
43551
  const errors = validateRecord(value);
43261
43552
  if (errors.length > 0) {
43262
43553
  throw new UsageError(
@@ -43294,7 +43585,7 @@ async function rerun(args, io) {
43294
43585
  });
43295
43586
  if (existsSync2(out)) io.err(`dunstan: replacing ${out}
43296
43587
  `);
43297
- writeFileSync3(out, serializeRecord(record2));
43588
+ writeFileSync4(out, serializeRecord(record2));
43298
43589
  const p = record2.predicate;
43299
43590
  if (p.digests.evidence !== old.digests.evidence) {
43300
43591
  const fields = diffPointers(
@@ -43313,17 +43604,17 @@ async function rerun(args, io) {
43313
43604
 
43314
43605
  `);
43315
43606
  }
43316
- io.out(renderRecord(record2));
43607
+ io.out(renderRecord(record2, { hasToken: client.hasToken }));
43317
43608
  io.out(`record: ${out} (the original ${path} is unchanged)
43318
43609
  `);
43319
43610
  return exitFor(p.verdict);
43320
43611
  }
43321
43612
 
43322
43613
  // src/cli/suggest.ts
43323
- import { readFileSync as readFileSync5 } from "node:fs";
43324
- import { parseArgs as parseArgs3 } from "node:util";
43614
+ import { readFileSync as readFileSync6 } from "node:fs";
43615
+ import { parseArgs as parseArgs4 } from "node:util";
43325
43616
  async function suggest(args, io) {
43326
- const { values } = parseArgs3({
43617
+ const { values } = parseArgs4({
43327
43618
  args,
43328
43619
  strict: true,
43329
43620
  allowPositionals: false,
@@ -43332,7 +43623,7 @@ async function suggest(args, io) {
43332
43623
  const file2 = values["report-file"];
43333
43624
  let bytes;
43334
43625
  try {
43335
- bytes = file2 === void 0 || file2 === "-" ? io.readStdin() : readFileSync5(file2);
43626
+ bytes = file2 === void 0 || file2 === "-" ? io.readStdin() : readFileSync6(file2);
43336
43627
  } catch (e) {
43337
43628
  io.err(`dunstan: cannot read the report ${file2 ?? "stdin"}: ${e.message}
43338
43629
  `);
@@ -43342,61 +43633,833 @@ async function suggest(args, io) {
43342
43633
  return 0;
43343
43634
  }
43344
43635
 
43636
+ // src/cli/survey.ts
43637
+ import { writeFileSync as writeFileSync5 } from "node:fs";
43638
+ import { basename as basename4 } from "node:path";
43639
+ import { parseArgs as parseArgs5 } from "node:util";
43640
+
43641
+ // src/survey/mapping.ts
43642
+ var SURVEY_MAPPING_VERSION = "0.2";
43643
+ var CLAIM_TYPES = [
43644
+ { type: "T1", label: "head commit" },
43645
+ { type: "T2", label: "files changed" },
43646
+ { type: "T3", label: "test count" },
43647
+ { type: "T4", label: "test failures" },
43648
+ { type: "T5", label: "check run total" },
43649
+ { type: "T6", label: "checks succeeded / none failed" },
43650
+ { type: "T7", label: "merged at" },
43651
+ { type: "T8", label: "deployed at" },
43652
+ { type: "R", label: "references (a row the original study did not have)" }
43653
+ ];
43654
+ var NOT_RECORD_CHECKABLE = [
43655
+ "record_not_found",
43656
+ "no_comparable_record_field",
43657
+ "local_run_not_in_record"
43658
+ ];
43659
+ function claimType(claim2) {
43660
+ const { check: check3, field } = claim2;
43661
+ if (check3 === "head") return "T1";
43662
+ if (check3 === "scope") return "T2";
43663
+ if (check3 === "reference") return "R";
43664
+ if (check3 === "count") {
43665
+ if (/^\/tests\/[0-9]+\/count$/.test(field)) return "T3";
43666
+ if (/^\/tests\/[0-9]+\/failures$/.test(field)) return "T4";
43667
+ if (field === "/checks/total") return "T5";
43668
+ if (field === "/checks/allSucceeded" || field === "/checks/noneFailed") return "T6";
43669
+ }
43670
+ if (check3 === "time") {
43671
+ if (field === "/mergedAt") return "T7";
43672
+ if (field === "/deployedAt") return "T8";
43673
+ }
43674
+ throw new Error(`no claim type for ${check3} ${field}`);
43675
+ }
43676
+ function column(claim2) {
43677
+ if (claim2.verdict === "pass") return "supported";
43678
+ if (claim2.verdict === "fail") return "contradicted";
43679
+ return NOT_RECORD_CHECKABLE.includes(claim2.reason ?? "") ? "notRecordCheckable" : "unverifiable";
43680
+ }
43681
+
43682
+ // src/survey/aggregate.ts
43683
+ var Z = 1.96;
43684
+ var DECIMALS = 4;
43685
+ function fixed(x) {
43686
+ return Math.min(1, Math.max(0, x)).toFixed(DECIMALS);
43687
+ }
43688
+ function rate(numerator, denominator) {
43689
+ if (denominator === 0) return { numerator, denominator, value: null, wilson95: null };
43690
+ const n = denominator;
43691
+ const p = numerator / n;
43692
+ const z2 = Z * Z;
43693
+ const centre = (p + z2 / (2 * n)) / (1 + z2 / n);
43694
+ const half = Z * Math.sqrt(p * (1 - p) / n + z2 / (4 * n * n)) / (1 + z2 / n);
43695
+ return {
43696
+ numerator,
43697
+ denominator,
43698
+ value: fixed(p),
43699
+ wilson95: {
43700
+ low: numerator === 0 ? fixed(0) : fixed(centre - half),
43701
+ high: numerator === n ? fixed(1) : fixed(centre + half)
43702
+ }
43703
+ };
43704
+ }
43705
+ var zero = () => ({
43706
+ supported: 0,
43707
+ unverifiable: 0,
43708
+ notRecordCheckable: 0,
43709
+ contradicted: 0
43710
+ });
43711
+ var byCodeUnit = (a, b) => a < b ? -1 : a > b ? 1 : 0;
43712
+ function aggregate(entries, listed2, advisory, reader) {
43713
+ const context = { hasToken: reader.authenticated };
43714
+ const totals = zero();
43715
+ const types = new Map(CLAIM_TYPES.map((t) => [t.type, zero()]));
43716
+ const unsettled2 = /* @__PURE__ */ new Map();
43717
+ const noRecord = /* @__PURE__ */ new Map();
43718
+ const notes = /* @__PURE__ */ new Map();
43719
+ const blocks = { found: 0, missing: 0, invalid: 0, ambiguous: 0 };
43720
+ let withRecord = 0;
43721
+ let advised = 0;
43722
+ for (const entry of entries) {
43723
+ if (entry.record === null) {
43724
+ noRecord.set(entry.reason, (noRecord.get(entry.reason) ?? 0) + 1);
43725
+ continue;
43726
+ }
43727
+ withRecord++;
43728
+ const p = entry.record.predicate;
43729
+ blocks[p.block.status]++;
43730
+ for (const claim2 of p.claims) {
43731
+ const col = column(claim2);
43732
+ totals[col]++;
43733
+ types.get(claimType(claim2))[col]++;
43734
+ if (col === "unverifiable" || col === "notRecordCheckable") {
43735
+ const reason = claim2.reason;
43736
+ const seen = unsettled2.get(reason) ?? { column: col, count: 0 };
43737
+ seen.count++;
43738
+ unsettled2.set(reason, seen);
43739
+ }
43740
+ }
43741
+ if (p.advisory !== void 0) {
43742
+ advised++;
43743
+ for (const a of p.advisory.advisories) notes.set(a.note, (notes.get(a.note) ?? 0) + 1);
43744
+ }
43745
+ }
43746
+ const total = totals.supported + totals.unverifiable + totals.notRecordCheckable + totals.contradicted;
43747
+ const result = {
43748
+ mapping: { version: SURVEY_MAPPING_VERSION },
43749
+ pullRequests: {
43750
+ listed: listed2,
43751
+ selected: entries.length,
43752
+ withRecord,
43753
+ blockFound: blocks.found,
43754
+ blockMissing: blocks.missing,
43755
+ blockInvalid: blocks.invalid,
43756
+ blockAmbiguous: blocks.ambiguous,
43757
+ noRecord: [...noRecord.entries()].sort(([a], [b]) => byCodeUnit(a, b)).map(([reason, count]) => ({ reason, count }))
43758
+ },
43759
+ claims: { total, ...totals },
43760
+ byType: CLAIM_TYPES.map((t) => ({ type: t.type, ...types.get(t.type) })),
43761
+ cannotSettle: rate(totals.unverifiable + totals.notRecordCheckable, total),
43762
+ contradictedOfSettled: rate(totals.contradicted, totals.supported + totals.contradicted),
43763
+ unsettled: [...unsettled2.entries()].sort(([a, x], [b, y]) => y.count - x.count || byCodeUnit(a, b)).map(([reason, { column: col, count }]) => ({
43764
+ reason,
43765
+ column: col,
43766
+ count,
43767
+ remedy: remedyForReason(reason, context) ?? null
43768
+ }))
43769
+ };
43770
+ if (advisory) {
43771
+ result.advisory = {
43772
+ pullRequests: advised,
43773
+ notes: [...notes.entries()].sort(([a], [b]) => byCodeUnit(a, b)).map(([note, count]) => ({ note, count }))
43774
+ };
43775
+ }
43776
+ return result;
43777
+ }
43778
+
43779
+ // src/survey/record.ts
43780
+ var SURVEY_FORMAT = "dunstan-survey-0.1-draft";
43781
+ var LISTING_PER_PAGE = 100;
43782
+ var LISTING_READS = 3;
43783
+ var DEFAULT_LAST = 30;
43784
+ var MAX_LAST = 200;
43785
+ function appendListed(listed2, seen, items) {
43786
+ let duplicates = 0;
43787
+ for (const item of items) {
43788
+ if (seen.has(item.number)) duplicates++;
43789
+ else {
43790
+ seen.add(item.number);
43791
+ listed2.push(item);
43792
+ }
43793
+ }
43794
+ return duplicates;
43795
+ }
43796
+ var sinceStart = (since) => `${since}T00:00:00Z`;
43797
+ function selectPullRequests(parameters, listed2) {
43798
+ const authors = new Set(parameters.authors.map((a) => a.toLowerCase()));
43799
+ const seen = /* @__PURE__ */ new Set();
43800
+ return listed2.filter((pr) => {
43801
+ if (seen.has(pr.number)) return false;
43802
+ seen.add(pr.number);
43803
+ return true;
43804
+ }).filter((pr) => parameters.since === null || pr.createdAt >= sinceStart(parameters.since)).filter(
43805
+ (pr) => authors.size === 0 || pr.author !== null && authors.has(pr.author.toLowerCase())
43806
+ ).slice(0, parameters.last).map((pr) => pr.number);
43807
+ }
43808
+ function listingStop(parameters, pages, listed2) {
43809
+ const last = pages.at(-1);
43810
+ if (last === void 0) return null;
43811
+ if (last.error !== void 0) return "listing_unreadable";
43812
+ if (selectPullRequests(parameters, listed2).length >= parameters.last) return "selection_full";
43813
+ const since = parameters.since;
43814
+ if (since !== null && listed2.some((pr) => pr.createdAt < sinceStart(since))) {
43815
+ return "since_reached";
43816
+ }
43817
+ if (last.items < LISTING_PER_PAGE) return "listing_ended";
43818
+ return null;
43819
+ }
43820
+ function surveyCommand(parameters, out) {
43821
+ const words2 = [
43822
+ "dunstan",
43823
+ "survey",
43824
+ "--repo",
43825
+ parameters.repository,
43826
+ "--last",
43827
+ `${parameters.last}`
43828
+ ];
43829
+ if (parameters.since !== null) words2.push("--since", parameters.since);
43830
+ for (const author of parameters.authors) words2.push("--author", author);
43831
+ if (parameters.report.kind === "pr-comment") {
43832
+ words2.push("--report-comment-author", parameters.report.author);
43833
+ }
43834
+ if (parameters.advisory) words2.push("--advisory");
43835
+ words2.push("--out", out);
43836
+ return words2.join(" ");
43837
+ }
43838
+ function surveyRerun(parameters, out) {
43839
+ return { offline: `dunstan verify ${out}`, online: surveyCommand(parameters, out) };
43840
+ }
43841
+ function surveyDigest(record2) {
43842
+ const { digest: _, ...rest } = record2;
43843
+ return sha256Canonical(rest);
43844
+ }
43845
+ function buildSurveyRecord(input2) {
43846
+ const unsigned = {
43847
+ format: SURVEY_FORMAT,
43848
+ checker: input2.checker,
43849
+ selection: {
43850
+ parameters: input2.parameters,
43851
+ reader: { authenticated: input2.reader.authenticated },
43852
+ listing: input2.listing,
43853
+ selected: selectPullRequests(input2.parameters, input2.listing.pullRequests)
43854
+ },
43855
+ entries: input2.entries,
43856
+ aggregate: aggregate(
43857
+ input2.entries,
43858
+ input2.listing.pullRequests.length,
43859
+ input2.parameters.advisory,
43860
+ input2.reader
43861
+ ),
43862
+ rerun: surveyRerun(input2.parameters, input2.out)
43863
+ };
43864
+ return { ...unsigned, digest: { sha256: surveyDigest(unsigned) } };
43865
+ }
43866
+ function serializeSurvey(record2) {
43867
+ return `${JSON.stringify(record2, null, 2)}
43868
+ `;
43869
+ }
43870
+ function isSurveyRecord(value) {
43871
+ return value !== null && typeof value === "object" && !Array.isArray(value) && value.format === SURVEY_FORMAT;
43872
+ }
43873
+
43874
+ // src/pipeline/survey.ts
43875
+ var recordFileName = (repository, number4) => `dunstan-${repository.replace("/", "-")}-${number4}.json`;
43876
+ function rateLimited(response) {
43877
+ const s = response.status;
43878
+ return s === 429 || s === 403 && (response.headers.get("x-ratelimit-remaining") === "0" || response.headers.get("retry-after") !== null);
43879
+ }
43880
+ function str2(value) {
43881
+ return typeof value === "string" ? value : null;
43882
+ }
43883
+ function listedOf(item) {
43884
+ if (item === null || typeof item !== "object") return null;
43885
+ const pr = item;
43886
+ const createdAt = str2(pr.created_at);
43887
+ if (typeof pr.number !== "number" || !Number.isSafeInteger(pr.number) || pr.number < 1 || createdAt === null || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z$/.test(createdAt)) {
43888
+ return null;
43889
+ }
43890
+ const user = pr.user;
43891
+ const login = str2(user?.login);
43892
+ return {
43893
+ number: pr.number,
43894
+ createdAt,
43895
+ author: login === "" ? null : login,
43896
+ merged: pr.merged_at !== null && pr.merged_at !== void 0
43897
+ };
43898
+ }
43899
+ async function readListing(client, parameters, now, pageBound) {
43900
+ const abandoned = [];
43901
+ for (; ; ) {
43902
+ const last = abandoned.length === LISTING_READS - 1;
43903
+ const read = await readOnce(client, parameters, now, pageBound, !last);
43904
+ if ("end" in read) return { ...read, abandoned };
43905
+ abandoned.push(read);
43906
+ }
43907
+ }
43908
+ async function readOnce(client, parameters, now, pageBound, abandonOnRepeat) {
43909
+ const pages = [];
43910
+ const pullRequests = [];
43911
+ const seen = /* @__PURE__ */ new Set();
43912
+ const done = (end) => ({
43913
+ pageBound,
43914
+ pages,
43915
+ pullRequests,
43916
+ duplicates: pages.reduce((sum, p) => sum + p.duplicates, 0),
43917
+ end
43918
+ });
43919
+ for (let page = 1; page <= pageBound; page++) {
43920
+ const path = `/repos/${parameters.repository}/pulls?state=closed&sort=created&direction=desc&per_page=${LISTING_PER_PAGE}&page=${page}`;
43921
+ const read = await client.read("pull_request", path, { record: false });
43922
+ const entry = {
43923
+ page,
43924
+ locator: `GET ${path}`,
43925
+ sha256: sha256Hex(read.body),
43926
+ readAt: now().toISOString(),
43927
+ items: 0,
43928
+ duplicates: 0
43929
+ };
43930
+ if (!read.ok) {
43931
+ entry.error = read.error;
43932
+ } else {
43933
+ const items = Array.isArray(read.json) ? read.json.map(listedOf) : null;
43934
+ if (items === null || items.some((i) => i === null)) entry.error = "parse";
43935
+ else {
43936
+ entry.items = items.length;
43937
+ entry.duplicates = appendListed(pullRequests, seen, items);
43938
+ }
43939
+ }
43940
+ pages.push(entry);
43941
+ if (abandonOnRepeat && entry.duplicates > 0) return { pages };
43942
+ const end = listingStop(parameters, pages, pullRequests);
43943
+ if (end !== null) return done(end);
43944
+ }
43945
+ return done("page_bound");
43946
+ }
43947
+ async function survey(input2) {
43948
+ const { parameters } = input2;
43949
+ const now = input2.now ?? (() => /* @__PURE__ */ new Date());
43950
+ const limited = /* @__PURE__ */ new Set();
43951
+ const baseFetch = input2.fetch ?? fetch;
43952
+ const watched = (async (resource, init) => {
43953
+ const response = await baseFetch(resource, init);
43954
+ if (rateLimited(response)) {
43955
+ const url2 = typeof resource === "string" ? resource : resource instanceof URL ? resource.href : resource.url;
43956
+ limited.add(new URL(url2).pathname);
43957
+ }
43958
+ return response;
43959
+ });
43960
+ const newClient = () => new GitHubClient({
43961
+ token: input2.token,
43962
+ fetch: watched,
43963
+ now,
43964
+ ...input2.sleep === void 0 ? {} : { sleep: input2.sleep },
43965
+ ...input2.warn === void 0 ? {} : { warn: input2.warn }
43966
+ });
43967
+ const listingClient = newClient();
43968
+ const reader = { authenticated: listingClient.hasToken };
43969
+ const listing = await readListing(listingClient, parameters, now, input2.pageBound ?? MAX_PAGES);
43970
+ const entries = [];
43971
+ for (const number4 of selectPullRequests(parameters, listing.pullRequests)) {
43972
+ const client = newClient();
43973
+ const repository = parameters.repository;
43974
+ const pullPath = `/repos/${repository}/pulls/${number4}`;
43975
+ const commentsPath = `/repos/${repository}/issues/${number4}/comments`;
43976
+ const report = parameters.report;
43977
+ try {
43978
+ const record2 = await checkPullRequest({
43979
+ readers: githubReaders(client),
43980
+ repository,
43981
+ number: number4,
43982
+ report: async (pr) => report.kind === "pr-body" ? reportFromPullRequestBody(pr.repository, pr) : reportFromComment(client, pr.repository, number4, report.author),
43983
+ checker: input2.checker,
43984
+ recordFile: recordFileName(repository, number4),
43985
+ advisory: parameters.advisory
43986
+ });
43987
+ entries.push({ pullRequest: number4, record: record2 });
43988
+ } catch (e) {
43989
+ let reason;
43990
+ if (e instanceof PullRequestMoved) reason = "pull_request_moved";
43991
+ else if (e instanceof PullRequestUnreadable) {
43992
+ reason = limited.has(pullPath) ? "rate_limited" : "pull_request_unreadable";
43993
+ } else if (e instanceof ReportUnavailable) {
43994
+ reason = limited.has(commentsPath) ? "rate_limited" : "report_unavailable";
43995
+ } else throw e;
43996
+ entries.push({ pullRequest: number4, record: null, reason });
43997
+ }
43998
+ }
43999
+ return buildSurveyRecord({
44000
+ checker: input2.checker,
44001
+ parameters,
44002
+ reader,
44003
+ listing,
44004
+ entries,
44005
+ out: input2.out
44006
+ });
44007
+ }
44008
+
44009
+ // src/survey/summary.ts
44010
+ var COLUMN_LABEL = {
44011
+ supported: "Supported",
44012
+ unverifiable: "Unverifiable",
44013
+ notRecordCheckable: "Not record-checkable",
44014
+ contradicted: "Contradicted"
44015
+ };
44016
+ var END_TEXT = {
44017
+ selection_full: "it held enough pull requests for the selection",
44018
+ since_reached: "it reached pull requests created before the --since date",
44019
+ listing_ended: "it ended",
44020
+ listing_unreadable: "a page could not be read; the selection is from the pages read before it",
44021
+ page_bound: "it reached the page bound"
44022
+ };
44023
+ var ANONYMOUS = "Without a token, every tests claim naming a JUnit record and every `closes` claim cannot be settled, so the cannot-settle share is higher than an authenticated read of the same repository would give.";
44024
+ function row(cells) {
44025
+ return `| ${cells.join(" | ")} |`;
44026
+ }
44027
+ function table2(head, rows) {
44028
+ return [row(head), row(head.map(() => "---")), ...rows.map(row)];
44029
+ }
44030
+ function share(r) {
44031
+ return r.value ?? "n/a";
44032
+ }
44033
+ function interval(r) {
44034
+ return r.wilson95 === null ? "n/a" : `${r.wilson95.low}\u2013${r.wilson95.high}`;
44035
+ }
44036
+ function plural2(n, one, many) {
44037
+ return `${n} ${n === 1 ? one : many}`;
44038
+ }
44039
+ function renderSurveySummary(record2) {
44040
+ const { parameters, listing, selected } = record2.selection;
44041
+ const a = record2.aggregate;
44042
+ const lines = [];
44043
+ const scope = [`the ${parameters.last} most recently created closed pull requests`];
44044
+ if (parameters.since !== null) scope.push(`created on or after ${parameters.since}`);
44045
+ if (parameters.authors.length > 0) scope.push(`opened by ${parameters.authors.join(", ")}`);
44046
+ const report = parameters.report.kind === "pr-body" ? "the pull request body" : `the latest comment by ${parameters.report.author}`;
44047
+ lines.push(
44048
+ `# Settleability survey: ${parameters.repository}`,
44049
+ "",
44050
+ `DRAFT (${record2.format}). ${record2.checker.name} ${record2.checker.version}, survey mapping ${a.mapping.version}. Read-only.`,
44051
+ "",
44052
+ `Selection: ${scope.join(", ")}. Report: ${report}. Advisory reader: ${parameters.advisory ? "on" : "off"}.`,
44053
+ "",
44054
+ record2.selection.reader.authenticated ? "GitHub was read with a token (GITHUB_TOKEN set)." : `GitHub was read without a token (GITHUB_TOKEN not set). ${ANONYMOUS}`,
44055
+ "",
44056
+ "This survey reports which claims the record could settle. A claim the record could not settle is neither supported nor contradicted.",
44057
+ "",
44058
+ "## Claims the record could not settle",
44059
+ "",
44060
+ `The record could not settle ${a.cannotSettle.numerator} of ${plural2(a.cannotSettle.denominator, "claim", "claims")}.`,
44061
+ ""
44062
+ );
44063
+ if (a.unsettled.length === 0) {
44064
+ lines.push("None.");
44065
+ } else {
44066
+ lines.push(
44067
+ ...table2(
44068
+ ["Reason", "Column", "Claims", "Remedy"],
44069
+ a.unsettled.map((u) => [
44070
+ `\`${u.reason}\``,
44071
+ COLUMN_LABEL[u.column],
44072
+ u.count,
44073
+ u.remedy ?? "none listed"
44074
+ ])
44075
+ )
44076
+ );
44077
+ }
44078
+ lines.push(
44079
+ "",
44080
+ "## Results",
44081
+ "",
44082
+ ...table2(
44083
+ ["Measure", "Claims", "Of", "Share", "Wilson 95%"],
44084
+ [
44085
+ [
44086
+ "The record could not settle",
44087
+ a.cannotSettle.numerator,
44088
+ `${a.cannotSettle.denominator} claims`,
44089
+ share(a.cannotSettle),
44090
+ interval(a.cannotSettle)
44091
+ ],
44092
+ [
44093
+ "Contradicted by the record, of the claims it settled",
44094
+ a.contradictedOfSettled.numerator,
44095
+ `${a.contradictedOfSettled.denominator} settled claims`,
44096
+ share(a.contradictedOfSettled),
44097
+ interval(a.contradictedOfSettled)
44098
+ ]
44099
+ ]
44100
+ ),
44101
+ "",
44102
+ "## By claim type",
44103
+ "",
44104
+ ...table2(
44105
+ ["Type", "Supported", "Unverifiable", "Not record-checkable", "Contradicted"],
44106
+ [
44107
+ ...a.byType.map((t) => {
44108
+ const label = CLAIM_TYPES.find((c) => c.type === t.type)?.label ?? "";
44109
+ return [
44110
+ `${t.type} ${label}`,
44111
+ t.supported,
44112
+ t.unverifiable,
44113
+ t.notRecordCheckable,
44114
+ t.contradicted
44115
+ ];
44116
+ }),
44117
+ [
44118
+ `All (${a.claims.total})`,
44119
+ a.claims.supported,
44120
+ a.claims.unverifiable,
44121
+ a.claims.notRecordCheckable,
44122
+ a.claims.contradicted
44123
+ ]
44124
+ ]
44125
+ )
44126
+ );
44127
+ if (a.advisory !== void 0) {
44128
+ lines.push(
44129
+ "",
44130
+ "## Advisory notes",
44131
+ "",
44132
+ `The advisory reader read the prose of ${plural2(a.advisory.pullRequests, "pull request", "pull requests")}. Its notes never enter either rate, and a possible disagreement is unverified.`,
44133
+ ""
44134
+ );
44135
+ if (a.advisory.notes.length === 0) lines.push("No notes.");
44136
+ else {
44137
+ lines.push(
44138
+ ...table2(
44139
+ ["Note", "As shown", "Count"],
44140
+ a.advisory.notes.map((n) => [`\`${n.note}\``, noteText(n.note), n.count])
44141
+ )
44142
+ );
44143
+ }
44144
+ }
44145
+ const p = a.pullRequests;
44146
+ const context = { hasToken: record2.selection.reader.authenticated };
44147
+ const remedy = (reason) => remedyForReason(reason, context) ?? "";
44148
+ lines.push(
44149
+ "",
44150
+ "## Pull requests",
44151
+ "",
44152
+ ...table2(
44153
+ ["Pull requests", "Count", "Remedy"],
44154
+ [
44155
+ ["Listed", p.listed, ""],
44156
+ ["Selected", p.selected, ""],
44157
+ ["With a valid block", p.blockFound, ""],
44158
+ ["`block_missing`", p.blockMissing, remedy("block_missing")],
44159
+ ["`block_invalid`", p.blockInvalid, remedy("block_invalid")],
44160
+ ["`block_ambiguous`", p.blockAmbiguous, remedy("block_ambiguous")],
44161
+ ...p.noRecord.map((n) => [`No record: \`${n.reason}\``, n.count, remedy(n.reason)])
44162
+ ]
44163
+ ),
44164
+ "",
44165
+ `The listing was read for ${plural2(listing.pages.length, "page", "pages")} and no further because ${listing.end === "page_bound" ? `it reached the bound of ${listing.pageBound} pages` : END_TEXT[listing.end]}.`
44166
+ );
44167
+ const abandoned = listing.abandoned.length;
44168
+ if (listing.duplicates > 0) {
44169
+ const one = listing.duplicates === 1;
44170
+ const reread = abandoned === 0 ? "" : ` It was read ${abandoned + 1} times, from the first page each time, and moved during every read.`;
44171
+ lines.push(
44172
+ "",
44173
+ `The listing named ${plural2(listing.duplicates, "pull request", "pull requests")} again on a later page, so it changed while it was read: most likely ${plural2(listing.duplicates, "pull request", "pull requests")} closed meanwhile and ${one ? "was" : "were"} not listed, and the selection may be missing ${one ? "it" : "them"}. Each repeated pull request is listed, selected and counted once: the pages held ${listing.pullRequests.length + listing.duplicates} items for ${listing.pullRequests.length} pull requests.${reread}`
44174
+ );
44175
+ } else if (abandoned > 0) {
44176
+ lines.push(
44177
+ "",
44178
+ `The listing moved while it was read: ${plural2(abandoned, "read was", "reads were")} abandoned at a repeated pull request and the listing was read again from the first page. The read used repeated no pull request.`
44179
+ );
44180
+ }
44181
+ if (selected.length < parameters.last) {
44182
+ lines.push(
44183
+ "",
44184
+ `${plural2(selected.length, "pull request was", "pull requests were")} selected, fewer than the ${parameters.last} asked for.`
44185
+ );
44186
+ }
44187
+ lines.push(
44188
+ "",
44189
+ `Verify offline: \`${record2.rerun.offline}\`. Run again: \`${record2.rerun.online}\`.`,
44190
+ ""
44191
+ );
44192
+ return lines.join("\n");
44193
+ }
44194
+
44195
+ // src/cli/survey.ts
44196
+ var LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})(?:\[bot\])?$/;
44197
+ function parseLogin(value, flag) {
44198
+ if (!LOGIN.test(value)) throw new UsageError(`${flag} must be a GitHub login`);
44199
+ return value;
44200
+ }
44201
+ function parseSince(value) {
44202
+ const day = /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
44203
+ const valid = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(value) && !Number.isNaN(day.getTime()) && day.toISOString().slice(0, 10) === value;
44204
+ if (!valid) throw new UsageError("--since must be a date, YYYY-MM-DD (UTC)");
44205
+ return value;
44206
+ }
44207
+ async function survey2(args, io) {
44208
+ const { values } = parseArgs5({
44209
+ args,
44210
+ strict: true,
44211
+ allowPositionals: false,
44212
+ options: {
44213
+ repo: { type: "string" },
44214
+ last: { type: "string" },
44215
+ since: { type: "string" },
44216
+ author: { type: "string", multiple: true },
44217
+ "report-comment-author": { type: "string" },
44218
+ advisory: { type: "boolean" },
44219
+ out: { type: "string" },
44220
+ summary: { type: "string" }
44221
+ }
44222
+ });
44223
+ if (values.repo === void 0 || !REPOSITORY.test(values.repo)) {
44224
+ throw new UsageError("--repo <owner/name> is required");
44225
+ }
44226
+ if (values.out === void 0) throw new UsageError("--out <survey.json> is required");
44227
+ const last = values.last === void 0 ? DEFAULT_LAST : parsePositiveInt(values.last, "--last");
44228
+ if (last > MAX_LAST) throw new UsageError(`--last is at most ${MAX_LAST}`);
44229
+ const commentAuthor = values["report-comment-author"];
44230
+ const parameters = {
44231
+ repository: values.repo,
44232
+ last,
44233
+ since: values.since === void 0 ? null : parseSince(values.since),
44234
+ authors: (values.author ?? []).map((a) => parseLogin(a, "--author")),
44235
+ report: commentAuthor === void 0 ? { kind: "pr-body" } : { kind: "pr-comment", author: parseLogin(commentAuthor, "--report-comment-author") },
44236
+ advisory: values.advisory === true
44237
+ };
44238
+ const token = io.env.GITHUB_TOKEN;
44239
+ if (token === void 0 || token === "") {
44240
+ io.err(
44241
+ "dunstan: GITHUB_TOKEN is not set; reading anonymously (GitHub serves no artifact to an anonymous reader)\n"
44242
+ );
44243
+ }
44244
+ const record2 = await survey({
44245
+ parameters,
44246
+ checker: checkerIdentity(io.artifact),
44247
+ out: basename4(values.out),
44248
+ token,
44249
+ ...io.fetch === void 0 ? {} : { fetch: io.fetch },
44250
+ ...io.now === void 0 ? {} : { now: io.now },
44251
+ warn: (message) => io.err(`dunstan: ${message}
44252
+ `)
44253
+ });
44254
+ writeFileSync5(values.out, serializeSurvey(record2));
44255
+ const summary = renderSurveySummary(record2);
44256
+ if (values.summary === void 0) {
44257
+ io.out(summary);
44258
+ io.err(`dunstan: survey record: ${values.out}
44259
+ `);
44260
+ } else {
44261
+ writeFileSync5(values.summary, summary);
44262
+ io.out(`survey record: ${values.out}
44263
+ summary: ${values.summary}
44264
+ `);
44265
+ }
44266
+ return 0;
44267
+ }
44268
+
43345
44269
  // src/cli/template.ts
43346
- import { parseArgs as parseArgs4 } from "node:util";
43347
- var TEST_RECORD_FLAGS = [
43348
- "tests-command",
44270
+ import { parseArgs as parseArgs6 } from "node:util";
44271
+
44272
+ // src/template/tests-ci.ts
44273
+ var plural3 = (n, one, many) => `${n} ${n === 1 ? one : many}`;
44274
+ function unsettled(reason, why, remedy) {
44275
+ const notes = [`tests are not declared: ${why} (${reason}).`];
44276
+ if (remedy !== void 0) notes.push(`remedy: ${remedy}`);
44277
+ return { status: "unsettled", reason, notes };
44278
+ }
44279
+ async function testsFromCi(client, input2) {
44280
+ const { repository, headCommit: head, record: record2 } = input2;
44281
+ if (!client.hasToken) {
44282
+ return unsettled(
44283
+ "no_token",
44284
+ "GITHUB_TOKEN is not set, and GitHub answers an artifact download only with a token, even on a public repository",
44285
+ "set GITHUB_TOKEN to a token that can read the repository, with actions: read, and run dunstan template --tests-ci again."
44286
+ );
44287
+ }
44288
+ if (!input2.pushed) {
44289
+ return unsettled(
44290
+ "not_pushed",
44291
+ `HEAD ${head} is not on any remote-tracking branch, so no CI run can have tested it`,
44292
+ "push it, wait for CI to finish, and run dunstan template --tests-ci again (run git fetch first if it was pushed from elsewhere)."
44293
+ );
44294
+ }
44295
+ const observed = { runsNotCompleted: 0 };
44296
+ const evidence = await readTestRecord(client, repository, record2, head, observed);
44297
+ const named = `the artifact ${record2.artifact} holding ${record2.path} from job ${record2.job}`;
44298
+ const pending = observed.runsNotCompleted > 0 ? `CI has not finished: ${plural3(observed.runsNotCompleted, "run", "runs")} of ${record2.workflow} at ${head} ${observed.runsNotCompleted === 1 ? "is" : "are"} not completed` : void 0;
44299
+ const again = "run dunstan template --tests-ci again once CI has finished on this commit.";
44300
+ if (evidence.status !== "ok") {
44301
+ const reason = evidence.status === "unreadable" ? `source_unreadable:${evidence.source}` : `evidence_field_unpopulated:${evidence.field}`;
44302
+ if (pending !== void 0) return unsettled(reason, pending, again);
44303
+ const why = reason === "source_unreadable:artifact" ? `a run of ${record2.workflow} at ${head} uploaded ${record2.artifact}, and it could not be read` : `the workflow runs of ${record2.workflow} at ${head} could not be read`;
44304
+ return unsettled(
44305
+ reason,
44306
+ why,
44307
+ remedyForReason(reason, { hasToken: true }) ?? "run dunstan template --tests-ci again."
44308
+ );
44309
+ }
44310
+ if (evidence.runs.length === 0) {
44311
+ if (pending !== void 0) {
44312
+ return unsettled(
44313
+ "record_not_found",
44314
+ `${pending}, and no completed run uploaded ${named}`,
44315
+ again
44316
+ );
44317
+ }
44318
+ return unsettled(
44319
+ "record_not_found",
44320
+ `no run of ${record2.workflow} at ${head} uploaded ${named}`,
44321
+ remedyForReason("record_not_found", { hasToken: true })
44322
+ );
44323
+ }
44324
+ const executed = new Set(evidence.runs.map((r) => r.executed));
44325
+ const failed = new Set(evidence.runs.map((r) => r.failed));
44326
+ if (executed.size > 1 || failed.size > 1) {
44327
+ const counts = evidence.runs.map((r) => `run ${r.runId}: ${r.executed} executed, ${r.failed} failed`).join("; ");
44328
+ return unsettled(
44329
+ "record_ambiguous",
44330
+ `the runs of ${record2.workflow} at ${head} that uploaded ${record2.artifact} disagree (${counts}), and the gate never picks one`,
44331
+ "make the runs agree (re-run the one that differs), or name a job only one of them runs."
44332
+ );
44333
+ }
44334
+ const run = evidence.runs[0];
44335
+ const runs = evidence.runs.map((r) => r.runId).join(", ");
44336
+ const notes = [
44337
+ `tests: ${run.executed} executed and ${run.failed} failed in ${record2.path} in the artifact ${record2.artifact} of job ${record2.job} in ${record2.workflow} at ${head}, read from CI as the gate reads it (${evidence.runs.length === 1 ? "run" : "runs"} ${runs}).`
44338
+ ];
44339
+ if (pending !== void 0) {
44340
+ notes.push(
44341
+ `${pending}; a run that finishes later with other counts makes the gate's read ambiguous. Run dunstan template --tests-ci again once CI has finished.`
44342
+ );
44343
+ }
44344
+ return {
44345
+ status: "settled",
44346
+ run: { command: input2.command, count: run.executed, failures: run.failed, record: record2 },
44347
+ notes
44348
+ };
44349
+ }
44350
+
44351
+ // src/cli/template.ts
44352
+ var RECORD_FLAGS = [
43349
44353
  "tests-workflow",
43350
44354
  "tests-job",
43351
44355
  "tests-artifact",
43352
44356
  "tests-artifact-path"
43353
44357
  ];
44358
+ var DEFAULT_ARTIFACT = "dunstan-junit";
44359
+ var DEFAULT_ARTIFACT_PATH = "junit.xml";
43354
44360
  async function template(args, io) {
43355
- const { values } = parseArgs4({
44361
+ const { values } = parseArgs6({
43356
44362
  args,
43357
44363
  strict: true,
43358
44364
  allowPositionals: false,
43359
44365
  options: {
43360
44366
  base: { type: "string" },
44367
+ "tests-ci": { type: "boolean" },
44368
+ "tests-local": { type: "string" },
43361
44369
  "tests-junit": { type: "string" },
43362
- ...Object.fromEntries(TEST_RECORD_FLAGS.map((f) => [f, { type: "string" }]))
44370
+ "tests-command": { type: "string" },
44371
+ repo: { type: "string" },
44372
+ ...Object.fromEntries(RECORD_FLAGS.map((f) => [f, { type: "string" }]))
43363
44373
  }
43364
44374
  });
43365
44375
  const v = values;
44376
+ const flag = (name) => v[name];
44377
+ const ci = v["tests-ci"] === true;
44378
+ const modes = [ci, flag("tests-local") !== void 0, flag("tests-junit") !== void 0];
44379
+ if (modes.filter(Boolean).length > 1) {
44380
+ throw new UsageError("give one of --tests-ci, --tests-local or --tests-junit");
44381
+ }
44382
+ if (flag("repo") !== void 0 && !ci) throw new UsageError("--repo goes with --tests-ci");
44383
+ const given = ["tests-command", ...RECORD_FLAGS].filter((f) => flag(f) !== void 0);
44384
+ const cwd = io.cwd ?? process.cwd();
43366
44385
  let tests;
43367
- const given = TEST_RECORD_FLAGS.filter((f) => v[f] !== void 0);
43368
- if (v["tests-junit"] === void 0) {
43369
- if (given.length > 0) {
43370
- throw new UsageError(`--${given[0]} goes with --tests-junit <JUnit XML file>`);
44386
+ if (ci) {
44387
+ const missing = ["tests-command", "tests-workflow", "tests-job"].filter(
44388
+ (f) => flag(f) === void 0
44389
+ );
44390
+ if (missing.length > 0) {
44391
+ throw new UsageError(`--tests-ci needs ${missing.map((f) => `--${f}`).join(", ")}`);
44392
+ }
44393
+ const repo = flag("repo");
44394
+ if (repo !== void 0 && !REPOSITORY.test(repo)) {
44395
+ throw new UsageError("--repo must be <owner/name>");
44396
+ }
44397
+ const head = headCommit(cwd);
44398
+ const repository = repo ?? originRepository(cwd);
44399
+ const client = new GitHubClient({
44400
+ token: io.env.GITHUB_TOKEN,
44401
+ ...io.fetch === void 0 ? {} : { fetch: io.fetch },
44402
+ warn: (message) => io.err(`dunstan: ${message}
44403
+ `)
44404
+ });
44405
+ const read = await testsFromCi(client, {
44406
+ repository,
44407
+ headCommit: head,
44408
+ command: flag("tests-command"),
44409
+ pushed: isPushed(cwd, head),
44410
+ record: {
44411
+ kind: "junit",
44412
+ workflow: flag("tests-workflow"),
44413
+ job: flag("tests-job"),
44414
+ artifact: flag("tests-artifact") ?? DEFAULT_ARTIFACT,
44415
+ path: flag("tests-artifact-path") ?? DEFAULT_ARTIFACT_PATH
44416
+ }
44417
+ });
44418
+ tests = { kind: "ci", read };
44419
+ } else if (flag("tests-local") !== void 0) {
44420
+ if (flag("tests-command") === void 0) {
44421
+ throw new UsageError("--tests-local needs --tests-command <cmd>");
43371
44422
  }
43372
- } else {
43373
- const missing = TEST_RECORD_FLAGS.filter((f) => v[f] === void 0);
44423
+ const extra = RECORD_FLAGS.find((f) => flag(f) !== void 0);
44424
+ if (extra !== void 0) throw new UsageError(`--${extra} does not go with --tests-local`);
44425
+ tests = {
44426
+ kind: "local",
44427
+ junit: flag("tests-local"),
44428
+ command: flag("tests-command")
44429
+ };
44430
+ } else if (flag("tests-junit") !== void 0) {
44431
+ const missing = ["tests-command", ...RECORD_FLAGS].filter(
44432
+ (f) => flag(f) === void 0
44433
+ );
43374
44434
  if (missing.length > 0) {
43375
44435
  throw new UsageError(
43376
44436
  `--tests-junit needs the CI record its counts are checked against: ${missing.map((f) => `--${f}`).join(", ")}`
43377
44437
  );
43378
44438
  }
43379
44439
  tests = {
43380
- junit: v["tests-junit"],
43381
- command: v["tests-command"],
44440
+ kind: "junit",
44441
+ junit: flag("tests-junit"),
44442
+ command: flag("tests-command"),
43382
44443
  record: {
43383
- workflow: v["tests-workflow"],
43384
- job: v["tests-job"],
43385
- artifact: v["tests-artifact"],
43386
- path: v["tests-artifact-path"]
44444
+ workflow: flag("tests-workflow"),
44445
+ job: flag("tests-job"),
44446
+ artifact: flag("tests-artifact"),
44447
+ path: flag("tests-artifact-path")
43387
44448
  }
43388
44449
  };
44450
+ } else if (given.length > 0) {
44451
+ throw new UsageError(`--${given[0]} goes with --tests-ci, --tests-local or --tests-junit`);
43389
44452
  }
43390
- const t = templateBlock({ cwd: io.cwd ?? process.cwd(), base: v.base, tests });
44453
+ const t = templateBlock({ cwd, base: flag("base"), tests });
43391
44454
  io.out(renderBlock(t.block));
43392
44455
  for (const note of t.notes) io.err(`dunstan: ${note}
43393
44456
  `);
43394
- return 0;
44457
+ return tests?.kind === "ci" && tests.read.status === "unsettled" ? EXIT.unverifiable : 0;
43395
44458
  }
43396
44459
 
43397
44460
  // src/cli/verify.ts
43398
- import { readFileSync as readFileSync6 } from "node:fs";
43399
- import { parseArgs as parseArgs5 } from "node:util";
44461
+ import { readFileSync as readFileSync7 } from "node:fs";
44462
+ import { parseArgs as parseArgs7 } from "node:util";
43400
44463
 
43401
44464
  // src/record/verify.ts
43402
44465
  function same(a, b) {
@@ -43451,10 +44514,10 @@ function verifyRecord(value) {
43451
44514
  const claims = claimsFor(p.block, evidence, p.subject.repository);
43452
44515
  if (!same(p.claims, claims)) {
43453
44516
  const index = claims.findIndex((c, i) => !same(c, p.claims[i]));
43454
- const at = index === -1 ? claims.length : index;
44517
+ const at2 = index === -1 ? claims.length : index;
43455
44518
  problems.push({
43456
- member: `/predicate/claims/${at}`,
43457
- message: `recorded ${canonicalize(p.claims[at] ?? null)}, recomputed ${canonicalize(claims[at] ?? null)}`
44519
+ member: `/predicate/claims/${at2}`,
44520
+ message: `recorded ${canonicalize(p.claims[at2] ?? null)}, recomputed ${canonicalize(claims[at2] ?? null)}`
43458
44521
  });
43459
44522
  }
43460
44523
  differ("/predicate/verdict", p.verdict, overallVerdict(p.block.status, claims));
@@ -43511,9 +44574,225 @@ function verifyRecord(value) {
43511
44574
  return { ok: problems.length === 0, problems, record: record2 };
43512
44575
  }
43513
44576
 
44577
+ // src/survey/verify.ts
44578
+ var text = (value) => canonicalize(value ?? null);
44579
+ function differences(a, b, pointer) {
44580
+ const isObject2 = (v) => v !== null && typeof v === "object";
44581
+ if (Array.isArray(a) && Array.isArray(b)) {
44582
+ const out = [];
44583
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
44584
+ out.push(...differences(a[i], b[i], `${pointer}/${i}`));
44585
+ }
44586
+ return out;
44587
+ }
44588
+ if (isObject2(a) && isObject2(b) && !Array.isArray(a) && !Array.isArray(b)) {
44589
+ const ao = a;
44590
+ const bo = b;
44591
+ const keys = [.../* @__PURE__ */ new Set([...Object.keys(ao), ...Object.keys(bo)])].sort();
44592
+ return keys.flatMap((k) => differences(ao[k], bo[k], `${pointer}/${escapePointerToken(k)}`));
44593
+ }
44594
+ return text(a) === text(b) ? [] : [pointer];
44595
+ }
44596
+ function at(value, pointer, base) {
44597
+ let v = value;
44598
+ for (const token of pointer.slice(base.length).split("/").slice(1)) {
44599
+ const key = token.replace(/~1/g, "/").replace(/~0/g, "~");
44600
+ v = v !== null && typeof v === "object" ? v[key] : void 0;
44601
+ }
44602
+ return v;
44603
+ }
44604
+ function abandonedProblems(listing, repeated) {
44605
+ const problems = [];
44606
+ const base = "/selection/listing/abandoned";
44607
+ const { abandoned } = listing;
44608
+ abandoned.forEach((read, r) => {
44609
+ if (read.pages.length > listing.pageBound) {
44610
+ problems.push({
44611
+ member: `${base}/${r}/pages`,
44612
+ message: `${read.pages.length} pages read, past the bound of ${listing.pageBound}`
44613
+ });
44614
+ }
44615
+ read.pages.forEach((page, i) => {
44616
+ const at2 = `${base}/${r}/pages/${i}`;
44617
+ if (page.page !== i + 1) {
44618
+ problems.push({
44619
+ member: `${at2}/page`,
44620
+ message: `recorded ${page.page}; a read runs from page 1 without a gap, so this is page ${i + 1}`
44621
+ });
44622
+ }
44623
+ if (i < read.pages.length - 1) {
44624
+ if (page.duplicates !== 0) {
44625
+ problems.push({
44626
+ member: `${at2}/duplicates`,
44627
+ message: `recorded ${page.duplicates}; a read is abandoned at its first page that repeats an earlier item, so no page before its last repeats one`
44628
+ });
44629
+ }
44630
+ if (page.error !== void 0) {
44631
+ problems.push({
44632
+ member: `${at2}/error`,
44633
+ message: `recorded ${text(page.error)}; a page that could not be read ends its read, so only a last page has an error`
44634
+ });
44635
+ }
44636
+ } else if (page.duplicates === 0 || page.duplicates > page.items) {
44637
+ problems.push({
44638
+ member: `${at2}/duplicates`,
44639
+ message: page.duplicates === 0 ? "recorded 0; an abandoned read ends at a page that repeats an earlier item" : `recorded ${page.duplicates}, more than the page's ${page.items} items`
44640
+ });
44641
+ }
44642
+ });
44643
+ });
44644
+ if (repeated > 0 && abandoned.length !== LISTING_READS - 1) {
44645
+ problems.push({
44646
+ member: base,
44647
+ message: `${abandoned.length} reads abandoned, but the read used repeated ${repeated} items: a read that repeats is abandoned unless it is the last of ${LISTING_READS} reads, so ${LISTING_READS - 1} reads were abandoned before it`
44648
+ });
44649
+ }
44650
+ const reads = [...abandoned.map((read) => read.pages), listing.pages];
44651
+ reads.slice(1).forEach((later2, r) => {
44652
+ const first = later2[0];
44653
+ if (first === void 0) return;
44654
+ const next = Date.parse(first.readAt);
44655
+ (reads[r] ?? []).forEach((page, i) => {
44656
+ if (Date.parse(page.readAt) > next) {
44657
+ problems.push({
44658
+ member: `${base}/${r}/pages/${i}/readAt`,
44659
+ message: `recorded ${text(page.readAt)}, after the next read's first page (${text(first.readAt)})`
44660
+ });
44661
+ }
44662
+ });
44663
+ });
44664
+ return problems;
44665
+ }
44666
+ function verifySurveyRecord(value) {
44667
+ const schemaErrors = validateSurveyRecord(value);
44668
+ if (schemaErrors.length > 0) {
44669
+ return {
44670
+ ok: false,
44671
+ problems: schemaErrors.map((e) => ({
44672
+ member: e.pointer,
44673
+ message: `schema_violation (${e.keyword}): ${e.message}`
44674
+ }))
44675
+ };
44676
+ }
44677
+ const record2 = value;
44678
+ const problems = [];
44679
+ const differ = (member, recorded, recomputed) => {
44680
+ for (const pointer of differences(recorded, recomputed, member)) {
44681
+ problems.push({
44682
+ member: pointer,
44683
+ message: `recorded ${text(at(recorded, pointer, member))}, recomputed ${text(at(recomputed, pointer, member))}`
44684
+ });
44685
+ }
44686
+ };
44687
+ const { checker, selection, entries } = record2;
44688
+ if (checker.name !== CHECKER_NAME || checker.version !== CHECKER_VERSION) {
44689
+ problems.push({
44690
+ member: "/checker",
44691
+ message: `survey was written by ${checker.name} ${checker.version}; this is ${CHECKER_NAME} ${CHECKER_VERSION}`
44692
+ });
44693
+ return { ok: false, problems, record: record2 };
44694
+ }
44695
+ const { parameters, listing } = selection;
44696
+ const firstAt = /* @__PURE__ */ new Map();
44697
+ listing.pullRequests.forEach((pr, i) => {
44698
+ const first = firstAt.get(pr.number);
44699
+ if (first === void 0) firstAt.set(pr.number, i);
44700
+ else {
44701
+ problems.push({
44702
+ member: `/selection/listing/pullRequests/${i}`,
44703
+ message: `pull request ${pr.number} is listed again (first at /selection/listing/pullRequests/${first}); the listing holds each number once`
44704
+ });
44705
+ }
44706
+ });
44707
+ const held = listing.pages.reduce((sum, page) => sum + page.items, 0);
44708
+ const repeated = listing.pages.reduce((sum, page) => sum + page.duplicates, 0);
44709
+ listing.pages.forEach((page, i) => {
44710
+ if (page.duplicates > page.items) {
44711
+ problems.push({
44712
+ member: `/selection/listing/pages/${i}/duplicates`,
44713
+ message: `recorded ${page.duplicates}, more than the page's ${page.items} items`
44714
+ });
44715
+ }
44716
+ });
44717
+ differ("/selection/listing/duplicates", listing.duplicates, repeated);
44718
+ differ(
44719
+ "/selection/listing/pullRequests/length",
44720
+ listing.pullRequests.length,
44721
+ held - listing.duplicates
44722
+ );
44723
+ let read = 0;
44724
+ listing.pages.forEach((page, i) => {
44725
+ read += page.items - page.duplicates;
44726
+ if (i === listing.pages.length - 1) return;
44727
+ const stop2 = listingStop(
44728
+ parameters,
44729
+ listing.pages.slice(0, i + 1),
44730
+ listing.pullRequests.slice(0, read)
44731
+ );
44732
+ if (stop2 !== null) {
44733
+ problems.push({
44734
+ member: `/selection/listing/pages/${i + 1}`,
44735
+ message: `the listing ended at page ${page.page} (${stop2}); no later page is read`
44736
+ });
44737
+ }
44738
+ });
44739
+ const stop = listingStop(parameters, listing.pages, listing.pullRequests);
44740
+ const end = stop ?? (listing.pages.length === listing.pageBound ? "page_bound" : null);
44741
+ if (end === null) {
44742
+ problems.push({
44743
+ member: "/selection/listing/end",
44744
+ message: `recorded ${text(listing.end)}, recomputed none: the listing was not read to an end`
44745
+ });
44746
+ } else {
44747
+ differ("/selection/listing/end", listing.end, end);
44748
+ }
44749
+ if (listing.pages.length > listing.pageBound) {
44750
+ problems.push({
44751
+ member: "/selection/listing/pages",
44752
+ message: `${listing.pages.length} pages read, past the bound of ${listing.pageBound}`
44753
+ });
44754
+ }
44755
+ problems.push(...abandonedProblems(listing, repeated));
44756
+ const selected = selectPullRequests(parameters, listing.pullRequests);
44757
+ differ("/selection/selected", selection.selected, selected);
44758
+ differ(
44759
+ "/entries/pullRequests",
44760
+ entries.map((e) => e.pullRequest),
44761
+ selected
44762
+ );
44763
+ entries.forEach((entry, i) => {
44764
+ if (entry.record === null) return;
44765
+ const base = `/entries/${i}/record`;
44766
+ const result = verifyRecord(entry.record);
44767
+ for (const p of result.problems)
44768
+ problems.push({ member: `${base}${p.member}`, message: p.message });
44769
+ const subject = entry.record.predicate.subject;
44770
+ differ(`${base}/predicate/subject/pullRequest`, subject.pullRequest, entry.pullRequest);
44771
+ differ(
44772
+ `${base}/predicate/subject/repository`,
44773
+ subject.repository.toLowerCase(),
44774
+ parameters.repository.toLowerCase()
44775
+ );
44776
+ differ(`${base}/predicate/checker`, entry.record.predicate.checker, checker);
44777
+ });
44778
+ try {
44779
+ differ(
44780
+ "/aggregate",
44781
+ record2.aggregate,
44782
+ aggregate(entries, listing.pullRequests.length, parameters.advisory, selection.reader)
44783
+ );
44784
+ } catch (e) {
44785
+ problems.push({ member: "/aggregate", message: `not recomputed: ${e.message}` });
44786
+ }
44787
+ const out = record2.rerun.offline.startsWith("dunstan verify ") ? record2.rerun.offline.slice("dunstan verify ".length) : "";
44788
+ differ("/rerun", record2.rerun, surveyRerun(parameters, out));
44789
+ differ("/digest/sha256", record2.digest.sha256, surveyDigest(record2));
44790
+ return { ok: problems.length === 0, problems, record: record2 };
44791
+ }
44792
+
43514
44793
  // src/cli/verify.ts
43515
44794
  async function verify(args, io) {
43516
- const { values, positionals } = parseArgs5({
44795
+ const { values, positionals } = parseArgs7({
43517
44796
  args,
43518
44797
  strict: true,
43519
44798
  allowPositionals: true,
@@ -43526,7 +44805,7 @@ async function verify(args, io) {
43526
44805
  throw new UsageError("--sig and --allowed-signers go together");
43527
44806
  }
43528
44807
  const path = positionals[0];
43529
- const bytes = readFileSync6(path);
44808
+ const bytes = readFileSync7(path);
43530
44809
  let value;
43531
44810
  try {
43532
44811
  value = parseStrictJson(new TextDecoder("utf-8").decode(bytes));
@@ -43536,6 +44815,25 @@ async function verify(args, io) {
43536
44815
  `);
43537
44816
  return 1;
43538
44817
  }
44818
+ if (isSurveyRecord(value)) {
44819
+ if (sig !== void 0) throw new UsageError("a survey record is not signed");
44820
+ const survey3 = verifySurveyRecord(value);
44821
+ if (!survey3.ok) {
44822
+ io.out(`NOT verified: ${path}
44823
+ `);
44824
+ for (const problem of survey3.problems) {
44825
+ io.out(` ${problem.member || "/"}: ${problem.message}
44826
+ `);
44827
+ }
44828
+ return 1;
44829
+ }
44830
+ const s = survey3.record;
44831
+ io.out(
44832
+ `verified: ${path}: survey of ${s?.entries.length} pull requests; every embedded record, the selection, the aggregate and the digest recompute
44833
+ `
44834
+ );
44835
+ return 0;
44836
+ }
43539
44837
  const result = verifyRecord(value);
43540
44838
  const problems = [...result.problems];
43541
44839
  const assurance = result.record?.predicate.assurance;
@@ -43589,9 +44887,16 @@ usage:
43589
44887
  dunstan verify <record.json> [--sig <file> --allowed-signers <file>]
43590
44888
  dunstan rerun <record.json> [--out <record.json>] [--exclude-check-run <id>]...
43591
44889
  dunstan template [--base <ref>]
43592
- [--tests-junit <file> --tests-command <cmd> --tests-workflow <path>
44890
+ [--tests-ci --tests-command <cmd> --tests-workflow <path> --tests-job <name>
44891
+ [--tests-artifact <name>] [--tests-artifact-path <path>] [--repo <owner/name>]
44892
+ | --tests-local <file> --tests-command <cmd>
44893
+ | --tests-junit <file> --tests-command <cmd> --tests-workflow <path>
43593
44894
  --tests-job <name> --tests-artifact <name> --tests-artifact-path <path>]
43594
44895
  dunstan suggest [--report-file <path|->]
44896
+ dunstan survey --repo <owner/name> --out <survey.json> [--summary <survey.md>]
44897
+ [--last <n>] [--since <YYYY-MM-DD>] [--author <login>]...
44898
+ [--report-comment-author <login>] [--advisory]
44899
+ dunstan junit-merge <file>... --out <file>
43595
44900
  dunstan mcp
43596
44901
 
43597
44902
  check reads the pull request, the named report and the evidence its block needs, writes the
@@ -43601,15 +44906,30 @@ check reads the pull request, the named report and the evidence its block ne
43601
44906
  verify offline: recomputes claims, verdict and digests from the record and compares them.
43602
44907
  rerun online: re-reads the sources for the recorded subject and block; writes a new record.
43603
44908
  template local git: prints a dunstan-handback block for HEAD, its files against the merge base
43604
- with --base (default: the remote's default branch). Tests only from a JUnit file and
43605
- the CI record named with it; references, checks and mergedAt never (docs/agents.md).
44909
+ with --base (default: the remote's default branch). Tests: --tests-ci reads the counts
44910
+ at HEAD from the CI record check reads (artifact dunstan-junit, file junit.xml unless
44911
+ given; the repository origin names on github.com unless --repo), with GITHUB_TOKEN;
44912
+ --tests-local declares a local JUnit file's counts as a local run; --tests-junit
44913
+ declares them against a CI record. Only --tests-ci reads the network. References,
44914
+ checks and mergedAt never (docs/agents.md, docs/ci-test-records.md).
43606
44915
  suggest offline: lists sentences in the report's prose (a file, or stdin) that look like claims
43607
44916
  its block does not declare, to declare or reword. Never a verdict (docs/agents.md).
44917
+ survey read-only, DRAFT (docs/survey.md): checks the --last (default 30, at most 200) most
44918
+ recently created closed pull requests as check does, the report in each body or the
44919
+ named author's latest comment; writes the survey record and a Markdown summary of the
44920
+ claims the record could not settle, why, and the contradicted share of the claims it
44921
+ settled. verify recomputes a survey record. GITHUB_TOKEN is optional; the record says
44922
+ whether it was set.
44923
+ junit-merge
44924
+ offline: one JUnit file from several (Maven Surefire, Gradle), inputs in code-unit
44925
+ order of path, each copied whole; refused unless its counts are the inputs' sum.
43608
44926
  mcp serves dunstan_check_handback and dunstan_suggest_declarations over stdio
43609
44927
  (docs/mcp.md): check's checker and suggest, called by an agent before it says done.
43610
44928
 
43611
- exit codes: 0 pass (verify: verified; template: printed; suggest: always), 1 fail (verify: not
43612
- verified), 2 unverifiable, 3 usage or internal error, or git cannot answer (template)
44929
+ exit codes: 0 pass (verify: verified; template: printed; suggest: always; survey: written;
44930
+ junit-merge: written), 1 fail (verify: not verified), 2 unverifiable (template:
44931
+ printed without the tests --tests-ci asked for), 3 usage or internal error, git
44932
+ cannot answer (template), or refused (junit-merge)
43613
44933
  `;
43614
44934
  async function main(argv, io) {
43615
44935
  const [command, ...args] = argv;
@@ -43625,6 +44945,10 @@ async function main(argv, io) {
43625
44945
  return await template(args, io);
43626
44946
  case "suggest":
43627
44947
  return await suggest(args, io);
44948
+ case "survey":
44949
+ return await survey2(args, io);
44950
+ case "junit-merge":
44951
+ return await junitMerge(args, io);
43628
44952
  case "mcp":
43629
44953
  return await mcp(args, io);
43630
44954
  case "--version":
@@ -43657,11 +44981,11 @@ ${USAGE}`);
43657
44981
 
43658
44982
  // src/cli/bin.ts
43659
44983
  process.exitCode = await main(process.argv.slice(2), {
43660
- out: (text) => process.stdout.write(text),
43661
- err: (text) => process.stderr.write(text),
44984
+ out: (text2) => process.stdout.write(text2),
44985
+ err: (text2) => process.stderr.write(text2),
43662
44986
  env: process.env,
43663
44987
  artifact: import.meta.url,
43664
- readStdin: () => readFileSync7(0),
44988
+ readStdin: () => readFileSync8(0),
43665
44989
  stdin: process.stdin,
43666
44990
  stdout: process.stdout
43667
44991
  });