code-foundry 1.20.2 → 1.25.3

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 (50) hide show
  1. package/.github/CONTRIBUTING.md +5 -2
  2. package/.github/code-foundry.yml +1 -0
  3. package/.github/release-please-foundry.json +56 -0
  4. package/.github/workflows/cloudflare-delivery.yml +9 -0
  5. package/.github/workflows/cloudflare-deploy.yml +8 -1
  6. package/.github/workflows/eval.yml +116 -0
  7. package/.github/workflows/opencode-security_self-ci.yml +1 -1
  8. package/.github/workflows/qualified-foundry-publish.yml +6 -7
  9. package/.github/workflows/release.yml +58 -10
  10. package/.github/workflows/release_self-ci.yml +211 -8
  11. package/.github/workflows/validation-no-codeql.yml +34 -6
  12. package/.github/workflows/validation.yml +32 -5
  13. package/.github/workflows/validation_audit_self-ci.yml +1 -0
  14. package/.github/workflows/validation_self-ci.yml +1 -0
  15. package/.gitignore +1 -1
  16. package/AGENTS.md +5 -1
  17. package/CHANGELOG.md +95 -0
  18. package/README.md +23 -17
  19. package/docs/CONFIGURATION.md +189 -152
  20. package/docs/EVALS.md +139 -0
  21. package/docs/EXTENSIONS.md +28 -7
  22. package/docs/INITIALIZATION.md +13 -9
  23. package/docs/PERFORMANCE.md +67 -59
  24. package/docs/PUBLISHING.md +39 -14
  25. package/docs/README.md +37 -22
  26. package/docs/RELEASES.md +18 -9
  27. package/docs/WORKFLOWS.md +23 -4
  28. package/docs/agent-validation.md +6 -5
  29. package/docs/cloudflare-delivery.md +20 -8
  30. package/docs/consumer-qualification.md +11 -9
  31. package/docs/fleet-release-eligibility.md +14 -15
  32. package/docs/fleet-rollouts.md +3 -3
  33. package/docs/merge-queues.md +21 -21
  34. package/docs/product-quality.md +9 -10
  35. package/docs/qualified-publication.md +166 -93
  36. package/docs/release-integrity.md +7 -6
  37. package/docs/required-capabilities.md +24 -15
  38. package/package.json +1 -1
  39. package/src/commands/cloudflare-delivery.mjs +6 -1
  40. package/src/commands/qualified-publication.mjs +103 -12
  41. package/src/commands/release-integrity.mjs +46 -14
  42. package/src/commands/sync.mjs +30 -24
  43. package/src/lib/eval-envelope.mjs +234 -0
  44. package/src/lib/merge-queue.mjs +1 -0
  45. package/src/lib/product-quality.mjs +220 -16
  46. package/src/lib/task-policy.mjs +7 -0
  47. package/src/lib/validation-policy.mjs +10 -6
  48. package/src/runtime-core.mjs +166 -8
  49. package/src/runtime.mjs +3 -1
  50. package/src/templates/gitignore +3 -1
@@ -46,6 +46,24 @@ export function runCommand(command, args, cwd) {
46
46
  )
47
47
  return result.stdout
48
48
  }
49
+
50
+ /** Default tolerant runner: GitHub CLI failures surface as retryable misses
51
+ * instead of aborting the publication flow.
52
+ * @param {string} command
53
+ * @param {string[]} args
54
+ * @param {string} [cwd]
55
+ * @returns {{status: number, stdout: string}}
56
+ */
57
+ function defaultRunTolerant(command, args, cwd) {
58
+ const result = spawnSync(command, args, {
59
+ cwd,
60
+ encoding: 'utf8',
61
+ timeout: 180_000,
62
+ maxBuffer: 8 * 1024 * 1024,
63
+ env: { ...process.env, GH_HOST: 'github.com', GH_PROMPT_DISABLED: '1' },
64
+ })
65
+ return { status: result.error ? 1 : (result.status ?? 1), stdout: result.stdout ?? '' }
66
+ }
49
67
  /** @param {Candidate} candidate */
