dunstan 0.1.15 → 0.1.16

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 +3 -3
  2. package/dist/dunstan.mjs +263 -130
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -26,7 +26,7 @@ jobs:
26
26
  handback:
27
27
  runs-on: ubuntu-latest
28
28
  steps:
29
- - uses: BargLabs/dunstan@v0.1.15 # pin the full commit SHA in production
29
+ - uses: BargLabs/dunstan@v0.1.16 # pin the full commit SHA in production
30
30
  with:
31
31
  report-source: pr-body
32
32
  ```
@@ -52,7 +52,7 @@ export exists).
52
52
 
53
53
  ## Use with Claude
54
54
 
55
- Dunstan ships as a Claude Code plugin: two MCP tools and a skill that has Claude declare, check and
55
+ Dunstan ships as a Claude Code plugin: three MCP tools and a skill that has Claude declare, check and
56
56
  report a pull request's handback before it says done ([`plugins/dunstan/`](plugins/dunstan/)).
57
57
 
58
58
  **Install.** In Claude Code:
@@ -62,7 +62,7 @@ report a pull request's handback before it says done ([`plugins/dunstan/`](plugi
62
62
  /plugin install dunstan@barglabs
63
63
  ```
64
64
 
65
- The plugin runs the MCP server with `npx -y dunstan@0.1.15 mcp`, the `dunstan` npm package at that
65
+ The plugin runs the MCP server with `npx -y dunstan@0.1.16 mcp`, the `dunstan` npm package at that
66
66
  exact version: one self-contained file, no dependencies.
67
67
 
68
68
  **The token.** When you enable the plugin, Claude Code asks for an optional GitHub token and keeps it
