dunstan 0.1.12 → 0.1.14

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 +1634 -383
  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.12";
9884
+ var CHECKER_VERSION = "0.1.14";
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",
@@ -10515,15 +10563,15 @@ var FILES = { "handback-block-0.1.schema.json": `{
10515
10563
  },
10516
10564
  "readerReason": {
10517
10565
  "type": "string",
10518
- "pattern": "^(?:no_matching_record_item|declared_item_not_among_candidates|candidate_not_in_evidence|ambiguous_candidates|workflow_not_in_record|workflow_name_ambiguous|no_comparable_record_field|file_list_truncated|checks_incomplete|all_succeeded_mismatch|test_not_executed|test_outcome_mismatch|not_closing|not_found|closing_link_unsettled|closing_base_not_default|closing_event_unsettled|evidence_field_unpopulated:[A-Za-z0-9_.]+|source_unreadable:(?:pull_request_files|check_runs|workflow_runs|pull_request_commits|closing_references|repository|issue|timeline|artifact))$"
10566
+ "pattern": "^(?:no_matching_record_item|declared_item_not_among_candidates|candidate_not_in_evidence|duplicate_record_item|ambiguous_candidates|workflow_not_in_record|workflow_name_ambiguous|no_comparable_record_field|file_list_truncated|checks_incomplete|all_succeeded_mismatch|test_not_executed|test_outcome_mismatch|not_closing|not_found|closing_link_unsettled|closing_base_not_default|closing_event_unsettled|evidence_field_unpopulated:[A-Za-z0-9_.]+|source_unreadable:(?:pull_request_files|check_runs|workflow_runs|pull_request_commits|closing_references|repository|issue|timeline|artifact))$"
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 "listing": {\n "type": "object",\n "required": ["pageBound", "pages", "pullRequests", "duplicates", "end"],\n "additionalProperties": false,\n "properties": {\n "pageBound": { "type": "integer", "minimum": 1 },\n "pages": {\n "type": "array",\n "minItems": 1,\n "items": {\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 listing already held. 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 },\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
  }
@@ -12626,11 +12735,14 @@ function identifierMatches(claim2, items, repository) {
12626
12735
  const shas = shaTokens(texts);
12627
12736
  const issues = issueRefs(texts, repository);
12628
12737
  const named = (value, inside) => texts.some((t) => t === value || occursBounded(t, value, inside));
12629
- const qualified = (item) => item.type === "test" && item.id !== item.fields[0]?.[1] && named(item.id, WORD);
12738
+ const classnameOf = (item) => item.fields.find(([f]) => f === "classname")?.[1];
12739
+ const qualified = (item) => {
12740
+ const classname = classnameOf(item);
12741
+ return item.type === "test" && classname !== void 0 && named(`${classname}.${item.fields[0]?.[1]}`, WORD);
12742
+ };
12630
12743
  const qualifiedNames = new Set(
12631
12744
  items.filter(qualified).map((item) => item.fields[0]?.[1])
12632
12745
  );
12633
- const classnameOf = (item) => item.fields.find(([f]) => f === "classname")?.[1];
12634
12746
  const classnames = [
12635
12747
  ...new Set(items.filter((item) => item.type === "test").map(classnameOf))
12636
12748
  ].filter((c) => c !== void 0);
@@ -12691,7 +12803,7 @@ function identifierMatches(claim2, items, repository) {
12691
12803
 
12692
12804
  // src/retrieval/items.ts
12693
12805
  function testId(test) {
12694
- return test.classname === void 0 ? test.name : `${test.classname}.${test.name}`;
12806
+ return JSON.stringify([test.classname ?? null, test.name]);
12695
12807
  }
12696
12808
  function recordItems(evidence) {
12697
12809
  const items = [];
@@ -12786,8 +12898,8 @@ var STOPWORDS = /* @__PURE__ */ new Set([
12786
12898
  function stem(token) {
12787
12899
  return token.length > 3 && token.endsWith("s") && !token.endsWith("ss") ? token.slice(0, -1) : token;
12788
12900
  }
12789
- function tokenize(text) {
12790
- 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);
12791
12903
  }
12792
12904
  var Bm25Index = class {
12793
12905
  termFrequencies;
@@ -12842,9 +12954,9 @@ function roundScore(score) {
12842
12954
  function bestField(item, terms, index) {
12843
12955
  let best = item.fields[0]?.[0] ?? "id";
12844
12956
  let bestWeight = -1;
12845
- for (const [name, text] of item.fields) {
12957
+ for (const [name, text2] of item.fields) {
12846
12958
  let weight = 0;
12847
- 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);
12848
12960
  if (weight > bestWeight) {
12849
12961
  best = name;
12850
12962
  bestWeight = weight;
@@ -12853,7 +12965,7 @@ function bestField(item, terms, index) {
12853
12965
  return best;
12854
12966
  }
12855
12967
  function itemTokens(item) {
12856
- return item.fields.flatMap(([, text]) => tokenize(text));
12968
+ return item.fields.flatMap(([, text2]) => tokenize(text2));
12857
12969
  }
12858
12970
  function compareRanked(a, b) {
12859
12971
  const sa = a.candidate.score;
@@ -12939,7 +13051,11 @@ function sectionReason(type, evidence) {
12939
13051
  }
12940
13052
  function lookup(type, evidence) {
12941
13053
  const entries = /* @__PURE__ */ new Map();
12942
- const put = (id, value) => entries.set(id, value);
13054
+ const duplicates = /* @__PURE__ */ new Set();
13055
+ const put = (id, value) => {
13056
+ if (entries.has(id)) duplicates.add(id);
13057
+ else entries.set(id, value);
13058
+ };
12943
13059
  const items = evidence.items;
12944
13060
  if (type === "file" && evidence.files?.status === "ok") {
12945
13061
  for (const f of evidence.files.entries) put(f.path, f);
@@ -12953,7 +13069,7 @@ function lookup(type, evidence) {
12953
13069
  } else if (type === "test" && items?.tests.status === "ok") {
12954
13070
  for (const t of items.tests.entries) put(testId(t), t);
12955
13071
  }
12956
- return entries;
13072
+ return { entries, duplicates };
12957
13073
  }
12958
13074
  var ISSUE_REF2 = /^(?:[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100})?#[1-9][0-9]{0,9}$/;
12959
13075
  function combinedCandidates(typed, floor) {
@@ -12973,17 +13089,17 @@ function ambiguousCandidates(combined, outcome) {
12973
13089
  const tied = combined.filter((_, i) => scores[i] === top);
12974
13090
  return tied.length >= 2 && new Set(tied.map(outcome)).size > 1;
12975
13091
  }
12976
- function namedWorkflowRuns(text, combined, evidence) {
13092
+ function namedWorkflowRuns(text2, combined, evidence) {
12977
13093
  const section = evidence.checkRuns;
12978
13094
  if (section?.status !== "ok") return [...combined];
12979
13095
  const jobNames = [...new Set(section.runs.map((r) => r.name))];
12980
- 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" };
12981
13097
  const recordedWorkflows = [
12982
13098
  ...new Set(
12983
13099
  section.runs.flatMap((r) => [r.workflowPath, r.workflowName]).filter((w) => w !== void 0)
12984
13100
  )
12985
13101
  ];
12986
- const named = workflowJobs(text, jobNames, recordedWorkflows);
13102
+ const named = workflowJobs(text2, jobNames, recordedWorkflows);
12987
13103
  if (named.length === 0) return [...combined];
12988
13104
  const counted = new Map(
12989
13105
  countedCheckRuns(section.runs, section.excludedIds).map((r) => [String(r.id), r])
@@ -13031,13 +13147,13 @@ function checkReaderClaim(claim2, evidence, repository) {
13031
13147
  if (claim2.kind === "reference_closes") {
13032
13148
  if (!ISSUE_REF2.test(declared)) return unverifiable2("no_comparable_record_field");
13033
13149
  const head = evidence.pullRequest.headSha;
13034
- const row = checkReferences(closesBlock(declared, head), evidence, repository)[0];
13035
- if (row === void 0) return unverifiable2("no_comparable_record_field");
13036
- 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);
13037
13153
  }
13038
13154
  const missing = sectionReason(type, evidence);
13039
13155
  if (missing !== void 0) return unverifiable2(missing);
13040
- const entries = lookup(type, evidence);
13156
+ const { entries, duplicates } = lookup(type, evidence);
13041
13157
  const typed = claim2.candidates.filter((c) => c.type === type);
13042
13158
  if (typed.some((c) => entries.get(c.id) === void 0)) {
13043
13159
  return unverifiable2("candidate_not_in_evidence");
@@ -13047,6 +13163,7 @@ function checkReaderClaim(claim2, evidence, repository) {
13047
13163
  if (relevant.length === 0) {
13048
13164
  return unverifiable2(truncated ? "file_list_truncated" : "no_matching_record_item");
13049
13165
  }
13166
+ if (relevant.some((c) => duplicates.has(c.id))) return unverifiable2("duplicate_record_item");
13050
13167
  if (type === "check_run") {
13051
13168
  const scoped = namedWorkflowRuns(claim2.text, relevant, evidence);
13052
13169
  if (!Array.isArray(scoped)) return unverifiable2(scoped.reason);
@@ -13261,10 +13378,10 @@ async function checkAt(input2, pr) {
13261
13378
  const repository = pr.repository;
13262
13379
  const ownRuns = await input2.afterPullRequest?.(pr) ?? [];
13263
13380
  const report = await input2.report(pr);
13264
- const text = new TextDecoder("utf-8").decode(report.bytes);
13265
- 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));
13266
13383
  const found = block.status === "found" ? block.value : null;
13267
- const proposed = input2.advisory === true ? extractClaims(text) : void 0;
13384
+ const proposed = input2.advisory === true ? extractClaims(text2) : void 0;
13268
13385
  const evidence = await input2.readers.evidence({
13269
13386
  repository,
13270
13387
  pullRequest: pr,
@@ -13360,6 +13477,102 @@ function noteText(note) {
13360
13477
  return `${POSSIBLE_DISAGREEMENT}: ${shows}`;
13361
13478
  }
13362
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
+
13363
13576
  // src/cli/output.ts
13364
13577
  var EXIT = { pass: 0, fail: 1, unverifiable: 2, error: 3 };
13365
13578
  function exitFor(verdict) {
@@ -13374,8 +13587,8 @@ var UsageError = class extends Error {
13374
13587
  var WIDTH = 48;
13375
13588
  function cell(value) {
13376
13589
  if (value === void 0) return "";
13377
- const text = typeof value === "string" ? value : JSON.stringify(value);
13378
- 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;
13379
13592
  }
13380
13593
  function table(rows) {
13381
13594
  const widths = rows[0]?.map((_, i) => Math.max(...rows.map((r) => (r[i] ?? "").length))) ?? [];
@@ -13383,7 +13596,7 @@ function table(rows) {
13383
13596
  (r) => r.map((c, i) => i === r.length - 1 ? c : c.padEnd(widths[i] ?? 0)).join(" ").trimEnd()
13384
13597
  ).join("\n");
13385
13598
  }
13386
- function renderRecord(record2) {
13599
+ function renderRecord(record2, context) {
13387
13600
  const p = record2.predicate;
13388
13601
  const lines = [
13389
13602
  `${p.checker.name} ${p.checker.version} ${p.subject.repository}#${p.subject.pullRequest} head ${p.subject.headSha}`,
@@ -13398,19 +13611,16 @@ function renderRecord(record2) {
13398
13611
  lines.push(`block: invalid (${b.reason}: ${[...new Set(errors)].join("; ")})`);
13399
13612
  } else lines.push(`block: missing (${b.reason})`);
13400
13613
  if (p.claims.length > 0) {
13401
- lines.push(
13402
- "",
13403
- table([
13404
- ["VERDICT", "CLAIM", "DECLARED", "OBSERVED", "REASON"],
13405
- ...p.claims.map((c) => [
13406
- c.verdict,
13407
- c.id,
13408
- cell(c.declared),
13409
- cell(c.observed),
13410
- c.reason ?? ""
13411
- ])
13412
- ])
13413
- );
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
+ });
13414
13624
  }