50
68
  export function validateCandidate(candidate) {
51
69
  ensure(
@@ -225,10 +243,50 @@ export async function publishQualifiedArchive(candidate, reports, adapters = {})
225
243
  return { ...before, status: 'published' }
226
244
  }
227
245
 
246
+ /** @typedef {(command:string, args:string[], cwd?:string) => {status: number, stdout: string}} RunTolerant */
247
+
248
+ /** Poll the releases-by-tag endpoint through its eventual-consistency window.
249
+ * Immediately after `gh release edit --draft=false` the endpoint can still
250
+ * return 404; a strict verification in that window aborts an otherwise
251
+ * healthy publication. Bounded retries keep the flow strict: it only proceeds
252
+ * once the endpoint serves the published release with the exact tag.
253
+ * @param {Candidate} candidate
254
+ * @param {{runTolerant?: RunTolerant, delay?: (ms:number)=>Promise<void>, attempts?: number}} [adapters]
255
+ * @returns {Promise<Record<string, any>>}
256
+ */
257
+ async function waitForPublishedRelease(candidate, adapters = {}) {
258
+ const runTolerant = adapters.runTolerant ?? defaultRunTolerant
259
+ const delay = adapters.delay ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
260
+ const attempts = adapters.attempts ?? 15
261
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
262
+ const result = runTolerant('gh', [
263
+ 'api',
264
+ '--hostname',
265
+ 'github.com',
266
+ `repos/${candidate.repository}/releases/tags/${encodeURIComponent(candidate.tag)}`,
267
+ ])
268
+ if (result.status === 0) {
269
+ /** @type {Record<string, any> | null} */
270
+ let release = null
271
+ try {
272
+ release = JSON.parse(result.stdout)
273
+ } catch {
274
+ release = null
275
+ }
276
+ if (release?.draft === false && release?.tag_name === candidate.tag) return release
277
+ }
278
+ if (attempt === attempts) break
279
+ await delay(2_000)
280
+ }
281
+ throw new Error(
282
+ 'The published release was not retrievable via the tag endpoint within the consistency window'
283
+ )
284
+ }
285
+
228
286
  /** Stage only a previously-created draft: never move tags, overwrite assets, or
229
287
  * treat an API/permissions failure as an absent release. Authentication needs
230
288
  * immutable-settings read and release write; no setting is mutated.
231
- * @param {Candidate} candidate @param {string[]} reports @param {{run?:Run, verify?:Verify}} [adapters]
289
+ * @param {Candidate} candidate @param {string[]} reports @param {{run?:Run, runTolerant?:RunTolerant, delay?:(ms:number)=>Promise<void>, attempts?:number, verify?:Verify}} [adapters]
232
290
  */
233
291
  export async function stageQualifiedRelease(candidate, reports, adapters = {}) {
234
292
  const run = adapters.run ?? runCommand
@@ -252,15 +310,28 @@ export async function stageQualifiedRelease(candidate, reports, adapters = {}) {
252
310
  )
253
311
  }
254
312
  checkTag()
255
- const endpoint = `${prefix}/releases/tags/${encodeURIComponent(candidate.tag)}`
256
- const release = api(endpoint)
257
- ensure(
258
- Number.isSafeInteger(release.id) &&
259
- release.draft === true &&
260
- release.tag_name === candidate.tag &&
261
- Array.isArray(release.assets),
262
- 'An existing draft release with the exact tag is required'
263
- )
313
+ const listEndpoint = `${prefix}/releases?per_page=100`
314
+ const findDraftRelease = () => {
315
+ const pages = JSON.parse(
316
+ run('gh', ['api', '--hostname', 'github.com', '--paginate', '--slurp', listEndpoint])
317
+ )
318
+ ensure(
319
+ Array.isArray(pages) && pages.every((page) => Array.isArray(page)),
320
+ 'GitHub returned an invalid release list'
321
+ )
322
+ const release = pages.flat().find((entry) => entry?.tag_name === candidate.tag)
323
+ ensure(
324
+ Number.isSafeInteger(release?.id) &&
325
+ release.draft === true &&
326
+ release.tag_name === candidate.tag &&
327
+ Array.isArray(release.assets),
328
+ 'An existing draft release with the exact tag is required'
329
+ )
330
+ return release
331
+ }
332
+ // GitHub's get-by-tag endpoint does not return draft releases. Enumerate the
333
+ // authenticated release list instead, including all pages, before any write.
334
+ const release = findDraftRelease()
264
335
  const temporary = mkdtempSync(join(tmpdir(), 'foundry-release-receipt-'))
265
336
  try {
266
337
  const receipt = join(temporary, 'qualification.json')
@@ -288,7 +359,7 @@ export async function stageQualifiedRelease(candidate, reports, adapters = {}) {
288
359
  candidate.repository,
289
360
  ])
290
361
  }