package/dist/dunstan.mjs CHANGED
@@ -9881,7 +9881,7 @@ async function reportFromComment(client, repository, number4, login) {
9881
9881
  // src/record/checker.ts
9882
9882
  import { readFileSync } from "node:fs";
9883
9883
  var CHECKER_NAME = "dunstan";
9884
- var CHECKER_VERSION = "0.1.15";
9884
+ var CHECKER_VERSION = "0.1.16";
9885
9885
  function checkerIdentity(artifact) {
9886
9886
  return {
9887
9887
  name: CHECKER_NAME,
@@ -35146,7 +35146,7 @@ var StdioServerTransport = class {
35146
35146
 
35147
35147
  // src/mcp/server.ts
35148
35148
  import { existsSync, writeFileSync as writeFileSync3 } from "node:fs";
35149
- import { basename as basename2, resolve as resolve3 } from "node:path";
35149
+ import { basename as basename2, isAbsolute, resolve as resolve3 } from "node:path";
35150
35150
 
35151
35151
  // node_modules/.pnpm/zod@4.6.5/node_modules/zod/v3/helpers/util.js
35152
35152
  var util;
@@ -43335,14 +43335,147 @@ function suggestText(report) {
43335
43335
  return renderSuggestions(suggestDeclarations(report));
43336
43336
  }
43337
43337
 
43338
+ // src/template/tests-ci.ts
43339
+ var plural2 = (n, one, many) => `${n} ${n === 1 ? one : many}`;
43340
+ function unsettled(reason, why, remedy) {
43341
+ const notes = [`tests are not declared: ${why} (${reason}).`];
43342
+ if (remedy !== void 0) notes.push(`remedy: ${remedy}`);
43343
+ return { status: "unsettled", reason, notes };
43344
+ }
43345
+ async function testsFromCi(client, input2) {
43346
+ const { repository, headCommit: head, record: record2 } = input2;
43347
+ if (!client.hasToken) {
43348
+ return unsettled(
43349
+ "no_token",
43350
+ "GITHUB_TOKEN is not set, and GitHub answers an artifact download only with a token, even on a public repository",
43351
+ "set GITHUB_TOKEN to a token that can read the repository, with actions: read, and run dunstan template --tests-ci again."
43352
+ );
43353
+ }
43354
+ if (!input2.pushed) {
43355
+ return unsettled(
43356
+ "not_pushed",
43357
+ `HEAD ${head} is not on any remote-tracking branch, so no CI run can have tested it`,
43358
+ "push it, wait for CI to finish, and run dunstan template --tests-ci again (run git fetch first if it was pushed from elsewhere)."
43359
+ );
43360
+ }
43361
+ const observed = { runsNotCompleted: 0 };
43362
+ const evidence = await readTestRecord(client, repository, record2, head, observed);
43363
+ const named = `the artifact ${record2.artifact} holding ${record2.path} from job ${record2.job}`;
43364
+ const pending = observed.runsNotCompleted > 0 ? `CI has not finished: ${plural2(observed.runsNotCompleted, "run", "runs")} of ${record2.workflow} at ${head} ${observed.runsNotCompleted === 1 ? "is" : "are"} not completed` : void 0;
43365
+ const again = "run dunstan template --tests-ci again once CI has finished on this commit.";
43366
+ if (evidence.status !== "ok") {
43367
+ const reason = evidence.status === "unreadable" ? `source_unreadable:${evidence.source}` : `evidence_field_unpopulated:${evidence.field}`;
43368
+ if (pending !== void 0) return unsettled(reason, pending, again);
43369
+ 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`;
43370
+ return unsettled(
43371
+ reason,
43372
+ why,
43373
+ remedyForReason(reason, { hasToken: true }) ?? "run dunstan template --tests-ci again."
43374
+ );
43375
+ }
43376
+ if (evidence.runs.length === 0) {
43377
+ if (pending !== void 0) {
43378
+ return unsettled(
43379
+ "record_not_found",
43380
+ `${pending}, and no completed run uploaded ${named}`,
43381
+ again
43382
+ );
43383
+ }
43384
+ return unsettled(
43385
+ "record_not_found",
43386
+ `no run of ${record2.workflow} at ${head} uploaded ${named}`,
43387
+ remedyForReason("record_not_found", { hasToken: true })
43388
+ );
43389
+ }
43390
+ const executed = new Set(evidence.runs.map((r) => r.executed));
43391
+ const failed = new Set(evidence.runs.map((r) => r.failed));
43392
+ if (executed.size > 1 || failed.size > 1) {
43393
+ const counts = evidence.runs.map((r) => `run ${r.runId}: ${r.executed} executed, ${r.failed} failed`).join("; ");
43394
+ return unsettled(
43395
+ "record_ambiguous",
43396
+ `the runs of ${record2.workflow} at ${head} that uploaded ${record2.artifact} disagree (${counts}), and the gate never picks one`,
43397
+ "make the runs agree (re-run the one that differs), or name a job only one of them runs."
43398
+ );
43399
+ }
43400
+ const run = evidence.runs[0];
43401
+ const runs = evidence.runs.map((r) => r.runId).join(", ");
43402
+ const notes = [
43403
+ `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}).`
43404
+ ];
43405
+ if (pending !== void 0) {
43406
+ notes.push(
43407
+ `${pending}; a run that finishes later with other counts makes the gate's read ambiguous. Run dunstan template --tests-ci again once CI has finished.`
43408
+ );
43409
+ }
43410
+ return {
43411
+ status: "settled",
43412
+ run: { command: input2.command, count: run.executed, failures: run.failed, record: record2 },
43413
+ notes
43414
+ };
43415
+ }
43416
+
43417
+ // src/template/run.ts
43418
+ var DEFAULT_ARTIFACT = "dunstan-junit";
43419
+ var DEFAULT_ARTIFACT_PATH = "junit.xml";
43420
+ async function testsSource(request, context) {
43421
+ const { cwd, tests } = request;
43422
+ if (tests === void 0) return void 0;
43423
+ if (tests.from === "local") return { kind: "local", junit: tests.junit, command: tests.command };
43424
+ if (tests.from === "junit") {
43425
+ return {
43426
+ kind: "junit",
43427
+ junit: tests.junit,
43428
+ command: tests.command,
43429
+ record: {
43430
+ workflow: tests.workflow,
43431
+ job: tests.job,
43432
+ artifact: tests.artifact,
43433
+ path: tests.artifactPath
43434
+ }
43435
+ };
43436
+ }
43437
+ const head = headCommit(cwd);
43438
+ const repository = tests.repository ?? originRepository(cwd);
43439
+ const client = new GitHubClient({
43440
+ token: context.env.GITHUB_TOKEN,
43441
+ ...context.fetch === void 0 ? {} : { fetch: context.fetch },
43442
+ warn: context.warn
43443
+ });
43444
+ const read = await testsFromCi(client, {
43445
+ repository,
43446
+ headCommit: head,
43447
+ command: tests.command,
43448
+ pushed: isPushed(cwd, head),
43449
+ record: {
43450
+ kind: "junit",
43451
+ workflow: tests.workflow,
43452
+ job: tests.job,
43453
+ artifact: tests.artifact ?? DEFAULT_ARTIFACT,
43454
+ path: tests.artifactPath ?? DEFAULT_ARTIFACT_PATH
43455
+ }
43456
+ });
43457
+ return { kind: "ci", read };
43458
+ }
43459
+ async function runTemplate(request, context) {
43460
+ const tests = await testsSource(request, context);
43461
+ const t = templateBlock({ cwd: request.cwd, base: request.base, tests });
43462
+ return {
43463
+ fence: renderBlock(t.block),
43464
+ notes: t.notes,
43465
+ tests: tests === void 0 ? "none" : tests.kind === "ci" && tests.read.status === "unsettled" ? "not_declared" : "declared"
43466
+ };
43467
+ }
43468
+
43338
43469
  // src/mcp/server.ts
43339
43470
  var TOOL_NAME = "dunstan_check_handback";
43340
43471
  var SUGGEST_TOOL_NAME = "dunstan_suggest_declarations";
43472
+ var TEMPLATE_TOOL_NAME = "dunstan_template_handback";
43341
43473
  var InputError = class extends Error {
43342
43474
  };
43343
- var INSTRUCTIONS = `Before you say a task is done: generate your dunstan-handback block with \`dunstan template\` and add your test and reference claims; call ${SUGGEST_TOOL_NAME} with your report and declare or reword each sentence it lists; then call ${TOOL_NAME} with your pull request and the block. If the verdict is not pass, report the rows as they are; never edit the block to make it pass without changing the work. The check reads only what the block declares; the prose is yours to bring into the block.`;
43475
+ var INSTRUCTIONS = `Before you say a task is done: call ${TEMPLATE_TOOL_NAME} with the absolute path of your checkout (and tests.from "ci" once CI has finished on your last push) to generate your dunstan-handback block, or run \`dunstan template\`, the same thing from a shell; add your reference claims; call ${SUGGEST_TOOL_NAME} with your report and declare or reword each sentence it lists; then call ${TOOL_NAME} with your pull request and the block. If the verdict is not pass, report the rows as they are; never edit the block to make it pass without changing the work. The check reads only what the block declares; the prose is yours to bring into the block.`;
43344
43476
  var SUGGEST_DESCRIPTION = `List the sentences in your report's prose that look like claims your dunstan-handback block does not declare, before you say done. Give report, the report text. For each, declare it in the block, or reword it if it is not a claim about this PR. Offline: needs no pull request, reads nothing and writes nothing. Never a verdict: nothing passes or fails, and nothing is written to a record. The same text as \`dunstan suggest\`.`;
43345
43477
  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.`;
43478
+ var TEMPLATE_DESCRIPTION = `Generate your dunstan-handback block from the local git checkout of your pull request's branch, after your last push: headCommit is HEAD, filesChanged is HEAD's diff against its merge base with base. Give directory, the checkout's absolute path. To declare tests, give tests.from "ci" with command, workflow and job once CI has finished on your last push: the counts are read from the JUnit record CI published, as the gate reads it, with GITHUB_TOKEN. tests.from "local" declares a local JUnit file as a local run, which the gate reports unverifiable. Returns the first line (tests: declared, tests: not declared, or tests: none asked) and the notes, then the fence, byte for byte what \`dunstan template\` prints. When the first line is tests: not declared, the block has no tests: do not add counts yourself; do what the note says. Add your references and checks yourself. Reads git and, with tests.from "ci", GitHub metadata and the JUnit artifact for its counts; writes nothing.`;
43346
43479
  function reportOfBlock(block) {
43347
43480
  const text2 = canonicalize(block);
43348
43481
  return {
@@ -43421,8 +43554,47 @@ function handbackResult(record2, outPath, notes, context) {
43421
43554
  ]
43422
43555
  };
43423
43556
  }
43424
- function toolError(error62) {
43425
- const known = error62 instanceof InputError || error62 instanceof PullRequestUnreadable || error62 instanceof PullRequestMoved || error62 instanceof CanonicalizationError || typeof error62.code === "string";
43557
+ var TESTS_LINE = {
43558
+ declared: "tests: declared",
43559
+ not_declared: "tests: not declared",
43560
+ none: "tests: none asked"
43561
+ };
43562
+ async function templateHandback(input2, options) {
43563
+ if (!isAbsolute(input2.directory)) {
43564
+ throw new InputError(`directory ${input2.directory} is not an absolute path`);
43565
+ }
43566
+ const warnings = [];
43567
+ const run = await runTemplate(
43568
+ { cwd: input2.directory, base: input2.base, tests: input2.tests },
43569
+ {
43570
+ env: options.env,
43571
+ fetch: options.fetch,
43572
+ warn: (message) => {
43573
+ warnings.push(message);
43574
+ options.warn(`dunstan: ${message}
43575
+ `);
43576
+ }
43577
+ }
43578
+ );
43579
+ const head = [TESTS_LINE[run.tests]];
43580
+ if (run.tests === "not_declared") {
43581
+ head.push("The block below has no tests. Do not add counts yourself; do what the note says.");
43582
+ }
43583
+ head.push(...[...warnings, ...run.notes].map((n) => `note: ${n}`));
43584
+ return {
43585
+ content: [
43586
+ { type: "text", text: `${head.join("\n")}
43587
+ ` },
43588
+ { type: "text", text: run.fence }
43589
+ ]
43590
+ };
43591
+ }
43592
+ var ERROR_TAIL = {
43593
+ check: "This is an error, not a verdict: nothing was checked.",
43594
+ template: "This is an error: no block was produced."
43595
+ };
43596
+ function toolError(error62, tail = "check") {
43597
+ const known = error62 instanceof InputError || error62 instanceof PullRequestUnreadable || error62 instanceof PullRequestMoved || error62 instanceof CanonicalizationError || error62 instanceof TemplateError || typeof error62.code === "string";
43426
43598
  const message = error62.message ?? String(error62);
43427
43599
  return {
43428
43600
  isError: true,
@@ -43430,12 +43602,37 @@ function toolError(error62) {
43430
43602
  {
43431
43603
  type: "text",
43432
43604
  text: `dunstan: ${known ? message : `internal error: ${message}`}
43433
- This is an error, not a verdict: nothing was checked.
43605
+ ${ERROR_TAIL[tail]}
43434
43606
  `
43435
43607
  }
43436
43608
  ]
43437
43609
  };
43438
43610
  }
43611
+ var nonEmpty = external_exports.string().min(1);
43612
+ var TEMPLATE_TESTS = external_exports.discriminatedUnion("from", [
43613
+ external_exports.object({
43614
+ from: external_exports.literal("ci"),
43615
+ command: nonEmpty.describe("The command that ran the tests, as the block declares it."),
43616
+ workflow: nonEmpty.describe(
43617
+ "The workflow file that ran them, as a path: .github/workflows/ci.yml."
43618
+ ),
43619
+ job: nonEmpty.describe("The name of the job that uploaded the JUnit artifact."),
43620
+ artifact: nonEmpty.optional().describe("The artifact holding the JUnit file. Absent: dunstan-junit."),
43621
+ artifactPath: nonEmpty.optional().describe("The JUnit file in the artifact. Absent: junit.xml."),
43622
+ repository: external_exports.string().regex(REPOSITORY, "repository must be owner/name").optional().describe("The GitHub repository, owner/name. Absent: the one the origin remote names.")
43623
+ }).strict().describe(
43624
+ "Read CI's counts at HEAD from the JUnit record CI published, as the gate reads it (dunstan template --tests-ci)."
43625
+ ),
43626
+ external_exports.object({
43627
+ from: external_exports.literal("local"),
43628
+ junit: nonEmpty.describe(
43629
+ "The local JUnit XML file; a relative path resolves against directory."
43630
+ ),
43631
+ command: nonEmpty.describe("The command that ran the tests, as the block declares it.")
43632
+ }).strict().describe(
43633
+ "Declare a local JUnit file as a local run, which the gate reports unverifiable (dunstan template --tests-local)."
43634
+ )
43635
+ ]);
43439
43636
  function createDunstanMcpServer(options) {
43440
43637
  const server = new McpServer(
43441
43638
  { name: CHECKER_NAME, version: CHECKER_VERSION },
@@ -43489,6 +43686,37 @@ function createDunstanMcpServer(options) {
43489
43686
  },
43490
43687
  async (input2) => ({ content: [{ type: "text", text: suggestText(input2.report) }] })
43491
43688
  );
43689
+ server.registerTool(
43690
+ TEMPLATE_TOOL_NAME,
43691
+ {
43692
+ title: "Generate a handback block from git",
43693
+ description: TEMPLATE_DESCRIPTION,
43694
+ inputSchema: external_exports.object({
43695
+ directory: nonEmpty.describe(
43696
+ "The absolute path of the local git checkout of the pull request's branch. Git runs there."
43697
+ ),
43698
+ base: nonEmpty.optional().describe(
43699
+ "The ref the pull request merges into, as dunstan template --base. Absent: the remote's default branch."
43700
+ ),
43701
+ tests: TEMPLATE_TESTS.optional().describe(
43702
+ "Which tests to declare. Absent: none, and the note says how."
43703
+ )
43704
+ }).strict(),
43705
+ annotations: {
43706
+ readOnlyHint: true,
43707
+ destructiveHint: false,
43708
+ idempotentHint: true,
43709
+ openWorldHint: true
43710
+ }
43711
+ },
43712
+ async (input2) => {
43713
+ try {
43714
+ return await templateHandback(input2, options);
43715
+ } catch (error62) {
43716
+ return toolError(error62, "template");
43717
+ }
43718
+ }
43719
+ );
43492
43720
  return server;
43493
43721
  }
43494
43722
 
@@ -44033,7 +44261,7 @@ function share(r) {
44033
44261
  function interval(r) {
44034
44262
  return r.wilson95 === null ? "n/a" : `${r.wilson95.low}\u2013${r.wilson95.high}`;
44035
44263
  }
44036
- function plural2(n, one, many) {
44264
+ function plural3(n, one, many) {
44037
44265
  return `${n} ${n === 1 ? one : many}`;
44038
44266
  }
44039
44267
  function renderSurveySummary(record2) {
@@ -44057,7 +44285,7 @@ function renderSurveySummary(record2) {
44057
44285
  "",
44058
44286
  "## Claims the record could not settle",
44059
44287
  "",
44060
- `The record could not settle ${a.cannotSettle.numerator} of ${plural2(a.cannotSettle.denominator, "claim", "claims")}.`,
44288
+ `The record could not settle ${a.cannotSettle.numerator} of ${plural3(a.cannotSettle.denominator, "claim", "claims")}.`,
44061
44289
  ""
44062
44290
  );
44063
44291
  if (a.unsettled.length === 0) {
@@ -44129,7 +44357,7 @@ function renderSurveySummary(record2) {
44129
44357
  "",
44130
44358
  "## Advisory notes",
44131
44359
  "",
44132
- `The advisory reader read the prose of ${plural2(a.advisory.pullRequests, "pull request", "pull requests")}. Its notes never enter either rate, and a possible disagreement is unverified.`,
44360
+ `The advisory reader read the prose of ${plural3(a.advisory.pullRequests, "pull request", "pull requests")}. Its notes never enter either rate, and a possible disagreement is unverified.`,
44133
44361
  ""
44134
44362
  );
44135
44363
  if (a.advisory.notes.length === 0) lines.push("No notes.");
@@ -44162,7 +44390,7 @@ function renderSurveySummary(record2) {
44162
44390
  ]
44163
44391
  ),
44164
44392
  "",
44165
- `The listing was read for ${plural2(listing.pages.length, "page", "pages")} and no further because ${listing.end === "page_bound" ? `it reached the bound of ${listing.pageBound} pages` : END_TEXT[listing.end]}.`
44393
+ `The listing was read for ${plural3(listing.pages.length, "page", "pages")} and no further because ${listing.end === "page_bound" ? `it reached the bound of ${listing.pageBound} pages` : END_TEXT[listing.end]}.`
44166
44394
  );
44167
44395
  const abandoned = listing.abandoned.length;
44168
44396
  if (listing.duplicates > 0) {
@@ -44170,18 +44398,18 @@ function renderSurveySummary(record2) {
44170
44398
  const reread = abandoned === 0 ? "" : ` It was read ${abandoned + 1} times, from the first page each time, and moved during every read.`;
44171
44399
  lines.push(
44172
44400
  "",
44173
- `The listing named ${plural2(listing.duplicates, "pull request", "pull requests")} again on a later page, so it changed while it was read: most likely ${plural2(listing.duplicates, "pull request", "pull requests")} closed meanwhile and ${one ? "was" : "were"} not listed, and the selection may be missing ${one ? "it" : "them"}. Each repeated pull request is listed, selected and counted once: the pages held ${listing.pullRequests.length + listing.duplicates} items for ${listing.pullRequests.length} pull requests.${reread}`
44401
+ `The listing named ${plural3(listing.duplicates, "pull request", "pull requests")} again on a later page, so it changed while it was read: most likely ${plural3(listing.duplicates, "pull request", "pull requests")} closed meanwhile and ${one ? "was" : "were"} not listed, and the selection may be missing ${one ? "it" : "them"}. Each repeated pull request is listed, selected and counted once: the pages held ${listing.pullRequests.length + listing.duplicates} items for ${listing.pullRequests.length} pull requests.${reread}`
44174
44402
  );
44175
44403
  } else if (abandoned > 0) {
44176
44404
  lines.push(
44177
44405
  "",
44178
- `The listing moved while it was read: ${plural2(abandoned, "read was", "reads were")} abandoned at a repeated pull request and the listing was read again from the first page. The read used repeated no pull request.`
44406
+ `The listing moved while it was read: ${plural3(abandoned, "read was", "reads were")} abandoned at a repeated pull request and the listing was read again from the first page. The read used repeated no pull request.`
44179
44407
  );
44180
44408
  }
44181
44409
  if (selected.length < parameters.last) {
44182
44410
  lines.push(
44183
44411
  "",
44184
- `${plural2(selected.length, "pull request was", "pull requests were")} selected, fewer than the ${parameters.last} asked for.`
44412
+ `${plural3(selected.length, "pull request was", "pull requests were")} selected, fewer than the ${parameters.last} asked for.`
44185
44413
  );
44186
44414
  }
44187
44415
  lines.push(
@@ -44268,95 +44496,12 @@ summary: ${values.summary}
44268
44496
 
44269
44497
  // src/cli/template.ts
44270
44498
  import { parseArgs as parseArgs6 } from "node:util";
44271
-
44272
- // src/template/tests-ci.ts
44273
- var plural3 = (n, one, many) => `${n} ${n === 1 ? one : many}`;
44274
- function unsettled(reason, why, remedy) {
44275
- const notes = [`tests are not declared: ${why} (${reason}).`];
44276
- if (remedy !== void 0) notes.push(`remedy: ${remedy}`);
44277
- return { status: "unsettled", reason, notes };
44278
- }
44279
- async function testsFromCi(client, input2) {
44280
- const { repository, headCommit: head, record: record2 } = input2;
44281
- if (!client.hasToken) {
44282
- return unsettled(
44283
- "no_token",
44284
- "GITHUB_TOKEN is not set, and GitHub answers an artifact download only with a token, even on a public repository",
44285
- "set GITHUB_TOKEN to a token that can read the repository, with actions: read, and run dunstan template --tests-ci again."
44286
- );
44287
- }
44288
- if (!input2.pushed) {
44289
- return unsettled(
44290
- "not_pushed",
44291
- `HEAD ${head} is not on any remote-tracking branch, so no CI run can have tested it`,
44292
- "push it, wait for CI to finish, and run dunstan template --tests-ci again (run git fetch first if it was pushed from elsewhere)."
44293
- );
44294
- }
44295
- const observed = { runsNotCompleted: 0 };
44296
- const evidence = await readTestRecord(client, repository, record2, head, observed);
44297
- const named = `the artifact ${record2.artifact} holding ${record2.path} from job ${record2.job}`;
44298
- const pending = observed.runsNotCompleted > 0 ? `CI has not finished: ${plural3(observed.runsNotCompleted, "run", "runs")} of ${record2.workflow} at ${head} ${observed.runsNotCompleted === 1 ? "is" : "are"} not completed` : void 0;
44299
- const again = "run dunstan template --tests-ci again once CI has finished on this commit.";
44300
- if (evidence.status !== "ok") {
44301
- const reason = evidence.status === "unreadable" ? `source_unreadable:${evidence.source}` : `evidence_field_unpopulated:${evidence.field}`;
44302
- if (pending !== void 0) return unsettled(reason, pending, again);
44303
- const why = reason === "source_unreadable:artifact" ? `a run of ${record2.workflow} at ${head} uploaded ${record2.artifact}, and it could not be read` : `the workflow runs of ${record2.workflow} at ${head} could not be read`;
44304
- return unsettled(
44305
- reason,
44306
- why,
44307
- remedyForReason(reason, { hasToken: true }) ?? "run dunstan template --tests-ci again."
44308
- );
44309
- }
44310
- if (evidence.runs.length === 0) {
44311
- if (pending !== void 0) {
44312
- return unsettled(
44313
- "record_not_found",
44314
- `${pending}, and no completed run uploaded ${named}`,
44315
- again
44316
- );
44317
- }
44318
- return unsettled(
44319
- "record_not_found",
44320
- `no run of ${record2.workflow} at ${head} uploaded ${named}`,
44321
- remedyForReason("record_not_found", { hasToken: true })
44322
- );
44323
- }
44324
- const executed = new Set(evidence.runs.map((r) => r.executed));
44325
- const failed = new Set(evidence.runs.map((r) => r.failed));
44326
- if (executed.size > 1 || failed.size > 1) {
44327
- const counts = evidence.runs.map((r) => `run ${r.runId}: ${r.executed} executed, ${r.failed} failed`).join("; ");
44328
- return unsettled(
44329
- "record_ambiguous",
44330
- `the runs of ${record2.workflow} at ${head} that uploaded ${record2.artifact} disagree (${counts}), and the gate never picks one`,
44331
- "make the runs agree (re-run the one that differs), or name a job only one of them runs."
44332
- );
44333
- }
44334
- const run = evidence.runs[0];
44335
- const runs = evidence.runs.map((r) => r.runId).join(", ");
44336
- const notes = [
44337
- `tests: ${run.executed} executed and ${run.failed} failed in ${record2.path} in the artifact ${record2.artifact} of job ${record2.job} in ${record2.workflow} at ${head}, read from CI as the gate reads it (${evidence.runs.length === 1 ? "run" : "runs"} ${runs}).`
44338
- ];
44339
- if (pending !== void 0) {
44340
- notes.push(
44341
- `${pending}; a run that finishes later with other counts makes the gate's read ambiguous. Run dunstan template --tests-ci again once CI has finished.`
44342
- );
44343
- }
44344
- return {
44345
- status: "settled",
44346
- run: { command: input2.command, count: run.executed, failures: run.failed, record: record2 },
44347
- notes
44348
- };
44349
- }
44350
-
44351
- // src/cli/template.ts
44352
44499
  var RECORD_FLAGS = [
44353
44500
  "tests-workflow",
44354
44501
  "tests-job",
44355
44502
  "tests-artifact",
44356
44503
  "tests-artifact-path"
44357
44504
  ];
44358
- var DEFAULT_ARTIFACT = "dunstan-junit";
44359
- var DEFAULT_ARTIFACT_PATH = "junit.xml";
44360
44505
  async function template(args, io) {
44361
44506
  const { values } = parseArgs6({
44362
44507
  args,
@@ -44381,7 +44526,6 @@ async function template(args, io) {
44381
44526
  }
44382
44527
  if (flag("repo") !== void 0 && !ci) throw new UsageError("--repo goes with --tests-ci");
44383
44528
  const given = ["tests-command", ...RECORD_FLAGS].filter((f) => flag(f) !== void 0);
44384
- const cwd = io.cwd ?? process.cwd();
44385
44529
  let tests;
44386
44530
  if (ci) {
44387
44531
  const missing = ["tests-command", "tests-workflow", "tests-job"].filter(
@@ -44394,28 +44538,15 @@ async function template(args, io) {
44394
44538
  if (repo !== void 0 && !REPOSITORY.test(repo)) {
44395
44539
  throw new UsageError("--repo must be <owner/name>");
44396
44540
  }
44397
- const head = headCommit(cwd);
44398
- const repository = repo ?? originRepository(cwd);
44399
- const client = new GitHubClient({
44400
- token: io.env.GITHUB_TOKEN,
44401
- ...io.fetch === void 0 ? {} : { fetch: io.fetch },
44402
- warn: (message) => io.err(`dunstan: ${message}
44403
- `)
44404
- });
44405
- const read = await testsFromCi(client, {
44406
- repository,
44407
- headCommit: head,
44541
+ tests = {
44542
+ from: "ci",
44408
44543
  command: flag("tests-command"),
44409
- pushed: isPushed(cwd, head),
44410
- record: {
44411
- kind: "junit",
44412
- workflow: flag("tests-workflow"),
44413
- job: flag("tests-job"),
44414
- artifact: flag("tests-artifact") ?? DEFAULT_ARTIFACT,
44415
- path: flag("tests-artifact-path") ?? DEFAULT_ARTIFACT_PATH
44416
- }
44417
- });
44418
- tests = { kind: "ci", read };
44544
+ workflow: flag("tests-workflow"),
44545
+ job: flag("tests-job"),
44546
+ artifact: flag("tests-artifact"),
44547
+ artifactPath: flag("tests-artifact-path"),
44548
+ repository: repo
44549
+ };
44419
44550
  } else if (flag("tests-local") !== void 0) {
44420
44551
  if (flag("tests-command") === void 0) {
44421
44552
  throw new UsageError("--tests-local needs --tests-command <cmd>");
@@ -44423,7 +44554,7 @@ async function template(args, io) {
44423
44554
  const extra = RECORD_FLAGS.find((f) => flag(f) !== void 0);
44424
44555
  if (extra !== void 0) throw new UsageError(`--${extra} does not go with --tests-local`);
44425
44556
  tests = {
44426
- kind: "local",
44557
+ from: "local",
44427
44558
  junit: flag("tests-local"),
44428
44559
  command: flag("tests-command")
44429
44560
  };
@@ -44437,24 +44568,26 @@ async function template(args, io) {
44437
44568
  );
44438
44569
  }
44439
44570
  tests = {
44440
- kind: "junit",
44571
+ from: "junit",
44441
44572
  junit: flag("tests-junit"),
44442
44573
  command: flag("tests-command"),
44443
- record: {
44444
- workflow: flag("tests-workflow"),
44445
- job: flag("tests-job"),
44446
- artifact: flag("tests-artifact"),
44447
- path: flag("tests-artifact-path")
44448
- }
44574
+ workflow: flag("tests-workflow"),
44575
+ job: flag("tests-job"),
44576
+ artifact: flag("tests-artifact"),
44577
+ artifactPath: flag("tests-artifact-path")
44449
44578
  };
44450
44579
  } else if (given.length > 0) {
44451
44580
  throw new UsageError(`--${given[0]} goes with --tests-ci, --tests-local or --tests-junit`);
44452
44581
  }
44453
- const t = templateBlock({ cwd, base: flag("base"), tests });
44454
- io.out(renderBlock(t.block));
44455
- for (const note of t.notes) io.err(`dunstan: ${note}
44582
+ const run = await runTemplate(
44583
+ { cwd: io.cwd ?? process.cwd(), base: flag("base"), tests },
44584
+ { env: io.env, fetch: io.fetch, warn: (message) => io.err(`dunstan: ${message}
44585
+ `) }
44586
+ );
44587
+ io.out(run.fence);
44588
+ for (const note of run.notes) io.err(`dunstan: ${note}
44456
44589
  `);
44457
- return tests?.kind === "ci" && tests.read.status === "unsettled" ? EXIT.unverifiable : 0;
44590
+ return run.tests === "not_declared" ? EXIT.unverifiable : 0;
44458
44591
  }
44459
44592
 
44460
44593
  // src/cli/verify.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dunstan",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "mcpName": "io.github.BargLabs/dunstan",
5
5
  "description": "Holds a coding agent's completion report to the record: deterministic checks of a declared handback block against the repository's own metadata.",
6
6
  "license": "AGPL-3.0-only",