13415
13625
  const unread2 = p.evidence.sources.filter((s) => s.error !== void 0);
13416
13626
  if (unread2.length > 0) {
@@ -13529,7 +13739,7 @@ async function check(args, io) {
13529
13739
  advisory: values.advisory === true
13530
13740
  });
13531
13741
  writeFileSync(out, serializeRecord(record2));
13532
- io.out(renderRecord(record2));
13742
+ io.out(renderRecord(record2, { hasToken: client.hasToken }));
13533
13743
  io.out(`record: ${out}
13534
13744
  `);
13535
13745
  if (assurance !== void 0) {
@@ -13539,6 +13749,95 @@ async function check(args, io) {
13539
13749
  return exitFor(record2.predicate.verdict);
13540
13750
  }
13541
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
+
13542
13841
  // node_modules/.pnpm/@modelcontextprotocol+sdk@1.32.0_zod@4.6.5/node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js
13543
13842
  import process2 from "node:process";
13544
13843
 
@@ -14376,14 +14675,14 @@ function promiseAllObject(promisesObj) {
14376
14675
  }
14377
14676
  function randomString(length = 10) {
14378
14677
  const chars = "abcdefghijklmnopqrstuvwxyz";
14379
- let str2 = "";
14678
+ let str3 = "";
14380
14679
  for (let i = 0; i < length; i++) {
14381
- str2 += chars[Math.floor(Math.random() * chars.length)];
14680
+ str3 += chars[Math.floor(Math.random() * chars.length)];
14382
14681
  }
14383
- return str2;
14682
+ return str3;
14384
14683
  }
14385
- function esc(str2) {
14386
- return JSON.stringify(str2);
14684
+ function esc(str3) {
14685
+ return JSON.stringify(str3);
14387
14686
  }
14388
14687
  function slugify(input2) {
14389
14688
  return input2.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
@@ -14497,8 +14796,8 @@ var primitiveTypes = /* @__PURE__ */ new Set([
14497
14796
  "symbol",
14498
14797
  "undefined"
14499
14798
  ]);
14500
- function escapeRegex(str2) {
14501
- return str2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14799
+ function escapeRegex(str3) {
14800
+ return str3.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14502
14801
  }
14503
14802
  function clone(inst, def, params) {
14504
14803
  const cl = new inst._zod.constr(def ?? inst._zod.def);
@@ -14752,13 +15051,13 @@ function getSizableOrigin(input2) {
14752
15051
  return "unknown";
14753
15052
  }
14754
15053
  var highSurrogate = /[\uD800-\uDBFF]/;
14755
- function codePointLength(str2) {
14756
- const units = str2.length;
14757
- if (!highSurrogate.test(str2))
15054
+ function codePointLength(str3) {
15055
+ const units = str3.length;
15056
+ if (!highSurrogate.test(str3))
14758
15057
  return units;
14759
15058
  let count = units;
14760
15059
  for (let i = 0; i < units - 1; i++) {
14761
- 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) {
14762
15061
  count--;
14763
15062
  i++;
14764
15063
  }
@@ -14851,9 +15150,9 @@ var Class = class {
14851
15150
  constructor(..._args) {
14852
15151
  }
14853
15152
  };
14854
- function members(proto, table2) {
14855
- for (const key in table2) {
14856
- const desc = Object.getOwnPropertyDescriptor(table2, key);
15153
+ function members(proto, table3) {
15154
+ for (const key in table3) {
15155
+ const desc = Object.getOwnPropertyDescriptor(table3, key);
14857
15156
  if (desc.get)
14858
15157
  Object.defineProperty(proto, key, { ...desc, enumerable: false });
14859
15158
  else
@@ -14868,10 +15167,10 @@ function hide(inst, key, value) {
14868
15167
  return own(inst, key, value, false);
14869
15168
  }
14870
15169
  // @__NO_SIDE_EFFECTS__
14871
- function derived(computes, table2) {
15170
+ function derived(computes, table3) {
14872
15171
  for (const key in computes) {
14873
15172
  const compute = computes[key];
14874
- Object.defineProperty(table2, key, {
15173
+ Object.defineProperty(table3, key, {
14875
15174
  configurable: true,
14876
15175
  enumerable: true,
14877
15176
  get() {
@@ -14882,7 +15181,7 @@ function derived(computes, table2) {
14882
15181
  }
14883
15182
  });
14884
15183
  }
14885
- return table2;
15184
+ return table3;
14886
15185
  }
14887
15186
  function defineBound(proto, key, fn) {
14888
15187
  Object.defineProperty(proto, key, {
@@ -16114,8 +16413,8 @@ var Doc = class {
16114
16413
  const lines = content.split("\n").filter((x) => x);
16115
16414
  const minIndent = Math.min(...lines.map((x) => x.length - x.trimStart().length));
16116
16415
  const dedented = lines.map((x) => x.slice(minIndent)).map((x) => " ".repeat(this.indent * 2) + x);
16117
- for (const line of dedented) {
16118
- this.content.push(line);
16416
+ for (const line2 of dedented) {
16417
+ this.content.push(line2);
16119
16418
  }
16120
16419
  }
16121
16420
  compile() {
@@ -18568,7 +18867,7 @@ var recursive = /* @__PURE__ */ new WeakMap();
18568
18867
  var NONE = 0;
18569
18868
  var ASSUMED = 1;
18570
18869
  var PROVEN = 2;
18571
- function isRecursive(inst, stack, resolve4) {
18870
+ function isRecursive(inst, stack, resolve5) {
18572
18871
  const cached2 = recursive.get(inst);
18573
18872
  if (cached2 !== void 0)
18574
18873
  return cached2 ? PROVEN : NONE;
@@ -18578,7 +18877,7 @@ function isRecursive(inst, stack, resolve4) {
18578
18877
  let result = NONE;
18579
18878
  const check3 = (child) => {
18580
18879
  if (result !== PROVEN && child?._zod) {
18581
- const answer = isRecursive(child, stack, resolve4);
18880
+ const answer = isRecursive(child, stack, resolve5);
18582
18881
  if (answer > result)
18583
18882
  result = answer;
18584
18883
  }
@@ -18589,7 +18888,7 @@ function isRecursive(inst, stack, resolve4) {
18589
18888
  const desc = Object.getOwnPropertyDescriptor(sh, key);
18590
18889
  if (spread && !desc.enumerable)
18591
18890
  continue;
18592
- 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;
18593
18892
  if (child > answer)
18594
18893
  answer = child;
18595
18894
  }
@@ -18653,7 +18952,7 @@ function isRecursive(inst, stack, resolve4) {
18653
18952
  break;
18654
18953
  // `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly
18655
18954
  case "lazy": {
18656
- const inner = def._cachedInner ?? (resolve4 ? inst._zod.innerType : void 0);
18955
+ const inner = def._cachedInner ?? (resolve5 ? inst._zod.innerType : void 0);
18657
18956
  merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
18658
18957
  break;
18659
18958
  }
@@ -22851,8 +23150,8 @@ function ko_default() {
22851
23150
  }
22852
23151
 
22853
23152
  // node_modules/.pnpm/zod@4.6.5/node_modules/zod/v4/locales/lt.js
22854
- var capitalizeFirstCharacter = (text) => {
22855
- return text.charAt(0).toUpperCase() + text.slice(1);
23153
+ var capitalizeFirstCharacter = (text2) => {
23154
+ return text2.charAt(0).toUpperCase() + text2.slice(1);
22856
23155
  };
22857
23156
  function getUnitTypeFromNumber(number4) {
22858
23157
  const abs = Math.abs(number4);
@@ -24212,8 +24511,8 @@ var error43 = () => {
24212
24511
  case "not_multiple_of":
24213
24512
  return `N\xFAmero inv\xE1lido: deve ser m\xFAltiplo de ${issue2.divisor}`;
24214
24513
  case "unrecognized_keys": {
24215
- const plural2 = issue2.keys.length > 1 ? "s" : "";
24216
- 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, ", ")}`;
24217
24516
  }
24218
24517
  case "invalid_key":
24219
24518
  return `Entrada inv\xE1lida n${translateOriginWithArticle(issue2.origin, "definite")}`;
@@ -24357,8 +24656,8 @@ var error44 = () => {
24357
24656
  case "not_multiple_of":
24358
24657
  return `N\xFAmero inv\xE1lido: deve ser m\xFAltiplo de ${issue2.divisor}`;
24359
24658
  case "unrecognized_keys": {
24360
- const plural2 = issue2.keys.length > 1 ? "s" : "";
24361
- 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, ", ")}`;
24362
24661
  }
24363
24662
  case "invalid_key":
24364
24663
  return `Entrada inv\xE1lida n${translateOriginWithArticle(issue2.origin, "definite")}`;
@@ -34763,16 +35062,16 @@ var ReadBuffer = class {
34763
35062
  if (index === -1) {
34764
35063
  return null;
34765
35064
  }
34766
- const line = this._buffer.toString("utf8", 0, index).replace(/\r$/, "");
35065
+ const line2 = this._buffer.toString("utf8", 0, index).replace(/\r$/, "");
34767
35066
  this._buffer = this._buffer.subarray(index + 1);
34768
- return deserializeMessage(line);
35067
+ return deserializeMessage(line2);
34769
35068
  }
34770
35069
  clear() {
34771
35070
  this._buffer = void 0;
34772
35071
  }
34773
35072
  };
34774
- function deserializeMessage(line) {
34775
- return JSONRPCMessageSchema.parse(JSON.parse(line));
35073
+ function deserializeMessage(line2) {
35074
+ return JSONRPCMessageSchema.parse(JSON.parse(line2));
34776
35075
  }
34777
35076
  function serializeMessage(message) {
34778
35077
  return JSON.stringify(message) + "\n";
@@ -34834,20 +35133,20 @@ var StdioServerTransport = class {
34834
35133
  this.onclose?.();
34835
35134
  }
34836
35135
  send(message) {
34837
- return new Promise((resolve4) => {
35136
+ return new Promise((resolve5) => {
34838
35137
  const json2 = serializeMessage(message);
34839
35138
  if (this._stdout.write(json2)) {
34840
- resolve4();
35139
+ resolve5();
34841
35140
  } else {
34842
- this._stdout.once("drain", resolve4);
35141
+ this._stdout.once("drain", resolve5);
34843
35142
  }
34844
35143
  });
34845
35144
  }
34846
35145
  };
34847
35146
 
34848
35147
  // src/mcp/server.ts
34849
- import { existsSync, writeFileSync as writeFileSync2 } from "node:fs";
34850
- 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";
34851
35150
 
34852
35151
  // node_modules/.pnpm/zod@4.6.5/node_modules/zod/v3/helpers/util.js
34853
35152
  var util;
@@ -40823,7 +41122,7 @@ var Protocol = class {
40823
41122
  return;
40824
41123
  }
40825
41124
  const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
40826
- await new Promise((resolve4) => setTimeout(resolve4, pollInterval));
41125
+ await new Promise((resolve5) => setTimeout(resolve5, pollInterval));
40827
41126
  options?.signal?.throwIfAborted();
40828
41127
  }
40829
41128
  } catch (error62) {
@@ -40840,7 +41139,7 @@ var Protocol = class {
40840
41139
  */
40841
41140
  request(request, resultSchema, options) {
40842
41141
  const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
40843
- return new Promise((resolve4, reject) => {
41142
+ return new Promise((resolve5, reject) => {
40844
41143
  const earlyReject = (error62) => {
40845
41144
  reject(error62);
40846
41145
  };
@@ -40918,7 +41217,7 @@ var Protocol = class {
40918
41217
  if (!parseResult.success) {
40919
41218
  reject(parseResult.error);
40920
41219
  } else {
40921
- resolve4(parseResult.data);
41220
+ resolve5(parseResult.data);
40922
41221
  }
40923
41222
  } catch (error62) {
40924
41223
  reject(error62);
@@ -41172,20 +41471,20 @@ var Protocol = class {
41172
41471
  * @returns Promise that resolves when an update occurs or rejects if aborted
41173
41472
  */
41174
41473
  async _waitForTaskUpdate(taskId, signal, sessionId) {
41175
- let interval = this._options?.defaultTaskPollInterval ?? 1e3;
41474
+ let interval2 = this._options?.defaultTaskPollInterval ?? 1e3;
41176
41475
  try {
41177
41476
  const task = await this._taskStore?.getTask(taskId, sessionId);
41178
41477
  if (task?.pollInterval) {
41179
- interval = task.pollInterval;
41478
+ interval2 = task.pollInterval;
41180
41479
  }
41181
41480
  } catch {
41182
41481
  }
41183
- return new Promise((resolve4, reject) => {
41482
+ return new Promise((resolve5, reject) => {
41184
41483
  if (signal.aborted) {
41185
41484
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
41186
41485
  return;
41187
41486
  }
41188
- const timeoutId = setTimeout(resolve4, interval);
41487
+ const timeoutId = setTimeout(resolve5, interval2);
41189
41488
  signal.addEventListener("abort", () => {
41190
41489
  clearTimeout(timeoutId);
41191
41490
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
@@ -42316,7 +42615,7 @@ var McpServer = class {
42316
42615
  let task = createTaskResult.task;
42317
42616
  const pollInterval = task.pollInterval ?? 5e3;
42318
42617
  while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
42319
- await new Promise((resolve4) => setTimeout(resolve4, pollInterval));
42618
+ await new Promise((resolve5) => setTimeout(resolve5, pollInterval));
42320
42619
  const updatedTask = await extra.taskStore.getTask(taskId);
42321
42620
  if (!updatedTask) {
42322
42621
  throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
@@ -42936,13 +43235,13 @@ function sentenceAt(report, span) {
42936
43235
  const lines = report.split("\n");
42937
43236
  const starts = [];
42938
43237
  let offset = 0;
42939
- for (const line of lines) {
43238
+ for (const line2 of lines) {
42940
43239
  starts.push(offset);
42941
- offset += line.length + 1;
43240
+ offset += line2.length + 1;
42942
43241
  }
42943
- const lineOf = (at) => {
43242
+ const lineOf = (at2) => {
42944
43243
  let i = 0;
42945
- while (i + 1 < starts.length && starts[i + 1] <= at) i++;
43244
+ while (i + 1 < starts.length && starts[i + 1] <= at2) i++;
42946
43245
  return i;
42947
43246
  };
42948
43247
  const blank = (i) => (lines[i] ?? "").trim() === "";
@@ -42965,8 +43264,8 @@ function sentenceAt(report, span) {
42965
43264
  break;
42966
43265
  }
42967
43266
  }
42968
- const text = unit.slice(start, end).replace(/^\s*(?:[-*+]|\d{1,3}[.)]|#{1,6}|>)\s+/, "").replace(/\s+/g, " ").trim();
42969
- 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;
42970
43269
  }
42971
43270
  function suggestDeclarations(report) {
42972
43271
  const block = extractHandbackBlock(report);
@@ -43045,19 +43344,19 @@ var INSTRUCTIONS = `Before you say a task is done: generate your dunstan-handbac
43045
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\`.`;
43046
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.`;
43047
43346
  function reportOfBlock(block) {
43048
- const text = canonicalize(block);
43347
+ const text2 = canonicalize(block);
43049
43348
  return {
43050
43349
  report: {
43051
- bytes: new TextEncoder().encode(text),
43350
+ bytes: new TextEncoder().encode(text2),
43052
43351
  source: { kind: "api", locator: `mcp ${TOOL_NAME}#block` }
43053
43352
  },
43054
- block: recordBlock(readBlockContent(text))
43353
+ block: recordBlock(readBlockContent(text2))
43055
43354
  };
43056
43355
  }
43057
- function reportOfText(text) {
43356
+ function reportOfText(text2) {
43058
43357
  return {
43059
43358
  report: {
43060
- bytes: new TextEncoder().encode(text),
43359
+ bytes: new TextEncoder().encode(text2),
43061
43360
  source: { kind: "api", locator: `mcp ${TOOL_NAME}#report` }
43062
43361
  }
43063
43362
  };
@@ -43068,7 +43367,7 @@ async function checkHandback(input2, options) {
43068
43367
  "pass exactly one of block (the handback block as a JSON object) or report (text carrying one dunstan-handback fence)"
43069
43368
  );
43070
43369
  }
43071
- const outPath = input2.outPath === void 0 ? void 0 : resolve2(input2.outPath);
43370
+ const outPath = input2.outPath === void 0 ? void 0 : resolve3(input2.outPath);
43072
43371
  if (outPath !== void 0 && existsSync(outPath)) {
43073
43372
  throw new InputError(`${outPath} exists; the tool never overwrites a file`);
43074
43373
  }
@@ -43100,10 +43399,10 @@ async function checkHandback(input2, options) {
43100
43399
  recordFile: basename2(recordFile)
43101
43400
  });
43102
43401
  const serialized = serializeRecord(record2);
43103
- if (outPath !== void 0) writeFileSync2(outPath, serialized, { flag: "wx" });
43104
- return handbackResult(record2, outPath, notes);
43402
+ if (outPath !== void 0) writeFileSync3(outPath, serialized, { flag: "wx" });
43403
+ return handbackResult(record2, outPath, notes, { hasToken: client.hasToken });
43105
43404
  }
43106
- function handbackResult(record2, outPath, notes) {
43405
+ function handbackResult(record2, outPath, notes, context) {
43107
43406
  const verdict = record2.predicate.verdict;
43108
43407
  const head = [`verdict: ${verdict}`];
43109
43408
  if (verdict !== "pass") {
@@ -43117,7 +43416,7 @@ function handbackResult(record2, outPath, notes) {
43117
43416
  content: [
43118
43417
  { type: "text", text: `${head.join("\n")}
43119
43418
  ` },
43120
- { type: "text", text: renderRecord(record2) },
43419
+ { type: "text", text: renderRecord(record2, context) },
43121
43420
  { type: "text", text: serializeRecord(record2) }
43122
43421
  ]
43123
43422
  };
@@ -43203,8 +43502,8 @@ async function mcp(args, io) {
43203
43502
  warn: io.err
43204
43503
  });
43205
43504
  const stdin = io.stdin ?? process.stdin;
43206
- const closed = new Promise((resolve4) => {
43207
- server.server.onclose = resolve4;
43505
+ const closed = new Promise((resolve5) => {
43506
+ server.server.onclose = resolve5;
43208
43507
  });
43209
43508
  stdin.once("end", () => void server.close());
43210
43509
  await server.connect(new StdioServerTransport(stdin, io.stdout ?? process.stdout));
@@ -43213,9 +43512,9 @@ async function mcp(args, io) {
43213
43512
  }
43214
43513
 
43215
43514
  // src/cli/rerun.ts
43216
- import { existsSync as existsSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
43217
- import { basename as basename3, resolve as resolve3 } from "node:path";
43218
- 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";
43219
43518
  function diffPointers(a, b, pointer = "") {
43220
43519
  const isObject2 = (v) => v !== null && typeof v === "object";
43221
43520
  if (Array.isArray(a) && Array.isArray(b)) {
@@ -43237,7 +43536,7 @@ function defaultOut(path) {
43237
43536
  return path.endsWith(".json") ? `${path.slice(0, -5)}.rerun.json` : `${path}.rerun.json`;
43238
43537
  }
43239
43538
  async function rerun(args, io) {
43240
- const { values, positionals } = parseArgs2({
43539
+ const { values, positionals } = parseArgs3({
43241
43540
  args,
43242
43541
  strict: true,
43243
43542
  allowPositionals: true,
@@ -43246,9 +43545,9 @@ async function rerun(args, io) {
43246
43545
  if (positionals.length !== 1) throw new UsageError("dunstan rerun <record.json> [--out <path>]");
43247
43546
  const path = positionals[0];
43248
43547
  const out = values.out ?? defaultOut(path);
43249
- if (resolve3(out) === resolve3(path))
43548
+ if (resolve4(out) === resolve4(path))
43250
43549
  throw new UsageError("rerun never overwrites the record it re-runs");
43251
- const value = parseStrictJson(readFileSync4(path, "utf8"));
43550
+ const value = parseStrictJson(readFileSync5(path, "utf8"));
43252
43551
  const errors = validateRecord(value);
43253
43552
  if (errors.length > 0) {
43254
43553
  throw new UsageError(
@@ -43286,7 +43585,7 @@ async function rerun(args, io) {
43286
43585
  });
43287
43586
  if (existsSync2(out)) io.err(`dunstan: replacing ${out}
43288
43587
  `);
43289
- writeFileSync3(out, serializeRecord(record2));
43588
+ writeFileSync4(out, serializeRecord(record2));
43290
43589
  const p = record2.predicate;
43291
43590
  if (p.digests.evidence !== old.digests.evidence) {
43292
43591
  const fields = diffPointers(
@@ -43305,17 +43604,17 @@ async function rerun(args, io) {
43305
43604
 
43306
43605
  `);
43307
43606
  }
43308
- io.out(renderRecord(record2));
43607
+ io.out(renderRecord(record2, { hasToken: client.hasToken }));
43309
43608
  io.out(`record: ${out} (the original ${path} is unchanged)
43310
43609
  `);
43311
43610
  return exitFor(p.verdict);
43312
43611
  }
43313
43612
 
43314
43613
  // src/cli/suggest.ts
43315
- import { readFileSync as readFileSync5 } from "node:fs";
43316
- import { parseArgs as parseArgs3 } from "node:util";
43614
+ import { readFileSync as readFileSync6 } from "node:fs";
43615
+ import { parseArgs as parseArgs4 } from "node:util";
43317
43616
  async function suggest(args, io) {
43318
- const { values } = parseArgs3({
43617
+ const { values } = parseArgs4({
43319
43618
  args,
43320
43619
  strict: true,
43321
43620
  allowPositionals: false,
@@ -43324,7 +43623,7 @@ async function suggest(args, io) {
43324
43623
  const file2 = values["report-file"];
43325
43624
  let bytes;
43326
43625
  try {
43327
- bytes = file2 === void 0 || file2 === "-" ? io.readStdin() : readFileSync5(file2);
43626
+ bytes = file2 === void 0 || file2 === "-" ? io.readStdin() : readFileSync6(file2);
43328
43627
  } catch (e) {
43329
43628
  io.err(`dunstan: cannot read the report ${file2 ?? "stdin"}: ${e.message}
43330
43629
  `);
@@ -43334,61 +43633,815 @@ async function suggest(args, io) {
43334
43633
  return 0;
43335
43634
  }
43336
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 DEFAULT_LAST = 30;
43783
+ var MAX_LAST = 200;
43784
+ function appendListed(listed2, seen, items) {
43785
+ let duplicates = 0;
43786
+ for (const item of items) {
43787
+ if (seen.has(item.number)) duplicates++;
43788
+ else {
43789
+ seen.add(item.number);
43790
+ listed2.push(item);
43791
+ }
43792
+ }
43793
+ return duplicates;
43794
+ }
43795
+ var sinceStart = (since) => `${since}T00:00:00Z`;
43796
+ function selectPullRequests(parameters, listed2) {
43797
+ const authors = new Set(parameters.authors.map((a) => a.toLowerCase()));
43798
+ const seen = /* @__PURE__ */ new Set();
43799
+ return listed2.filter((pr) => {
43800
+ if (seen.has(pr.number)) return false;
43801
+ seen.add(pr.number);
43802
+ return true;
43803
+ }).filter((pr) => parameters.since === null || pr.createdAt >= sinceStart(parameters.since)).filter(
43804
+ (pr) => authors.size === 0 || pr.author !== null && authors.has(pr.author.toLowerCase())
43805
+ ).slice(0, parameters.last).map((pr) => pr.number);
43806
+ }
43807
+ function listingStop(parameters, pages, listed2) {
43808
+ const last = pages.at(-1);
43809
+ if (last === void 0) return null;
43810
+ if (last.error !== void 0) return "listing_unreadable";
43811
+ if (selectPullRequests(parameters, listed2).length >= parameters.last) return "selection_full";
43812
+ const since = parameters.since;
43813
+ if (since !== null && listed2.some((pr) => pr.createdAt < sinceStart(since))) {
43814
+ return "since_reached";
43815
+ }
43816
+ if (last.items < LISTING_PER_PAGE) return "listing_ended";
43817
+ return null;
43818
+ }
43819
+ function surveyCommand(parameters, out) {
43820
+ const words2 = [
43821
+ "dunstan",
43822
+ "survey",
43823
+ "--repo",
43824
+ parameters.repository,
43825
+ "--last",
43826
+ `${parameters.last}`
43827
+ ];
43828
+ if (parameters.since !== null) words2.push("--since", parameters.since);
43829
+ for (const author of parameters.authors) words2.push("--author", author);
43830
+ if (parameters.report.kind === "pr-comment") {
43831
+ words2.push("--report-comment-author", parameters.report.author);
43832
+ }
43833
+ if (parameters.advisory) words2.push("--advisory");
43834
+ words2.push("--out", out);
43835
+ return words2.join(" ");
43836
+ }
43837
+ function surveyRerun(parameters, out) {
43838
+ return { offline: `dunstan verify ${out}`, online: surveyCommand(parameters, out) };
43839
+ }
43840
+ function surveyDigest(record2) {
43841
+ const { digest: _, ...rest } = record2;
43842
+ return sha256Canonical(rest);
43843
+ }
43844
+ function buildSurveyRecord(input2) {
43845
+ const unsigned = {
43846
+ format: SURVEY_FORMAT,
43847
+ checker: input2.checker,
43848
+ selection: {
43849
+ parameters: input2.parameters,
43850
+ reader: { authenticated: input2.reader.authenticated },
43851
+ listing: input2.listing,
43852
+ selected: selectPullRequests(input2.parameters, input2.listing.pullRequests)
43853
+ },
43854
+ entries: input2.entries,
43855
+ aggregate: aggregate(
43856
+ input2.entries,
43857
+ input2.listing.pullRequests.length,
43858
+ input2.parameters.advisory,
43859
+ input2.reader
43860
+ ),
43861
+ rerun: surveyRerun(input2.parameters, input2.out)
43862
+ };
43863
+ return { ...unsigned, digest: { sha256: surveyDigest(unsigned) } };
43864
+ }
43865
+ function serializeSurvey(record2) {
43866
+ return `${JSON.stringify(record2, null, 2)}
43867
+ `;
43868
+ }
43869
+ function isSurveyRecord(value) {
43870
+ return value !== null && typeof value === "object" && !Array.isArray(value) && value.format === SURVEY_FORMAT;
43871
+ }
43872
+
43873
+ // src/pipeline/survey.ts
43874
+ var recordFileName = (repository, number4) => `dunstan-${repository.replace("/", "-")}-${number4}.json`;
43875
+ function rateLimited(response) {
43876
+ const s = response.status;
43877
+ return s === 429 || s === 403 && (response.headers.get("x-ratelimit-remaining") === "0" || response.headers.get("retry-after") !== null);
43878
+ }
43879
+ function str2(value) {
43880
+ return typeof value === "string" ? value : null;
43881
+ }
43882
+ function listedOf(item) {
43883
+ if (item === null || typeof item !== "object") return null;
43884
+ const pr = item;
43885
+ const createdAt = str2(pr.created_at);
43886
+ 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)) {
43887
+ return null;
43888
+ }
43889
+ const user = pr.user;
43890
+ const login = str2(user?.login);
43891
+ return {
43892
+ number: pr.number,
43893
+ createdAt,
43894
+ author: login === "" ? null : login,
43895
+ merged: pr.merged_at !== null && pr.merged_at !== void 0
43896
+ };
43897
+ }
43898
+ async function readListing(client, parameters, now, pageBound) {
43899
+ const pages = [];
43900
+ const pullRequests = [];
43901
+ const seen = /* @__PURE__ */ new Set();
43902
+ const done = (end) => ({
43903
+ pageBound,
43904
+ pages,
43905
+ pullRequests,
43906
+ duplicates: pages.reduce((sum, p) => sum + p.duplicates, 0),
43907
+ end
43908
+ });
43909
+ for (let page = 1; page <= pageBound; page++) {
43910
+ const path = `/repos/${parameters.repository}/pulls?state=closed&sort=created&direction=desc&per_page=${LISTING_PER_PAGE}&page=${page}`;
43911
+ const read = await client.read("pull_request", path, { record: false });
43912
+ const entry = {
43913
+ page,
43914
+ locator: `GET ${path}`,
43915
+ sha256: sha256Hex(read.body),
43916
+ readAt: now().toISOString(),
43917
+ items: 0,
43918
+ duplicates: 0
43919
+ };
43920
+ if (!read.ok) {
43921
+ entry.error = read.error;
43922
+ } else {
43923
+ const items = Array.isArray(read.json) ? read.json.map(listedOf) : null;
43924
+ if (items === null || items.some((i) => i === null)) entry.error = "parse";
43925
+ else {
43926
+ entry.items = items.length;
43927
+ entry.duplicates = appendListed(pullRequests, seen, items);
43928
+ }
43929
+ }
43930
+ pages.push(entry);
43931
+ const end = listingStop(parameters, pages, pullRequests);
43932
+ if (end !== null) return done(end);
43933
+ }
43934
+ return done("page_bound");
43935
+ }
43936
+ async function survey(input2) {
43937
+ const { parameters } = input2;
43938
+ const now = input2.now ?? (() => /* @__PURE__ */ new Date());
43939
+ const limited = /* @__PURE__ */ new Set();
43940
+ const baseFetch = input2.fetch ?? fetch;
43941
+ const watched = (async (resource, init) => {
43942
+ const response = await baseFetch(resource, init);
43943
+ if (rateLimited(response)) {
43944
+ const url2 = typeof resource === "string" ? resource : resource instanceof URL ? resource.href : resource.url;
43945
+ limited.add(new URL(url2).pathname);
43946
+ }
43947
+ return response;
43948
+ });
43949
+ const newClient = () => new GitHubClient({
43950
+ token: input2.token,
43951
+ fetch: watched,
43952
+ now,
43953
+ ...input2.sleep === void 0 ? {} : { sleep: input2.sleep },
43954
+ ...input2.warn === void 0 ? {} : { warn: input2.warn }
43955
+ });
43956
+ const listingClient = newClient();
43957
+ const reader = { authenticated: listingClient.hasToken };
43958
+ const listing = await readListing(listingClient, parameters, now, input2.pageBound ?? MAX_PAGES);
43959
+ const entries = [];
43960
+ for (const number4 of selectPullRequests(parameters, listing.pullRequests)) {
43961
+ const client = newClient();
43962
+ const repository = parameters.repository;
43963
+ const pullPath = `/repos/${repository}/pulls/${number4}`;
43964
+ const commentsPath = `/repos/${repository}/issues/${number4}/comments`;
43965
+ const report = parameters.report;
43966
+ try {
43967
+ const record2 = await checkPullRequest({
43968
+ readers: githubReaders(client),
43969
+ repository,
43970
+ number: number4,
43971
+ report: async (pr) => report.kind === "pr-body" ? reportFromPullRequestBody(pr.repository, pr) : reportFromComment(client, pr.repository, number4, report.author),
43972
+ checker: input2.checker,
43973
+ recordFile: recordFileName(repository, number4),
43974
+ advisory: parameters.advisory
43975
+ });
43976
+ entries.push({ pullRequest: number4, record: record2 });
43977
+ } catch (e) {
43978
+ let reason;
43979
+ if (e instanceof PullRequestMoved) reason = "pull_request_moved";
43980
+ else if (e instanceof PullRequestUnreadable) {
43981
+ reason = limited.has(pullPath) ? "rate_limited" : "pull_request_unreadable";
43982
+ } else if (e instanceof ReportUnavailable) {
43983
+ reason = limited.has(commentsPath) ? "rate_limited" : "report_unavailable";
43984
+ } else throw e;
43985
+ entries.push({ pullRequest: number4, record: null, reason });
43986
+ }
43987
+ }
43988
+ return buildSurveyRecord({
43989
+ checker: input2.checker,
43990
+ parameters,
43991
+ reader,
43992
+ listing,
43993
+ entries,
43994
+ out: input2.out
43995
+ });
43996
+ }
43997
+
43998
+ // src/survey/summary.ts
43999
+ var COLUMN_LABEL = {
44000
+ supported: "Supported",
44001
+ unverifiable: "Unverifiable",
44002
+ notRecordCheckable: "Not record-checkable",
44003
+ contradicted: "Contradicted"
44004
+ };
44005
+ var END_TEXT = {
44006
+ selection_full: "it held enough pull requests for the selection",
44007
+ since_reached: "it reached pull requests created before the --since date",
44008
+ listing_ended: "it ended",
44009
+ listing_unreadable: "a page could not be read; the selection is from the pages read before it",
44010
+ page_bound: "it reached the page bound"
44011
+ };
44012
+ 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.";
44013
+ function row(cells) {
44014
+ return `| ${cells.join(" | ")} |`;
44015
+ }
44016
+ function table2(head, rows) {
44017
+ return [row(head), row(head.map(() => "---")), ...rows.map(row)];
44018
+ }
44019
+ function share(r) {
44020
+ return r.value ?? "n/a";
44021
+ }
44022
+ function interval(r) {
44023
+ return r.wilson95 === null ? "n/a" : `${r.wilson95.low}\u2013${r.wilson95.high}`;
44024
+ }
44025
+ function plural2(n, one, many) {
44026
+ return `${n} ${n === 1 ? one : many}`;
44027
+ }
44028
+ function renderSurveySummary(record2) {
44029
+ const { parameters, listing, selected } = record2.selection;
44030
+ const a = record2.aggregate;
44031
+ const lines = [];
44032
+ const scope = [`the ${parameters.last} most recently created closed pull requests`];
44033
+ if (parameters.since !== null) scope.push(`created on or after ${parameters.since}`);
44034
+ if (parameters.authors.length > 0) scope.push(`opened by ${parameters.authors.join(", ")}`);
44035
+ const report = parameters.report.kind === "pr-body" ? "the pull request body" : `the latest comment by ${parameters.report.author}`;
44036
+ lines.push(
44037
+ `# Settleability survey: ${parameters.repository}`,
44038
+ "",
44039
+ `DRAFT (${record2.format}). ${record2.checker.name} ${record2.checker.version}, survey mapping ${a.mapping.version}. Read-only.`,
44040
+ "",
44041
+ `Selection: ${scope.join(", ")}. Report: ${report}. Advisory reader: ${parameters.advisory ? "on" : "off"}.`,
44042
+ "",
44043
+ record2.selection.reader.authenticated ? "GitHub was read with a token (GITHUB_TOKEN set)." : `GitHub was read without a token (GITHUB_TOKEN not set). ${ANONYMOUS}`,
44044
+ "",
44045
+ "This survey reports which claims the record could settle. A claim the record could not settle is neither supported nor contradicted.",
44046
+ "",
44047
+ "## Claims the record could not settle",
44048
+ "",
44049
+ `The record could not settle ${a.cannotSettle.numerator} of ${plural2(a.cannotSettle.denominator, "claim", "claims")}.`,
44050
+ ""
44051
+ );
44052
+ if (a.unsettled.length === 0) {
44053
+ lines.push("None.");
44054
+ } else {
44055
+ lines.push(
44056
+ ...table2(
44057
+ ["Reason", "Column", "Claims", "Remedy"],
44058
+ a.unsettled.map((u) => [
44059
+ `\`${u.reason}\``,
44060
+ COLUMN_LABEL[u.column],
44061
+ u.count,
44062
+ u.remedy ?? "none listed"
44063
+ ])
44064
+ )
44065
+ );
44066
+ }
44067
+ lines.push(
44068
+ "",
44069
+ "## Results",
44070
+ "",
44071
+ ...table2(
44072
+ ["Measure", "Claims", "Of", "Share", "Wilson 95%"],
44073
+ [
44074
+ [
44075
+ "The record could not settle",
44076
+ a.cannotSettle.numerator,
44077
+ `${a.cannotSettle.denominator} claims`,
44078
+ share(a.cannotSettle),
44079
+ interval(a.cannotSettle)
44080
+ ],
44081
+ [
44082
+ "Contradicted by the record, of the claims it settled",
44083
+ a.contradictedOfSettled.numerator,
44084
+ `${a.contradictedOfSettled.denominator} settled claims`,
44085
+ share(a.contradictedOfSettled),
44086
+ interval(a.contradictedOfSettled)
44087
+ ]
44088
+ ]
44089
+ ),
44090
+ "",
44091
+ "## By claim type",
44092
+ "",
44093
+ ...table2(
44094
+ ["Type", "Supported", "Unverifiable", "Not record-checkable", "Contradicted"],
44095
+ [
44096
+ ...a.byType.map((t) => {
44097
+ const label = CLAIM_TYPES.find((c) => c.type === t.type)?.label ?? "";
44098
+ return [
44099
+ `${t.type} ${label}`,
44100
+ t.supported,
44101
+ t.unverifiable,
44102
+ t.notRecordCheckable,
44103
+ t.contradicted
44104
+ ];
44105
+ }),
44106
+ [
44107
+ `All (${a.claims.total})`,
44108
+ a.claims.supported,
44109
+ a.claims.unverifiable,
44110
+ a.claims.notRecordCheckable,
44111
+ a.claims.contradicted
44112
+ ]
44113
+ ]
44114
+ )
44115
+ );
44116
+ if (a.advisory !== void 0) {
44117
+ lines.push(
44118
+ "",
44119
+ "## Advisory notes",
44120
+ "",
44121
+ `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.`,
44122
+ ""
44123
+ );
44124
+ if (a.advisory.notes.length === 0) lines.push("No notes.");
44125
+ else {
44126
+ lines.push(
44127
+ ...table2(
44128
+ ["Note", "As shown", "Count"],
44129
+ a.advisory.notes.map((n) => [`\`${n.note}\``, noteText(n.note), n.count])
44130
+ )
44131
+ );
44132
+ }
44133
+ }
44134
+ const p = a.pullRequests;
44135
+ const context = { hasToken: record2.selection.reader.authenticated };
44136
+ const remedy = (reason) => remedyForReason(reason, context) ?? "";
44137
+ lines.push(
44138
+ "",
44139
+ "## Pull requests",
44140
+ "",
44141
+ ...table2(
44142
+ ["Pull requests", "Count", "Remedy"],
44143
+ [
44144
+ ["Listed", p.listed, ""],
44145
+ ["Selected", p.selected, ""],
44146
+ ["With a valid block", p.blockFound, ""],
44147
+ ["`block_missing`", p.blockMissing, remedy("block_missing")],
44148
+ ["`block_invalid`", p.blockInvalid, remedy("block_invalid")],
44149
+ ["`block_ambiguous`", p.blockAmbiguous, remedy("block_ambiguous")],
44150
+ ...p.noRecord.map((n) => [`No record: \`${n.reason}\``, n.count, remedy(n.reason)])
44151
+ ]
44152
+ ),
44153
+ "",
44154
+ `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]}.`
44155
+ );
44156
+ if (listing.duplicates > 0) {
44157
+ const one = listing.duplicates === 1;
44158
+ lines.push(
44159
+ "",
44160
+ `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.`
44161
+ );
44162
+ }
44163
+ if (selected.length < parameters.last) {
44164
+ lines.push(
44165
+ "",
44166
+ `${plural2(selected.length, "pull request was", "pull requests were")} selected, fewer than the ${parameters.last} asked for.`
44167
+ );
44168
+ }
44169
+ lines.push(
44170
+ "",
44171
+ `Verify offline: \`${record2.rerun.offline}\`. Run again: \`${record2.rerun.online}\`.`,
44172
+ ""
44173
+ );
44174
+ return lines.join("\n");
44175
+ }
44176
+
44177
+ // src/cli/survey.ts
44178
+ var LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})(?:\[bot\])?$/;
44179
+ function parseLogin(value, flag) {
44180
+ if (!LOGIN.test(value)) throw new UsageError(`${flag} must be a GitHub login`);
44181
+ return value;
44182
+ }
44183
+ function parseSince(value) {
44184
+ const day = /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
44185
+ const valid = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(value) && !Number.isNaN(day.getTime()) && day.toISOString().slice(0, 10) === value;
44186
+ if (!valid) throw new UsageError("--since must be a date, YYYY-MM-DD (UTC)");
44187
+ return value;
44188
+ }
44189
+ async function survey2(args, io) {
44190
+ const { values } = parseArgs5({
44191
+ args,
44192
+ strict: true,
44193
+ allowPositionals: false,
44194
+ options: {
44195
+ repo: { type: "string" },
44196
+ last: { type: "string" },
44197
+ since: { type: "string" },
44198
+ author: { type: "string", multiple: true },
44199
+ "report-comment-author": { type: "string" },
44200
+ advisory: { type: "boolean" },
44201
+ out: { type: "string" },
44202
+ summary: { type: "string" }
44203
+ }
44204
+ });
44205
+ if (values.repo === void 0 || !REPOSITORY.test(values.repo)) {
44206
+ throw new UsageError("--repo <owner/name> is required");
44207
+ }
44208
+ if (values.out === void 0) throw new UsageError("--out <survey.json> is required");
44209
+ const last = values.last === void 0 ? DEFAULT_LAST : parsePositiveInt(values.last, "--last");
44210
+ if (last > MAX_LAST) throw new UsageError(`--last is at most ${MAX_LAST}`);
44211
+ const commentAuthor = values["report-comment-author"];
44212
+ const parameters = {
44213
+ repository: values.repo,
44214
+ last,
44215
+ since: values.since === void 0 ? null : parseSince(values.since),
44216
+ authors: (values.author ?? []).map((a) => parseLogin(a, "--author")),
44217
+ report: commentAuthor === void 0 ? { kind: "pr-body" } : { kind: "pr-comment", author: parseLogin(commentAuthor, "--report-comment-author") },
44218
+ advisory: values.advisory === true
44219
+ };
44220
+ const token = io.env.GITHUB_TOKEN;
44221
+ if (token === void 0 || token === "") {
44222
+ io.err(
44223
+ "dunstan: GITHUB_TOKEN is not set; reading anonymously (GitHub serves no artifact to an anonymous reader)\n"
44224
+ );
44225
+ }
44226
+ const record2 = await survey({
44227
+ parameters,
44228
+ checker: checkerIdentity(io.artifact),
44229
+ out: basename4(values.out),
44230
+ token,
44231
+ ...io.fetch === void 0 ? {} : { fetch: io.fetch },
44232
+ ...io.now === void 0 ? {} : { now: io.now },
44233
+ warn: (message) => io.err(`dunstan: ${message}
44234
+ `)
44235
+ });
44236
+ writeFileSync5(values.out, serializeSurvey(record2));
44237
+ const summary = renderSurveySummary(record2);
44238
+ if (values.summary === void 0) {
44239
+ io.out(summary);
44240
+ io.err(`dunstan: survey record: ${values.out}
44241
+ `);
44242
+ } else {
44243
+ writeFileSync5(values.summary, summary);
44244
+ io.out(`survey record: ${values.out}
44245
+ summary: ${values.summary}
44246
+ `);
44247
+ }
44248
+ return 0;
44249
+ }
44250
+
43337
44251
  // src/cli/template.ts
43338
- import { parseArgs as parseArgs4 } from "node:util";
43339
- var TEST_RECORD_FLAGS = [
43340
- "tests-command",
44252
+ import { parseArgs as parseArgs6 } from "node:util";
44253
+
44254
+ // src/template/tests-ci.ts
44255
+ var plural3 = (n, one, many) => `${n} ${n === 1 ? one : many}`;
44256
+ function unsettled(reason, why, remedy) {
44257
+ const notes = [`tests are not declared: ${why} (${reason}).`];
44258
+ if (remedy !== void 0) notes.push(`remedy: ${remedy}`);
44259
+ return { status: "unsettled", reason, notes };
44260
+ }
44261
+ async function testsFromCi(client, input2) {
44262
+ const { repository, headCommit: head, record: record2 } = input2;
44263
+ if (!client.hasToken) {
44264
+ return unsettled(
44265
+ "no_token",
44266
+ "GITHUB_TOKEN is not set, and GitHub answers an artifact download only with a token, even on a public repository",
44267
+ "set GITHUB_TOKEN to a token that can read the repository, with actions: read, and run dunstan template --tests-ci again."
44268
+ );
44269
+ }
44270
+ if (!input2.pushed) {
44271
+ return unsettled(
44272
+ "not_pushed",
44273
+ `HEAD ${head} is not on any remote-tracking branch, so no CI run can have tested it`,
44274
+ "push it, wait for CI to finish, and run dunstan template --tests-ci again (run git fetch first if it was pushed from elsewhere)."
44275
+ );
44276
+ }
44277
+ const observed = { runsNotCompleted: 0 };
44278
+ const evidence = await readTestRecord(client, repository, record2, head, observed);
44279
+ const named = `the artifact ${record2.artifact} holding ${record2.path} from job ${record2.job}`;
44280
+ 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;
44281
+ const again = "run dunstan template --tests-ci again once CI has finished on this commit.";
44282
+ if (evidence.status !== "ok") {
44283
+ const reason = evidence.status === "unreadable" ? `source_unreadable:${evidence.source}` : `evidence_field_unpopulated:${evidence.field}`;
44284
+ if (pending !== void 0) return unsettled(reason, pending, again);
44285
+ 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`;
44286
+ return unsettled(
44287
+ reason,
44288
+ why,
44289
+ remedyForReason(reason, { hasToken: true }) ?? "run dunstan template --tests-ci again."
44290
+ );
44291
+ }
44292
+ if (evidence.runs.length === 0) {
44293
+ if (pending !== void 0) {
44294
+ return unsettled(
44295
+ "record_not_found",
44296
+ `${pending}, and no completed run uploaded ${named}`,
44297
+ again
44298
+ );
44299
+ }
44300
+ return unsettled(
44301
+ "record_not_found",
44302
+ `no run of ${record2.workflow} at ${head} uploaded ${named}`,
44303
+ remedyForReason("record_not_found", { hasToken: true })
44304
+ );
44305
+ }
44306
+ const executed = new Set(evidence.runs.map((r) => r.executed));
44307
+ const failed = new Set(evidence.runs.map((r) => r.failed));
44308
+ if (executed.size > 1 || failed.size > 1) {
44309
+ const counts = evidence.runs.map((r) => `run ${r.runId}: ${r.executed} executed, ${r.failed} failed`).join("; ");
44310
+ return unsettled(
44311
+ "record_ambiguous",
44312
+ `the runs of ${record2.workflow} at ${head} that uploaded ${record2.artifact} disagree (${counts}), and the gate never picks one`,
44313
+ "make the runs agree (re-run the one that differs), or name a job only one of them runs."
44314
+ );
44315
+ }
44316
+ const run = evidence.runs[0];
44317
+ const runs = evidence.runs.map((r) => r.runId).join(", ");
44318
+ const notes = [
44319
+ `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}).`
44320
+ ];
44321
+ if (pending !== void 0) {
44322
+ notes.push(
44323
+ `${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.`
44324
+ );
44325
+ }
44326
+ return {
44327
+ status: "settled",
44328
+ run: { command: input2.command, count: run.executed, failures: run.failed, record: record2 },
44329
+ notes
44330
+ };
44331
+ }
44332
+
44333
+ // src/cli/template.ts
44334
+ var RECORD_FLAGS = [
43341
44335
  "tests-workflow",
43342
44336
  "tests-job",
43343
44337
  "tests-artifact",
43344
44338
  "tests-artifact-path"
43345
44339
  ];
44340
+ var DEFAULT_ARTIFACT = "dunstan-junit";
44341
+ var DEFAULT_ARTIFACT_PATH = "junit.xml";
43346
44342
  async function template(args, io) {
43347
- const { values } = parseArgs4({
44343
+ const { values } = parseArgs6({
43348
44344
  args,
43349
44345
  strict: true,
43350
44346
  allowPositionals: false,
43351
44347
  options: {
43352
44348
  base: { type: "string" },
44349
+ "tests-ci": { type: "boolean" },
44350
+ "tests-local": { type: "string" },
43353
44351
  "tests-junit": { type: "string" },
43354
- ...Object.fromEntries(TEST_RECORD_FLAGS.map((f) => [f, { type: "string" }]))
44352
+ "tests-command": { type: "string" },
44353
+ repo: { type: "string" },
44354
+ ...Object.fromEntries(RECORD_FLAGS.map((f) => [f, { type: "string" }]))
43355
44355
  }
43356
44356
  });
43357
44357
  const v = values;
44358
+ const flag = (name) => v[name];
44359
+ const ci = v["tests-ci"] === true;
44360
+ const modes = [ci, flag("tests-local") !== void 0, flag("tests-junit") !== void 0];
44361
+ if (modes.filter(Boolean).length > 1) {
44362
+ throw new UsageError("give one of --tests-ci, --tests-local or --tests-junit");
44363
+ }
44364
+ if (flag("repo") !== void 0 && !ci) throw new UsageError("--repo goes with --tests-ci");
44365
+ const given = ["tests-command", ...RECORD_FLAGS].filter((f) => flag(f) !== void 0);
44366
+ const cwd = io.cwd ?? process.cwd();
43358
44367
  let tests;
43359
- const given = TEST_RECORD_FLAGS.filter((f) => v[f] !== void 0);
43360
- if (v["tests-junit"] === void 0) {
43361
- if (given.length > 0) {
43362
- throw new UsageError(`--${given[0]} goes with --tests-junit <JUnit XML file>`);
44368
+ if (ci) {
44369
+ const missing = ["tests-command", "tests-workflow", "tests-job"].filter(
44370
+ (f) => flag(f) === void 0
44371
+ );
44372
+ if (missing.length > 0) {
44373
+ throw new UsageError(`--tests-ci needs ${missing.map((f) => `--${f}`).join(", ")}`);
44374
+ }
44375
+ const repo = flag("repo");
44376
+ if (repo !== void 0 && !REPOSITORY.test(repo)) {
44377
+ throw new UsageError("--repo must be <owner/name>");
44378
+ }
44379
+ const head = headCommit(cwd);
44380
+ const repository = repo ?? originRepository(cwd);
44381
+ const client = new GitHubClient({
44382
+ token: io.env.GITHUB_TOKEN,
44383
+ ...io.fetch === void 0 ? {} : { fetch: io.fetch },
44384
+ warn: (message) => io.err(`dunstan: ${message}
44385
+ `)
44386
+ });
44387
+ const read = await testsFromCi(client, {
44388
+ repository,
44389
+ headCommit: head,
44390
+ command: flag("tests-command"),
44391
+ pushed: isPushed(cwd, head),
44392
+ record: {
44393
+ kind: "junit",
44394
+ workflow: flag("tests-workflow"),
44395
+ job: flag("tests-job"),
44396
+ artifact: flag("tests-artifact") ?? DEFAULT_ARTIFACT,
44397
+ path: flag("tests-artifact-path") ?? DEFAULT_ARTIFACT_PATH
44398
+ }
44399
+ });
44400
+ tests = { kind: "ci", read };
44401
+ } else if (flag("tests-local") !== void 0) {
44402
+ if (flag("tests-command") === void 0) {
44403
+ throw new UsageError("--tests-local needs --tests-command <cmd>");
43363
44404
  }
43364
- } else {
43365
- const missing = TEST_RECORD_FLAGS.filter((f) => v[f] === void 0);
44405
+ const extra = RECORD_FLAGS.find((f) => flag(f) !== void 0);
44406
+ if (extra !== void 0) throw new UsageError(`--${extra} does not go with --tests-local`);
44407
+ tests = {
44408
+ kind: "local",
44409
+ junit: flag("tests-local"),
44410
+ command: flag("tests-command")
44411
+ };
44412
+ } else if (flag("tests-junit") !== void 0) {
44413
+ const missing = ["tests-command", ...RECORD_FLAGS].filter(
44414
+ (f) => flag(f) === void 0
44415
+ );
43366
44416
  if (missing.length > 0) {
43367
44417
  throw new UsageError(
43368
44418
  `--tests-junit needs the CI record its counts are checked against: ${missing.map((f) => `--${f}`).join(", ")}`
43369
44419
  );
43370
44420
  }
43371
44421
  tests = {
43372
- junit: v["tests-junit"],
43373
- command: v["tests-command"],
44422
+ kind: "junit",
44423
+ junit: flag("tests-junit"),
44424
+ command: flag("tests-command"),
43374
44425
  record: {
43375
- workflow: v["tests-workflow"],
43376
- job: v["tests-job"],
43377
- artifact: v["tests-artifact"],
43378
- path: v["tests-artifact-path"]
44426
+ workflow: flag("tests-workflow"),
44427
+ job: flag("tests-job"),
44428
+ artifact: flag("tests-artifact"),
44429
+ path: flag("tests-artifact-path")
43379
44430
  }
43380
44431
  };
44432
+ } else if (given.length > 0) {
44433
+ throw new UsageError(`--${given[0]} goes with --tests-ci, --tests-local or --tests-junit`);
43381
44434
  }
43382
- const t = templateBlock({ cwd: io.cwd ?? process.cwd(), base: v.base, tests });
44435
+ const t = templateBlock({ cwd, base: flag("base"), tests });
43383
44436
  io.out(renderBlock(t.block));
43384
44437
  for (const note of t.notes) io.err(`dunstan: ${note}
43385
44438
  `);
43386
- return 0;
44439
+ return tests?.kind === "ci" && tests.read.status === "unsettled" ? EXIT.unverifiable : 0;
43387
44440
  }
43388
44441
 
43389
44442
  // src/cli/verify.ts
43390
- import { readFileSync as readFileSync6 } from "node:fs";
43391
- import { parseArgs as parseArgs5 } from "node:util";
44443
+ import { readFileSync as readFileSync7 } from "node:fs";
44444
+ import { parseArgs as parseArgs7 } from "node:util";
43392
44445
 
43393
44446
  // src/record/verify.ts
43394
44447
  function same(a, b) {
@@ -43443,10 +44496,10 @@ function verifyRecord(value) {
43443
44496
  const claims = claimsFor(p.block, evidence, p.subject.repository);
43444
44497
  if (!same(p.claims, claims)) {
43445
44498
  const index = claims.findIndex((c, i) => !same(c, p.claims[i]));
43446
- const at = index === -1 ? claims.length : index;
44499
+ const at2 = index === -1 ? claims.length : index;
43447
44500
  problems.push({
43448
- member: `/predicate/claims/${at}`,
43449
- message: `recorded ${canonicalize(p.claims[at] ?? null)}, recomputed ${canonicalize(claims[at] ?? null)}`
44501
+ member: `/predicate/claims/${at2}`,
44502
+ message: `recorded ${canonicalize(p.claims[at2] ?? null)}, recomputed ${canonicalize(claims[at2] ?? null)}`
43450
44503
  });
43451
44504
  }
43452
44505
  differ("/predicate/verdict", p.verdict, overallVerdict(p.block.status, claims));
@@ -43503,9 +44556,162 @@ function verifyRecord(value) {
43503
44556
  return { ok: problems.length === 0, problems, record: record2 };
43504
44557
  }
43505
44558
 
44559
+ // src/survey/verify.ts
44560
+ var text = (value) => canonicalize(value ?? null);
44561
+ function differences(a, b, pointer) {
44562
+ const isObject2 = (v) => v !== null && typeof v === "object";
44563
+ if (Array.isArray(a) && Array.isArray(b)) {
44564
+ const out = [];
44565
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
44566
+ out.push(...differences(a[i], b[i], `${pointer}/${i}`));
44567
+ }
44568
+ return out;
44569
+ }
44570
+ if (isObject2(a) && isObject2(b) && !Array.isArray(a) && !Array.isArray(b)) {
44571
+ const ao = a;
44572
+ const bo = b;
44573
+ const keys = [.../* @__PURE__ */ new Set([...Object.keys(ao), ...Object.keys(bo)])].sort();
44574
+ return keys.flatMap((k) => differences(ao[k], bo[k], `${pointer}/${escapePointerToken(k)}`));
44575
+ }
44576
+ return text(a) === text(b) ? [] : [pointer];
44577
+ }
44578
+ function at(value, pointer, base) {
44579
+ let v = value;
44580
+ for (const token of pointer.slice(base.length).split("/").slice(1)) {
44581
+ const key = token.replace(/~1/g, "/").replace(/~0/g, "~");
44582
+ v = v !== null && typeof v === "object" ? v[key] : void 0;
44583
+ }
44584
+ return v;
44585
+ }
44586
+ function verifySurveyRecord(value) {
44587
+ const schemaErrors = validateSurveyRecord(value);
44588
+ if (schemaErrors.length > 0) {
44589
+ return {
44590
+ ok: false,
44591
+ problems: schemaErrors.map((e) => ({
44592
+ member: e.pointer,
44593
+ message: `schema_violation (${e.keyword}): ${e.message}`
44594
+ }))
44595
+ };
44596
+ }
44597
+ const record2 = value;
44598
+ const problems = [];
44599
+ const differ = (member, recorded, recomputed) => {
44600
+ for (const pointer of differences(recorded, recomputed, member)) {
44601
+ problems.push({
44602
+ member: pointer,
44603
+ message: `recorded ${text(at(recorded, pointer, member))}, recomputed ${text(at(recomputed, pointer, member))}`
44604
+ });
44605
+ }
44606
+ };
44607
+ const { checker, selection, entries } = record2;
44608
+ if (checker.name !== CHECKER_NAME || checker.version !== CHECKER_VERSION) {
44609
+ problems.push({
44610
+ member: "/checker",
44611
+ message: `survey was written by ${checker.name} ${checker.version}; this is ${CHECKER_NAME} ${CHECKER_VERSION}`
44612
+ });
44613
+ return { ok: false, problems, record: record2 };
44614
+ }
44615
+ const { parameters, listing } = selection;
44616
+ const firstAt = /* @__PURE__ */ new Map();
44617
+ listing.pullRequests.forEach((pr, i) => {
44618
+ const first = firstAt.get(pr.number);
44619
+ if (first === void 0) firstAt.set(pr.number, i);
44620
+ else {
44621
+ problems.push({
44622
+ member: `/selection/listing/pullRequests/${i}`,
44623
+ message: `pull request ${pr.number} is listed again (first at /selection/listing/pullRequests/${first}); the listing holds each number once`
44624
+ });
44625
+ }
44626
+ });
44627
+ const held = listing.pages.reduce((sum, page) => sum + page.items, 0);
44628
+ const repeated = listing.pages.reduce((sum, page) => sum + page.duplicates, 0);
44629
+ listing.pages.forEach((page, i) => {
44630
+ if (page.duplicates > page.items) {
44631
+ problems.push({
44632
+ member: `/selection/listing/pages/${i}/duplicates`,
44633
+ message: `recorded ${page.duplicates}, more than the page's ${page.items} items`
44634
+ });
44635
+ }
44636
+ });
44637
+ differ("/selection/listing/duplicates", listing.duplicates, repeated);
44638
+ differ(
44639
+ "/selection/listing/pullRequests/length",
44640
+ listing.pullRequests.length,
44641
+ held - listing.duplicates
44642
+ );
44643
+ let read = 0;
44644
+ listing.pages.forEach((page, i) => {
44645
+ read += page.items - page.duplicates;
44646
+ if (i === listing.pages.length - 1) return;
44647
+ const stop2 = listingStop(
44648
+ parameters,
44649
+ listing.pages.slice(0, i + 1),
44650
+ listing.pullRequests.slice(0, read)
44651
+ );
44652
+ if (stop2 !== null) {
44653
+ problems.push({
44654
+ member: `/selection/listing/pages/${i + 1}`,
44655
+ message: `the listing ended at page ${page.page} (${stop2}); no later page is read`
44656
+ });
44657
+ }
44658
+ });
44659
+ const stop = listingStop(parameters, listing.pages, listing.pullRequests);
44660
+ const end = stop ?? (listing.pages.length === listing.pageBound ? "page_bound" : null);
44661
+ if (end === null) {
44662
+ problems.push({
44663
+ member: "/selection/listing/end",
44664
+ message: `recorded ${text(listing.end)}, recomputed none: the listing was not read to an end`
44665
+ });
44666
+ } else {
44667
+ differ("/selection/listing/end", listing.end, end);
44668
+ }
44669
+ if (listing.pages.length > listing.pageBound) {
44670
+ problems.push({
44671
+ member: "/selection/listing/pages",
44672
+ message: `${listing.pages.length} pages read, past the bound of ${listing.pageBound}`
44673
+ });
44674
+ }
44675
+ const selected = selectPullRequests(parameters, listing.pullRequests);
44676
+ differ("/selection/selected", selection.selected, selected);
44677
+ differ(
44678
+ "/entries/pullRequests",
44679
+ entries.map((e) => e.pullRequest),
44680
+ selected
44681
+ );
44682
+ entries.forEach((entry, i) => {
44683
+ if (entry.record === null) return;
44684
+ const base = `/entries/${i}/record`;
44685
+ const result = verifyRecord(entry.record);
44686
+ for (const p of result.problems)
44687
+ problems.push({ member: `${base}${p.member}`, message: p.message });
44688
+ const subject = entry.record.predicate.subject;
44689
+ differ(`${base}/predicate/subject/pullRequest`, subject.pullRequest, entry.pullRequest);
44690
+ differ(
44691
+ `${base}/predicate/subject/repository`,
44692
+ subject.repository.toLowerCase(),
44693
+ parameters.repository.toLowerCase()
44694
+ );
44695
+ differ(`${base}/predicate/checker`, entry.record.predicate.checker, checker);
44696
+ });
44697
+ try {
44698
+ differ(
44699
+ "/aggregate",
44700
+ record2.aggregate,
44701
+ aggregate(entries, listing.pullRequests.length, parameters.advisory, selection.reader)
44702
+ );
44703
+ } catch (e) {
44704
+ problems.push({ member: "/aggregate", message: `not recomputed: ${e.message}` });
44705
+ }
44706
+ const out = record2.rerun.offline.startsWith("dunstan verify ") ? record2.rerun.offline.slice("dunstan verify ".length) : "";
44707
+ differ("/rerun", record2.rerun, surveyRerun(parameters, out));
44708
+ differ("/digest/sha256", record2.digest.sha256, surveyDigest(record2));
44709
+ return { ok: problems.length === 0, problems, record: record2 };
44710
+ }
44711
+
43506
44712
  // src/cli/verify.ts
43507
44713
  async function verify(args, io) {
43508
- const { values, positionals } = parseArgs5({
44714
+ const { values, positionals } = parseArgs7({
43509
44715
  args,
43510
44716
  strict: true,
43511
44717
  allowPositionals: true,
@@ -43518,7 +44724,7 @@ async function verify(args, io) {
43518
44724
  throw new UsageError("--sig and --allowed-signers go together");
43519
44725
  }
43520
44726
  const path = positionals[0];
43521
- const bytes = readFileSync6(path);
44727
+ const bytes = readFileSync7(path);
43522
44728
  let value;
43523
44729
  try {
43524
44730
  value = parseStrictJson(new TextDecoder("utf-8").decode(bytes));
@@ -43528,6 +44734,25 @@ async function verify(args, io) {
43528
44734
  `);
43529
44735
  return 1;
43530
44736
  }
44737
+ if (isSurveyRecord(value)) {
44738
+ if (sig !== void 0) throw new UsageError("a survey record is not signed");
44739
+ const survey3 = verifySurveyRecord(value);
44740
+ if (!survey3.ok) {
44741
+ io.out(`NOT verified: ${path}
44742
+ `);
44743
+ for (const problem of survey3.problems) {
44744
+ io.out(` ${problem.member || "/"}: ${problem.message}
44745
+ `);
44746
+ }
44747
+ return 1;
44748
+ }
44749
+ const s = survey3.record;
44750
+ io.out(
44751
+ `verified: ${path}: survey of ${s?.entries.length} pull requests; every embedded record, the selection, the aggregate and the digest recompute
44752
+ `
44753
+ );
44754
+ return 0;
44755
+ }
43531
44756
  const result = verifyRecord(value);
43532
44757
  const problems = [...result.problems];
43533
44758
  const assurance = result.record?.predicate.assurance;
@@ -43581,9 +44806,16 @@ usage:
43581
44806
  dunstan verify <record.json> [--sig <file> --allowed-signers <file>]
43582
44807
  dunstan rerun <record.json> [--out <record.json>] [--exclude-check-run <id>]...
43583
44808
  dunstan template [--base <ref>]
43584
- [--tests-junit <file> --tests-command <cmd> --tests-workflow <path>
44809
+ [--tests-ci --tests-command <cmd> --tests-workflow <path> --tests-job <name>
44810
+ [--tests-artifact <name>] [--tests-artifact-path <path>] [--repo <owner/name>]
44811
+ | --tests-local <file> --tests-command <cmd>
44812
+ | --tests-junit <file> --tests-command <cmd> --tests-workflow <path>
43585
44813
  --tests-job <name> --tests-artifact <name> --tests-artifact-path <path>]
43586
44814
  dunstan suggest [--report-file <path|->]
44815
+ dunstan survey --repo <owner/name> --out <survey.json> [--summary <survey.md>]
44816
+ [--last <n>] [--since <YYYY-MM-DD>] [--author <login>]...
44817
+ [--report-comment-author <login>] [--advisory]
44818
+ dunstan junit-merge <file>... --out <file>
43587
44819
  dunstan mcp
43588
44820
 
43589
44821
  check reads the pull request, the named report and the evidence its block needs, writes the
@@ -43593,15 +44825,30 @@ check reads the pull request, the named report and the evidence its block ne
43593
44825
  verify offline: recomputes claims, verdict and digests from the record and compares them.
43594
44826
  rerun online: re-reads the sources for the recorded subject and block; writes a new record.
43595
44827
  template local git: prints a dunstan-handback block for HEAD, its files against the merge base
43596
- with --base (default: the remote's default branch). Tests only from a JUnit file and
43597
- the CI record named with it; references, checks and mergedAt never (docs/agents.md).
44828
+ with --base (default: the remote's default branch). Tests: --tests-ci reads the counts
44829
+ at HEAD from the CI record check reads (artifact dunstan-junit, file junit.xml unless
44830
+ given; the repository origin names on github.com unless --repo), with GITHUB_TOKEN;
44831
+ --tests-local declares a local JUnit file's counts as a local run; --tests-junit
44832
+ declares them against a CI record. Only --tests-ci reads the network. References,
44833
+ checks and mergedAt never (docs/agents.md, docs/ci-test-records.md).
43598
44834
  suggest offline: lists sentences in the report's prose (a file, or stdin) that look like claims
43599
44835
  its block does not declare, to declare or reword. Never a verdict (docs/agents.md).
44836
+ survey read-only, DRAFT (docs/survey.md): checks the --last (default 30, at most 200) most
44837
+ recently created closed pull requests as check does, the report in each body or the
44838
+ named author's latest comment; writes the survey record and a Markdown summary of the
44839
+ claims the record could not settle, why, and the contradicted share of the claims it
44840
+ settled. verify recomputes a survey record. GITHUB_TOKEN is optional; the record says
44841
+ whether it was set.
44842
+ junit-merge
44843
+ offline: one JUnit file from several (Maven Surefire, Gradle), inputs in code-unit
44844
+ order of path, each copied whole; refused unless its counts are the inputs' sum.
43600
44845
  mcp serves dunstan_check_handback and dunstan_suggest_declarations over stdio
43601
44846
  (docs/mcp.md): check's checker and suggest, called by an agent before it says done.
43602
44847
 
43603
- exit codes: 0 pass (verify: verified; template: printed; suggest: always), 1 fail (verify: not
43604
- verified), 2 unverifiable, 3 usage or internal error, or git cannot answer (template)
44848
+ exit codes: 0 pass (verify: verified; template: printed; suggest: always; survey: written;
44849
+ junit-merge: written), 1 fail (verify: not verified), 2 unverifiable (template:
44850
+ printed without the tests --tests-ci asked for), 3 usage or internal error, git
44851
+ cannot answer (template), or refused (junit-merge)
43605
44852
  `;
43606
44853
  async function main(argv, io) {
43607
44854
  const [command, ...args] = argv;
@@ -43617,6 +44864,10 @@ async function main(argv, io) {
43617
44864
  return await template(args, io);
43618
44865
  case "suggest":
43619
44866
  return await suggest(args, io);
44867
+ case "survey":
44868
+ return await survey2(args, io);
44869
+ case "junit-merge":
44870
+ return await junitMerge(args, io);
43620
44871
  case "mcp":
43621
44872
  return await mcp(args, io);
43622
44873
  case "--version":
@@ -43649,11 +44900,11 @@ ${USAGE}`);
43649
44900
 
43650
44901
  // src/cli/bin.ts
43651
44902
  process.exitCode = await main(process.argv.slice(2), {
43652
- out: (text) => process.stdout.write(text),
43653
- err: (text) => process.stderr.write(text),
44903
+ out: (text2) => process.stdout.write(text2),
44904
+ err: (text2) => process.stderr.write(text2),
43654
44905
  env: process.env,
43655
44906
  artifact: import.meta.url,
43656
- readStdin: () => readFileSync7(0),
44907
+ readStdin: () => readFileSync8(0),
43657
44908
  stdin: process.stdin,
43658
44909
  stdout: process.stdout
43659
44910
  });