291
- const uploaded = api(endpoint)
362
+ const uploaded = findDraftRelease()
292
363
  ensure(
293
364
  uploaded.id === release.id && uploaded.draft === true && uploaded.tag_name === candidate.tag,
294
365
  'Draft changed during staging'
@@ -319,7 +390,27 @@ export async function stageQualifiedRelease(candidate, reports, adapters = {}) {
319
390
  '--repo',
320
391
  candidate.repository,
321
392
  ])
322
- const identity = await (adapters.verify ?? verifier)(candidate)
393
+ // GitHub's release index is eventually consistent right after a draft is
394
+ // published; a strict verification a second later can observe a 404 and
395
+ // abort the publication even though the release is live. Poll until the
396
+ // index serves the release, then verify; the verification cycle itself
397
+ // retries through the same window and still fails closed in the end.
398
+ await waitForPublishedRelease(candidate, {
399
+ runTolerant: adapters.runTolerant,
400
+ delay: adapters.delay,
401
+ attempts: 10,
402
+ })
403
+ /** @type {Record<string, any>} */
404
+ let identity
405
+ for (let attempt = 1; ; attempt += 1) {
406
+ try {
407
+ identity = await (adapters.verify ?? verifier)(candidate)
408
+ break
409
+ } catch (error) {
410
+ if (attempt >= 10) throw error
411
+ await (adapters.delay ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))))(5_000)
412
+ }
413
+ }
323
414
  ensure(
324
415
  identity.status === 'passed' &&
325
416
  identity.immutable === true &&
@@ -7,7 +7,7 @@ import { basename, isAbsolute, relative, resolve, sep } from 'node:path'
7
7
  import { spawnSync } from 'node:child_process'
8
8
  import { pathToFileURL } from 'node:url'
9
9
 
10
- /** @typedef {(argv: string[]) => {status: number|null, stdout: string}} Runner */
10
+ /** @typedef {(argv: string[]) => {status: number|null, stdout: string, stderr?: string}} Runner */
11
11
 
12
12
  /** @type {Runner} */
13
13
  export function runGh(argv) {
@@ -18,7 +18,7 @@ export function runGh(argv) {
18
18
  env: { ...process.env, GH_PROMPT_DISABLED: '1', GH_HOST: 'github.com' },
19
19
  })
20
20
  if (result.error) throw new Error(`GitHub CLI unavailable: ${result.error.name}`)
21
- return { status: result.status, stdout: result.stdout ?? '' }
21
+ return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }
22
22
  }
23
23
 
24
24
  /** @param {string} repository */
@@ -56,7 +56,7 @@ function json(run, argv) {
56
56
  const result = run(argv)
57
57
  if (result.status !== 0)
58
58
  throw new Error(
59
- `GitHub verification command failed (${result.status ?? 'signal'}); state is unverified`
59
+ `GitHub verification command failed (${result.status ?? 'signal'}); state is unverified: ${String(result.stderr ?? '').slice(0, 300) || 'no stderr captured'}`
60
60
  )
61
61
  try {
62
62
  return JSON.parse(result.stdout)
@@ -140,6 +140,15 @@ function tagCommit(run, repository, tag) {
140
140
  return object.sha
141
141
  }
142
142
 
143
+ /** Resolve a release tag without requiring the release itself to be published.
144
+ * @param {string} repository @param {string} tag @param {Runner} [run]
145
+ */
146
+ export function resolveTagCommit(repository, tag, run = runGh) {
147
+ validateRepository(repository)
148
+ validateTag(tag)
149
+ return tagCommit(run, repository, tag)
150
+ }
151
+
143
152
  /** GitHub CLI performs signature/attestation verification, not merely a metadata check.
144
153
  * @param {{repository: string, tag: string, root?: string, assets?: string[], expectedSha?: string}} options
145
154
  * @param {Runner} [run]
@@ -188,6 +197,17 @@ export function verifyRelease(options, run = runGh) {
188
197
  }
189
198
  }
190
199
 
200
+ /** Sync pause used by the post-publication verifier while the release index
201
+ * catches up with a just-published release.
202
+ * @param {number} ms
203
+ */
204
+ function sleepSync(ms) {
205
+ if (ms <= 0) return
206
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
207
+ }
208
+
209
+ const RELEASE_VERIFICATION_ATTEMPTS = 6
210
+
191
211
  /** @param {string[]} argv @param {Runner} [run] */
192
212
  export function integrityCommand(argv, run = runGh) {
193
213
  const [command, ...args] = argv
@@ -219,17 +239,29 @@ export function integrityCommand(argv, run = runGh) {
219
239
  throw new Error('An argument is not supported by this integrity command')
220
240
  if (command === 'settings') return verifyImmutableSetting(values['--repo'] ?? '', run)
221
241
  if (command === 'manifest') return assetManifest(values['--root'] ?? process.cwd(), assets)
222
- if (command === 'release')
223
- return verifyRelease(
224
- {
225
- repository: values['--repo'] ?? '',
226
- tag: values['--tag'] ?? '',
227
- root: values['--root'],
228
- expectedSha: values['--expected-sha'],
229
- assets,
230
- },
231
- run
232
- )
242
+ if (command === 'release') {
243
+ const options = {
244
+ repository: values['--repo'] ?? '',
245
+ tag: values['--tag'] ?? '',
246
+ root: values['--root'],
247
+ expectedSha: values['--expected-sha'],
248
+ assets,
249
+ }
250
+ // The lane fires the instant GitHub marks the release published, while the
251
+ // releases-by-tag index can still lag behind. Retry through that window;
252
+ // every strict check still runs on every attempt and the command fails
253
+ // closed once the bounded window is exhausted.
254
+ let lastError
255
+ for (let attempt = 1; attempt <= RELEASE_VERIFICATION_ATTEMPTS; attempt += 1) {
256
+ try {
257
+ return verifyRelease(options, run)
258
+ } catch (error) {
259
+ lastError = error
260
+ if (attempt < RELEASE_VERIFICATION_ATTEMPTS) sleepSync(5_000)
261
+ }
262
+ }
263
+ throw lastError
264
+ }
233
265
  throw new Error('Use release-integrity settings, release, or manifest')
234
266
  }
235
267
 
@@ -132,6 +132,10 @@ function synchronize(options) {
132
132
  if (!['direct', 'staging-release'].includes(workflow)) {
133
133
  throw new Error(`Unsupported git_workflow: ${workflow}; use direct or staging-release.`)
134
134
  }
135
+ const draftProtection = configured(config.draft_protection, 'true')
136
+ if (!['true', 'false'].includes(draftProtection)) {
137
+ throw new Error(`Unsupported draft_protection: ${draftProtection}; use true or false.`)
138
+ }
135
139
  const obsoleteConfigKeys = [
136
140
  'opencode_security',
137
141
  ...(workflow === 'direct' ? ['staging_validation_mode'] : []),
@@ -632,13 +636,33 @@ function renderWorkflow(content, config, repository, ref, rustCodeql, file, self
632
636
  release: config.release_runner ?? config.runner,
633
637
  }
634
638
  const workflow = file.match(/^\.github\/workflows\/([^/]+)\.yml$/)?.[1]
639
+ // Generated PR callers protect drafts by default. Consumers that intentionally
640
+ // run gates while a PR is still draft can opt out in code-foundry.yml.
641
+ if (configured(config.draft_protection, 'true') === 'false') {
642
+ rendered = rendered.replaceAll(' && github.event.pull_request.draft == false', '')
643
+ }
635
644
  // Consumer release callers are generic package release workflows. The
636
645
  // installed-consumer qualification harness is specific to Code Foundry's
637
646
  // own package and must remain in the self workflow rather than being
638
647
  // rendered into every consumer's release caller.
639
648
  if (workflow === 'release' && !selfRepository) {
640
- rendered = removeWorkflowBlock(rendered, 'qualification')
641
- rendered = removeWorkflowNeed(rendered, 'release', 'qualification')
649
+ // The self caller owns qualification, immutability preflight, draft
650
+ // recovery, staging, and qualified publication. Consumer callers must
651
+ // retain the ordinary reusable Release Please path instead of inheriting
652
+ // self-only jobs whose outputs and environments do not exist for them.
653
+ for (const job of ['qualification', 'preflight', 'recovery', 'stage', 'publish'])
654
+ rendered = removeWorkflowBlock(rendered, job)
655
+ rendered = rendered.replace(/^ needs: \[qualification, preflight\]\n/m, '')
656
+ rendered = rendered.replace(/^ config-file: \.github\/release-please-foundry\.json\n/m, '')
657
+ rendered = rendered.replace(/^ defer-publication: true\n/m, '')
658
+ rendered = rendered.replace(
659
+ /^ if: github\.ref == 'refs\/heads\/main' && \(vars\.CI_BILLING_PAUSED != 'true' \|\| \(github\.event_name == 'workflow_dispatch' && inputs\['release-while-paused'\] == true\)\)\n/m,
660
+ " if: vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true)\n"
661
+ )
662
+ rendered = rendered.replace(
663
+ /(\n secrets:\n CODE_FOUNDRY_TOKEN: \$\{\{ secrets\.CODE_FOUNDRY_TOKEN \}\}\n)/,
664
+ '$1 NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n'
665
+ )
642
666
  }
643
667
  // The staging-release topology validates and scans pull requests against
644
668
  // both main and the integration branch; direct repositories only ever
@@ -690,6 +714,7 @@ function renderWorkflow(content, config, repository, ref, rustCodeql, file, self
690
714
  'codeql-runner': config.codeql_runner ?? config.runner,
691
715
  'unit-runner': config.unit_runner,
692
716
  'performance-runner': config.performance_runner ?? config.test_runner ?? config.runner,
717
+ 'eval-runner': config.eval_runner ?? config.runner,
693
718
  }
694
719
  for (const [input, value] of Object.entries(runnerInputs)) {
695
720
  if (!value) continue
@@ -730,26 +755,6 @@ function removeWorkflowBlock(content, blockId) {
730
755
  return lines.join('\n')
731
756
  }
732
757
 
733
- /**
734
- * Remove a single root-job dependency while preserving the rest of the job.
735
- * @param {string} content
736
- * @param {string} jobId
737
- * @param {string} dependency
738
- * @returns {string}
739
- */
740
- function removeWorkflowNeed(content, jobId, dependency) {
741
- const lines = content.split('\n')
742
- const start = lines.findIndex((line) => line === ` ${jobId}:`)
743
- if (start === -1) return content
744
- let end = start + 1
745
- while (end < lines.length && !/^ [A-Za-z0-9_-]+:\s*$/.test(lines[end])) end += 1
746
- const need = lines.findIndex(
747
- (line, index) => index > start && index < end && line === ` needs: ${dependency}`
748
- )
749
- if (need >= 0) lines.splice(need, 1)
750
- return lines.join('\n')
751
- }
752
-
753
758
  /**
754
759
  * Dependabot updates land on the repository's integration branch. Direct
755
760
  * repositories have no staging branch, so every update targets main. Cargo,
@@ -897,8 +902,8 @@ const DIRECT_DOC_REPLACEMENTS = {
897
902
  '| Change | Target | Merge method | Merge gate |\n| ------------------------- | ------ | --------------------------------- | --------------------------------------- |\n| Working branch | `main` | Squash | All applicable required checks pass |\n| Release Please version PR | `main` | Squash (`release_merge_strategy`) | Validation gate and release policy pass |\n',
898
903
  ],
899
904
  [
900
- 'Draft pull requests do not start runner-heavy validation. The lightweight Draft Guard converts ordinary pull requests opened or reopened while ready back to draft; it never checks out pull-request code and it excludes Release Please version heads, whose release workflow owns their state. Marking a pull request ready for review starts the applicable validation tier, and each new commit on a ready pull request reruns that tier for the current head. Draft updates allocate no validation runner. Converting a pull request to draft cancels in-flight validation through the lightweight cancellation control.',
901
- 'Draft pull requests do not start validation. The lightweight Draft Guard converts ordinary pull requests opened or reopened while ready back to draft; it never checks out pull-request code and it excludes Release Please version heads, whose release workflow owns their state. Marking a pull request ready for review starts the applicable validation tier, and each new commit on a ready pull request reruns that tier for the current head. Draft updates allocate no validation runner. Converting a pull request to draft runs only the lightweight cancellation control.',
905
+ 'Draft pull requests do not start runner-heavy validation unless `draft_protection: false` is configured for generated callers. The lightweight Draft Guard converts ordinary pull requests opened or reopened while ready back to draft; it never checks out pull-request code and it excludes Release Please version heads, whose release workflow owns their state. Marking a pull request ready for review starts the applicable validation tier, and each new commit on a ready pull request reruns that tier for the current head. Draft updates allocate no validation runner while protection is enabled. Converting a pull request to draft cancels in-flight validation through the lightweight cancellation control.',
906
+ 'Draft pull requests do not start validation unless `draft_protection: false` is configured. The lightweight Draft Guard converts ordinary pull requests opened or reopened while ready back to draft; it never checks out pull-request code and it excludes Release Please version heads, whose release workflow owns their state. Marking a pull request ready for review starts the applicable validation tier, and each new commit on a ready pull request reruns that tier for the current head. Draft updates allocate no validation runner while protection is enabled. Converting a pull request to draft runs only the lightweight cancellation control.',
902
907
  ],
903
908
  ['1. Create a focused branch from `staging`.', '1. Create a focused branch from `main`.'],
904
909
  ],
@@ -1370,6 +1375,7 @@ function createDefaultConfig(root, source, configuredWorkflow) {
1370
1375
  toolchain: 'auto',
1371
1376
  ...(stagingRelease ? { staging_validation_mode: 'fast' } : {}),
1372
1377
  performance: 'auto',
1378
+ draft_protection: 'true',
1373
1379
  performance_command: '',
1374
1380
  performance_profile: '',
1375
1381
  performance_budget_file: 'performance-package-budgets.json',
@@ -0,0 +1,234 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * The shared eval-report contract. Every `ci eval` run validates the consumer
5
+ * repository's eval report against this envelope before applying budgets.
6
+ * Deterministic probes (Layer 1) and model-agent runs (Layer 2) emit the same
7
+ * envelope, so task outcomes stay comparable across executors and revisions.
8
+ * See docs/EVALS.md for the full field reference.
9
+ */
10
+
11
+ export const EVAL_REPORT_FILE = 'eval-results/result.json'
12
+ export const EVAL_SUMMARY_FILE = 'eval-results/summary.json'
13
+ export const EVAL_BUDGET_FILE_DEFAULT = 'eval-budgets.json'
14
+ export const EVAL_REPORT_SCHEMA_VERSION = 1
15
+ export const EVAL_SUMMARY_KIND = 'code-foundry-eval-summary'
16
+
17
+ const FAILURE_CLASSES = ['harness', 'task']
18
+ const STAT_FIELDS = ['mean', 'p50', 'p95', 'max']
19
+
20
+ /** @param {unknown} value @returns {value is number} */
21
+ function isNonNegativeInteger(value) {
22
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
23
+ }
24
+
25
+ /** @param {unknown} value @returns {value is number} */
26
+ function isFiniteNumber(value) {
27
+ return typeof value === 'number' && Number.isFinite(value)
28
+ }
29
+
30
+ /** @param {unknown} value @returns {boolean} */
31
+ function isStats(value) {
32
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
33
+ const record = /** @type {Record<string, unknown>} */ (value)
34
+ if (!isNonNegativeInteger(record.count)) return false
35
+ if (record.count === 0) {
36
+ return (
37
+ Object.keys(record).length === 1 || STAT_FIELDS.every((field) => record[field] === undefined)
38
+ )
39
+ }
40
+ return STAT_FIELDS.every((field) => isFiniteNumber(record[field]))
41
+ }
42
+
43
+ /**
44
+ * Validate one eval report envelope. Returns every violation instead of
45
+ * throwing, so one broken report lists all of its problems at once.
46
+ * @param {unknown} report @returns {{valid: boolean, errors: string[]}}
47
+ */
48
+ export function validateEvalReport(report) {
49
+ /** @type {string[]} */
50
+ const errors = []
51
+ /** @param {string} message */
52
+ const push = (message) => errors.push(message)
53
+ if (!report || typeof report !== 'object' || Array.isArray(report)) {
54
+ return { valid: false, errors: ['report must be a JSON object'] }
55
+ }
56
+ const value = /** @type {Record<string, unknown>} */ (report)
57
+ if (value.schemaVersion !== EVAL_REPORT_SCHEMA_VERSION)
58
+ push(`schemaVersion: expected ${EVAL_REPORT_SCHEMA_VERSION}`)
59
+ if (value.revision !== undefined && typeof value.revision !== 'string')
60
+ push('revision: must be a string when present')
61
+ if (value.dependencyHash !== undefined && typeof value.dependencyHash !== 'string')
62
+ push('dependencyHash: must be a string when present')
63
+
64
+ const summary = value.summary
65
+ if (!summary || typeof summary !== 'object' || Array.isArray(summary)) {
66
+ push('summary: must be an object')
67
+ return { valid: errors.length === 0, errors }
68
+ }
69
+ const counts = /** @type {Record<string, unknown>} */ (summary)
70
+ for (const field of [
71
+ 'taskCount',
72
+ 'attempts',
73
+ 'passed',
74
+ 'failed',
75
+ 'harnessFailures',
76
+ 'toolCalls',
77
+ 'evidenceErrors',
78
+ ]) {
79
+ if (!isNonNegativeInteger(counts[field]))
80
+ push(`summary.${field}: must be a non-negative integer`)
81
+ }
82
+ if (
83
+ isNonNegativeInteger(counts.passed) &&
84
+ isNonNegativeInteger(counts.failed) &&
85
+ isNonNegativeInteger(counts.attempts) &&
86
+ counts.passed + counts.failed > counts.attempts
87
+ )
88
+ push('summary.passed/failed: must not exceed attempts')
89
+ if (
90
+ isNonNegativeInteger(counts.harnessFailures) &&
91
+ isNonNegativeInteger(counts.attempts) &&
92
+ counts.harnessFailures > counts.attempts
93
+ )
94
+ push('summary.harnessFailures: must not exceed attempts')
95
+ if (isFiniteNumber(counts.successRate) && (counts.successRate < 0 || counts.successRate > 1))
96
+ push('summary.successRate: must be between 0 and 1')
97
+ for (const field of ['taskDurationMs', 'startupMs', 'stepDurationMs']) {
98
+ if (!isStats(counts[field])) push(`summary.${field}: must be a stats object`)
99
+ }
100
+
101
+ const tasks = value.tasks
102
+ if (!Array.isArray(tasks)) {
103
+ push('tasks: must be an array')
104
+ return { valid: errors.length === 0, errors }
105
+ }
106
+ for (const [index, task] of tasks.entries()) {
107
+ if (!task || typeof task !== 'object' || Array.isArray(task)) {
108
+ push(`tasks[${index}]: must be an object`)
109
+ continue
110
+ }
111
+ const record = /** @type {Record<string, unknown>} */ (task)
112
+ if (typeof record.id !== 'string' || record.id.trim().length === 0)
113
+ push(`tasks[${index}].id: must be a non-empty string`)
114
+ if (!Array.isArray(record.attempts)) {
115
+ push(`tasks[${index}].attempts: must be an array`)
116
+ continue
117
+ }
118
+ for (const [attemptIndex, attempt] of record.attempts.entries()) {
119
+ const path = `tasks[${index}].attempts[${attemptIndex}]`
120
+ if (!attempt || typeof attempt !== 'object') {
121
+ push(`${path}: must be an object`)
122
+ continue
123
+ }
124
+ const entry = /** @type {Record<string, unknown>} */ (attempt)
125
+ if (entry.status !== 'passed' && entry.status !== 'failed')
126
+ push(`${path}.status: must be passed or failed`)
127
+ if (entry.durationMs !== undefined && !isFiniteNumber(entry.durationMs))
128
+ push(`${path}.durationMs: must be a finite number`)
129
+ if (entry.startupMs !== undefined && !isFiniteNumber(entry.startupMs))
130
+ push(`${path}.startupMs: must be a finite number`)
131
+ if (entry.failure) {
132
+ const failure = entry.failure
133
+ if (!failure || typeof failure !== 'object') push(`${path}.failure: must be an object`)
134
+ else if (
135
+ typeof (/** @type {Record<string, unknown>} */ (failure).message) !== 'string' ||
136
+ /** @type {Record<string, unknown>} */ (failure).message === ''
137
+ )
138
+ push(`${path}.failure.message: must be a non-empty string`)
139
+ if (!FAILURE_CLASSES.includes(/** @type {string} */ (entry.failureClass)))
140
+ push(`${path}.failureClass: must be harness or task when a failure is present`)
141
+ } else if (entry.failureClass !== undefined) {
142
+ if (!FAILURE_CLASSES.includes(/** @type {string} */ (entry.failureClass)))
143
+ push(`${path}.failureClass: must be harness or task when present`)
144
+ }
145
+ if (Array.isArray(entry.steps)) {
146
+ for (const [stepIndex, stepEntry] of entry.steps.entries()) {
147
+ const stepPath = `${path}.steps[${stepIndex}]`
148
+ if (!stepEntry || typeof stepEntry !== 'object') {
149
+ push(`${stepPath}: must be an object`)
150
+ continue
151
+ }
152
+ const step = /** @type {Record<string, unknown>} */ (stepEntry)
153
+ if (typeof step.tool !== 'string' || step.tool.trim().length === 0)
154
+ push(`${stepPath}.tool: must be a non-empty string`)
155
+ if (!['running', 'passed', 'failed'].includes(/** @type {string} */ (step.status)))
156
+ push(`${stepPath}.status: must be running, passed, or failed`)
157
+ if (step.durationMs !== undefined && !isFiniteNumber(step.durationMs))
158
+ push(`${stepPath}.durationMs: must be a finite number`)
159
+ }
160
+ }
161
+ }
162
+ }
163
+ return { valid: errors.length === 0, errors }
164
+ }
165
+
166
+ /**
167
+ * Evaluate the configured budget thresholds against one validated report.
168
+ * Unknown budget keys fail closed so a typo can never silently disable a gate.
169
+ * @param {{summary?: Record<string, unknown>}} report
170
+ * @param {Record<string, unknown>} budgets
171
+ * @returns {{passed: boolean, failures: string[]}}
172
+ */
173
+ export function evaluateEvalBudgets(report, budgets) {
174
+ if (!report || typeof report !== 'object' || Array.isArray(report))
175
+ return { passed: false, failures: ['report must be a validated eval report object'] }
176
+ const failures = []
177
+ if (!budgets || typeof budgets !== 'object' || Array.isArray(budgets)) {
178
+ return { passed: false, failures: ['budgets must be a JSON object'] }
179
+ }
180
+ const known = new Set([
181
+ 'successRate',
182
+ 'taskP95Ms',
183
+ 'startupP95Ms',
184
+ 'stepP95Ms',
185
+ 'maxHarnessFailures',
186
+ 'maxEvidenceErrors',
187
+ 'maxToolCalls',
188
+ ])
189
+ for (const key of Object.keys(budgets)) {
190
+ if (!known.has(key)) failures.push(`budgets.${key}: unknown budget key`)
191
+ }
192
+ if (failures.length > 0) return { passed: false, failures }
193
+ /** @type {Record<string, unknown>} */
194
+ const summary = report.summary ?? {}
195
+ const rate = budgets.successRate
196
+ if (rate !== undefined) {
197
+ if (!isFiniteNumber(rate) || rate < 0 || rate > 1)
198
+ return { passed: false, failures: ['budgets.successRate: must be between 0 and 1'] }
199
+ if (isFiniteNumber(summary.successRate) && summary.successRate < rate)
200
+ failures.push(`successRate ${summary.successRate.toFixed(2)} is below budget ${rate}`)
201
+ }
202
+ const p95Fields = /** @type {const} */ ([
203
+ ['taskP95Ms', 'taskDurationMs'],
204
+ ['startupP95Ms', 'startupMs'],
205
+ ['stepP95Ms', 'stepDurationMs'],
206
+ ])
207
+ for (const [budgetField, summaryField] of p95Fields) {
208
+ const budget = /** @type {unknown} */ (budgets[budgetField])
209
+ if (budget === undefined) continue
210
+ if (!isFiniteNumber(budget) || budget <= 0)
211
+ return { passed: false, failures: [`budgets.${budgetField}: must be a positive number`] }
212
+ const stats = summary[summaryField]
213
+ if (!stats || typeof stats !== 'object') continue
214
+ const p95 = /** @type {unknown} */ (/** @type {Record<string, unknown>} */ (stats).p95)
215
+ const count = /** @type {Record<string, unknown>} */ (stats).count
216
+ if (!isFiniteNumber(p95) || count === 0) continue
217
+ if (p95 > budget) failures.push(`${summaryField}.p95 ${p95}ms is above budget ${budget}ms`)
218
+ }
219
+ const maxFields = /** @type {const} */ ([
220
+ ['maxHarnessFailures', 'harnessFailures'],
221
+ ['maxEvidenceErrors', 'evidenceErrors'],
222
+ ['maxToolCalls', 'toolCalls'],
223
+ ])
224
+ for (const [budgetField, summaryField] of maxFields) {
225
+ const budget = /** @type {unknown} */ (budgets[budgetField])
226
+ if (budget === undefined) continue
227
+ if (!isNonNegativeInteger(budget))
228
+ return { passed: false, failures: [`budgets.${budgetField}: must be a non-negative integer`] }
229
+ const measured = /** @type {unknown} */ (summary[summaryField])
230
+ if (isNonNegativeInteger(measured) && measured > budget)
231
+ failures.push(`${summaryField} ${measured} is above budget ${budget}`)
232
+ }
233
+ return { passed: failures.length === 0, failures }
234
+ }
@@ -153,6 +153,7 @@ jobs:
153
153
  unit-runner: ${runner('unit_runner', 'ubuntu-slim')}
154
154
  performance-runner: ${runner('performance_runner')}
155
155
  security-runner: ${runner('security_runner', 'ubuntu-slim')}
156
+ eval-runner: ${runner('eval_runner')}
156
157
  codeql-runner: ${runner('codeql_runner')}
157
158
  rust-shards: '${JSON.stringify(shards)}'
158
159
  rust-threads: '${threads}'