From 95ef1531463452712bcaa8fa2acf1503515c23a9 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Wed, 16 Sep 2026 19:36:30 -0400 Subject: [PATCH] history: Preserve AIC component breakdowns The collector already extracts exact agent and detection usage, but discarding those values makes canonical evidence less useful and leaves misleading null-heavy records. Retain available components, preserve partial artifact evidence during comment fallback, and omit fields that are genuinely unavailable. Assisted-by: AI --- scripts/README.md | 12 +++-- scripts/org-history.js | 92 ++++++++++++++++++++++++++++++++++----- tests/org-history.test.js | 64 +++++++++++++++++++++++++-- 3 files changed, 151 insertions(+), 17 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index eea064a..7d16e41 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -18,13 +18,17 @@ node scripts/org-history.js --previous-iso-week Schema v5 retains the v4 period field names and changes detailed `items`: it contains only items with exact evidence linking them to a relevant workflow run with known AIC. -Each record contains only `repository`, `number`, `type`, `aic`, and sorted, unique -`aicRunIds`; generic GitHub metadata is intentionally omitted. `aic` is the normalized -sum for its linked runs, including zero. A run linked exactly to multiple items contributes -its full AIC to each association, so item AIC values are not additive; use +Each record contains `repository`, `number`, `type`, `aic`, and sorted, unique +`aicRunIds`; generic GitHub metadata is intentionally omitted. When every linked run has +a known component breakdown, `agentAic` and `detectionAic` are also present as normalized +sums; otherwise both are omitted. `aic` is the normalized sum for its linked runs, +including zero. A run linked exactly to multiple items contributes its full AIC to each +association, so item AIC values are not additive; use `coverage.aic.total` for the organization-wide total. Repository aggregates still describe the full collected activity cohort, not just these AIC-linked detailed items; the linked run evidence is serialized for audit. +Unavailable properties on `workflowRuns` records, including component AIC values, are +omitted rather than serialized as `null`. Pass `--repo REPO` to collect one non-archived, non-fork repository within `ORG` instead of the full visible organization. The resulting snapshot records the repository filter. diff --git a/scripts/org-history.js b/scripts/org-history.js index d051559..a3d4298 100644 --- a/scripts/org-history.js +++ b/scripts/org-history.js @@ -11,7 +11,9 @@ const path = require('path'); const SCHEMA_VERSION = 5; const AGENT_LABELS = ['agent/code', 'agent/fixme', 'agent/lgtm']; -const DETAILED_ITEM_FIELDS = ['repository', 'number', 'type', 'aic', 'aicRunIds']; +const DETAILED_ITEM_REQUIRED_FIELDS = ['repository', 'number', 'type', 'aic', 'aicRunIds']; +const DETAILED_ITEM_COMPONENT_FIELDS = ['agentAic', 'detectionAic']; +const DETAILED_ITEM_FIELDS = [...DETAILED_ITEM_REQUIRED_FIELDS, ...DETAILED_ITEM_COMPONENT_FIELDS]; const AGGREGATE_USAGE_FILE = 'agent_usage.json'; const TOKEN_USAGE_FILE = 'agent/token_usage.jsonl'; @@ -230,6 +232,24 @@ function extractAicFromFiles(files) { function normalizeAic(value) { return Number(value.toFixed(6)); } +function sparseProperties(properties) { + const record = {}; + for (const [key, value] of Object.entries(properties)) if (value !== null && value !== undefined) record[key] = value; + return record; +} + +function artifactAicProperties(usage) { + const properties = { + aic: usage.known ? normalizeAic(usage.aic) : null, + aicArtifactKnown: usage.known, + aicSource: usage.source, + aicRecords: usage.records, + }; + if (usage.agent && usage.agent.known) properties.aicAgent = normalizeAic(usage.agent.aic); + if (usage.detection && usage.detection.known) properties.aicDetection = normalizeAic(usage.detection.aic); + return properties; +} + // Only read the machine-produced footer, not arbitrary discussion of AIC in a // comment. The optional hidden marker is emitted by newer gh-aw versions and // gives an independently labelled run identity. @@ -293,8 +313,16 @@ function applyCommentAicFallback(runs, records) { for (const run of runs) { const record = byRun.get(run.id); if (!record || run.aic !== null && run.aic !== undefined) continue; - run.aic = record.aic; run.aicSource = 'comment_footer'; run.aicRecords = 1; - run.aicAgent = record.agentAic; run.aicDetection = record.detectionAic; + const retainedArtifactComponent = finiteNonnegative(run.aicAgent) || finiteNonnegative(run.aicDetection); + if (record.detectionAic === null) { + // A footer without a detection value reports a total, not an agent allocation. + run.aic = record.aic; + } else { + if (!finiteNonnegative(run.aicAgent)) run.aicAgent = normalizeAic(record.agentAic); + if (!finiteNonnegative(run.aicDetection)) run.aicDetection = normalizeAic(record.detectionAic); + run.aic = normalizeAic(run.aicAgent + run.aicDetection); + } + run.aicSource = retainedArtifactComponent ? 'partial_artifact+comment_footer' : 'comment_footer'; run.aicRecords = 1; run.aicCommentActor = record.actor; run.aicCommentCreatedAt = record.createdAt; run.aicCommentUpdatedAt = record.updatedAt; run.aicFooterRunUrl = record.footerRunUrl; run.aicCommentUrl = record.commentUrl; run.aicCommentId = record.commentId; run.aicCommentBodySha256 = record.bodySha256; @@ -341,13 +369,19 @@ function aicLinkedItems(items, runs, commentAic, org) { } return [...links.entries()].map(([item, runIds]) => { const aicRunIds = [...runIds].sort((a, b) => a - b); - return { + const linkedRuns = aicRunIds.map((runId) => runById.get(runId)); + const detailedItem = { repository: item.repository, number: item.number, type: item.type, - aic: normalizeAic(aicRunIds.reduce((total, runId) => total + runById.get(runId).aic, 0)), + aic: normalizeAic(linkedRuns.reduce((total, run) => total + run.aic, 0)), aicRunIds, }; + if (linkedRuns.every((run) => finiteNonnegative(run.aicAgent) && finiteNonnegative(run.aicDetection))) { + detailedItem.agentAic = normalizeAic(linkedRuns.reduce((total, run) => total + run.aicAgent, 0)); + detailedItem.detectionAic = normalizeAic(linkedRuns.reduce((total, run) => total + run.aicDetection, 0)); + } + return detailedItem; }) .sort((a, b) => a.repository.localeCompare(b.repository) || a.number - b.number); } @@ -355,7 +389,9 @@ function aicLinkedItems(items, runs, commentAic, org) { function validateDetailedItems(items, workflowRuns, commentAic, org) { const identities = new Set(); for (const item of items) { - if (!item || typeof item !== 'object' || Array.isArray(item) || Object.keys(item).length !== DETAILED_ITEM_FIELDS.length || Object.keys(item).some((field) => !DETAILED_ITEM_FIELDS.includes(field))) throw new Error('Detailed item has unexpected fields'); + const fields = item && typeof item === 'object' && !Array.isArray(item) ? Object.keys(item) : []; + const hasComponents = DETAILED_ITEM_COMPONENT_FIELDS.every((field) => fields.includes(field)); + if (!item || typeof item !== 'object' || Array.isArray(item) || fields.some((field) => !DETAILED_ITEM_FIELDS.includes(field)) || DETAILED_ITEM_REQUIRED_FIELDS.some((field) => !fields.includes(field)) || DETAILED_ITEM_COMPONENT_FIELDS.some((field) => fields.includes(field)) !== hasComponents) throw new Error('Detailed item has unexpected fields'); if (typeof item.repository !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(item.repository) || !Number.isSafeInteger(item.number) || item.number <= 0 || !['issue', 'pull_request'].includes(item.type)) throw new Error('Detailed item has an invalid identity'); const identity = `${item.repository}/${item.number}`; if (identities.has(identity)) throw new Error(`Detailed item ${item.repository}#${item.number} is duplicated`); @@ -367,15 +403,25 @@ function validateDetailedItems(items, workflowRuns, commentAic, org) { const itemPath = item.type === 'pull_request' ? 'pull' : 'issues'; const canonicalItemUrl = `https://github.com/${org}/${item.repository}/${itemPath}/${item.number}`; let total = 0; + let agentTotal = 0; + let detectionTotal = 0; + let componentsKnown = true; for (const runId of item.aicRunIds) { const run = workflowRuns.find((candidate) => candidate && candidate.id === runId && candidate.repository === item.repository); if (!run || run.repository !== item.repository || !finiteNonnegative(run.aic)) throw new Error(`Detailed item ${item.repository}#${item.number} references unknown-AIC run ${runId}`); total += run.aic; + if (finiteNonnegative(run.aicAgent) && finiteNonnegative(run.aicDetection)) { + agentTotal += run.aicAgent; + detectionTotal += run.aicDetection; + } else componentsKnown = false; const commentEvidence = (commentAic || []).some((record) => record.repository === item.repository && record.runId === runId && record.itemNumber === item.number && record.itemUrl === canonicalItemUrl); const pullRequestEvidence = item.type === 'pull_request' && (run.pullRequests || []).some((reference) => reference.number === item.number && reference.url === canonicalItemUrl); if (!commentEvidence && !pullRequestEvidence) throw new Error(`Detailed item ${item.repository}#${item.number} lacks exact evidence for run ${runId}`); } if (item.aic !== normalizeAic(total)) throw new Error(`Detailed item ${item.repository}#${item.number} has an incorrect AIC total`); + if (componentsKnown) { + if (!hasComponents || !finiteNonnegative(item.agentAic) || !finiteNonnegative(item.detectionAic) || item.agentAic !== normalizeAic(agentTotal) || item.detectionAic !== normalizeAic(detectionTotal)) throw new Error(`Detailed item ${item.repository}#${item.number} has incorrect AIC components`); + } else if (hasComponents) throw new Error(`Detailed item ${item.repository}#${item.number} has incomplete AIC components`); } } @@ -387,7 +433,7 @@ function summarizeAicCoverage(runs) { if (run.aic === null || run.aic === undefined) { coverage.missingOrExpired++; continue; } coverage.runsWithValues++; if (run.aicArtifactKnown) coverage.artifactBackedRuns++; - if (run.aicSource === 'comment_footer') coverage.commentBackedRuns++; + if (typeof run.aicSource === 'string' && run.aicSource.split('+').includes('comment_footer')) coverage.commentBackedRuns++; } finalizeAicCoverage(coverage, runs); return coverage; @@ -467,6 +513,32 @@ function mergeAicCoverage(aggregate, repository) { return merged; } +function workflowRunRecord(run, org, repository) { + return sparseProperties({ + repository, + id: run.id, + url: run.html_url, + name: run.name, + path: run.path, + createdAt: run.created_at, + conclusion: run.conclusion, + kind: classifyWorkflow(run), + pullRequests: normalizedPullRequests(run, org, repository), + aic: run.aic, + aicSource: run.aicSource, + aicRecords: run.aicRecords, + aicAgent: run.aicAgent, + aicDetection: run.aicDetection, + aicCommentActor: run.aicCommentActor, + aicCommentCreatedAt: run.aicCommentCreatedAt, + aicCommentUpdatedAt: run.aicCommentUpdatedAt, + aicFooterRunUrl: run.aicFooterRunUrl, + aicCommentUrl: run.aicCommentUrl, + aicCommentId: run.aicCommentId, + aicCommentBodySha256: run.aicCommentBodySha256, + }); +} + function writeSnapshot(output, snapshot) { fs.writeFileSync(output, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); } @@ -513,7 +585,7 @@ function collect(org, interval, bot, repositoryFilter) { const relevant = runs.filter(classifyWorkflow); for (const run of relevant) { if (!aicEligible(run)) continue; - try { const usage = collectRunUsage(org, name, run); if (usage.error) repositoryErrors.push({ repository: name, operation: `artifact/${run.id}`, message: usage.error }); run.aic = usage.known ? usage.aic : null; run.aicArtifactKnown = usage.known; run.aicSource = usage.source; run.aicRecords = usage.records; } + try { const usage = collectRunUsage(org, name, run); if (usage.error) repositoryErrors.push({ repository: name, operation: `artifact/${run.id}`, message: usage.error }); Object.assign(run, artifactAicProperties(usage)); } catch (error) { run.aic = null; repositoryErrors.push({ repository: name, operation: `artifact/${run.id}`, message: error.message }); } } if (bot) { @@ -524,7 +596,7 @@ function collect(org, interval, bot, repositoryFilter) { } catch (error) { repositoryErrors.push({ repository: name, operation: 'comments', message: error.message }); } } repositoryAic = summarizeAicCoverage(relevant); - const workflowRuns = runs.map((run) => ({ repository: name, id: run.id, url: run.html_url, name: run.name, path: run.path, createdAt: run.created_at, conclusion: run.conclusion, kind: classifyWorkflow(run), pullRequests: normalizedPullRequests(run, org, name), aic: run.aic === undefined ? null : run.aic, aicSource: run.aicSource || null, aicRecords: run.aicRecords === undefined ? null : run.aicRecords, aicAgent: run.aicAgent === undefined ? null : run.aicAgent, aicDetection: run.aicDetection === undefined ? null : run.aicDetection, aicCommentActor: run.aicCommentActor || null, aicCommentCreatedAt: run.aicCommentCreatedAt || null, aicCommentUpdatedAt: run.aicCommentUpdatedAt || null, aicFooterRunUrl: run.aicFooterRunUrl || null, aicCommentUrl: run.aicCommentUrl || null, aicCommentId: run.aicCommentId === undefined ? null : run.aicCommentId, aicCommentBodySha256: run.aicCommentBodySha256 || null })).filter((run) => run.kind); + const workflowRuns = runs.map((run) => workflowRunRecord(run, org, name)).filter((run) => run.kind); snapshot.items.push(...aicLinkedItems(items, runs, snapshot.commentAic.filter((record) => record.repository === name), org)); snapshot.workflowRuns.push(...workflowRuns); snapshot.repositories.push({ name, url: repo.html_url, ...aggregateRepository(items, runs) }); @@ -578,4 +650,4 @@ function main(argv) { } if (require.main === module) { try { main(process.argv.slice(2)); } catch (error) { console.error(`org-history: ${error.message}`); process.exitCode = 1; } } -module.exports = { aicLinkedItems, deduplicateCommentAic, historyFilename, normalizedPullRequests, parseCommentAic, parsePeriod, previousCompleteIsoWeek, validateDetailedItems }; +module.exports = { aicLinkedItems, applyCommentAicFallback, artifactAicProperties, deduplicateCommentAic, historyFilename, normalizedPullRequests, parseCommentAic, parsePeriod, previousCompleteIsoWeek, summarizeAicCoverage, validateDetailedItems, workflowRunRecord }; diff --git a/tests/org-history.test.js b/tests/org-history.test.js index 14c538a..c1efded 100644 --- a/tests/org-history.test.js +++ b/tests/org-history.test.js @@ -4,7 +4,7 @@ const assert = require('node:assert/strict'); const childProcess = require('node:child_process'); const path = require('node:path'); const test = require('node:test'); -const { aicLinkedItems, deduplicateCommentAic, historyFilename, normalizedPullRequests, parseCommentAic, parsePeriod, previousCompleteIsoWeek, validateDetailedItems } = require('../scripts/org-history.js'); +const { aicLinkedItems, applyCommentAicFallback, artifactAicProperties, deduplicateCommentAic, historyFilename, normalizedPullRequests, parseCommentAic, parsePeriod, previousCompleteIsoWeek, summarizeAicCoverage, validateDetailedItems, workflowRunRecord } = require('../scripts/org-history.js'); const script = path.join(__dirname, '..', 'scripts', 'org-history.js'); @@ -59,7 +59,7 @@ test('projects compact AIC-linked items from exact known-AIC evidence', () => { { repository: 'repo', number: 2, type: 'pull_request', url: 'https://github.com/org/repo/pull/2', title: 'metadata' }, { repository: 'repo', number: 3, type: 'issue', url: 'https://github.com/org/repo/issues/3' }, ]; - const run = (id, aic, pullRequests = []) => ({ id, repository: 'repo', kind: 'drafter', aic, pull_requests: pullRequests }); + const run = (id, aic, pullRequests = [], components = {}) => ({ id, repository: 'repo', kind: 'drafter', aic, pull_requests: pullRequests, ...components }); const pull = (number, repository = 'repo') => ({ url: `https://api.github.com/repos/org/${repository}/pulls/${number}` }); const comment = (runId, itemNumber) => ({ repository: 'repo', runId, itemNumber, itemUrl: items.find((item) => item.number === itemNumber).url }); for (const scenario of [ @@ -70,6 +70,8 @@ test('projects compact AIC-linked items from exact known-AIC evidence', () => { { name: 'deduplicates evidence', runs: [run(14, 4, [pull(2), pull(2)])], comments: [comment(14, 2), comment(14, 2)], expected: [{ repository: 'repo', number: 2, type: 'pull_request', aic: 4, aicRunIds: [14] }] }, { name: 'attributes one run fully to multiple items', runs: [run(16, 5)], comments: [comment(16, 1), comment(16, 3)], expected: [{ repository: 'repo', number: 1, type: 'issue', aic: 5, aicRunIds: [16] }, { repository: 'repo', number: 3, type: 'issue', aic: 5, aicRunIds: [16] }] }, { name: 'retains zero AIC', runs: [run(15, 0)], comments: [comment(15, 1)], expected: [{ repository: 'repo', number: 1, type: 'issue', aic: 0, aicRunIds: [15] }] }, + { name: 'sums complete component breakdowns including zero', runs: [run(10, 1.25, [], { aicAgent: 1.25, aicDetection: 0 }), run(14, 2.5, [], { aicAgent: 2, aicDetection: 0.5 })], comments: [comment(10, 1), comment(14, 1)], expected: [{ repository: 'repo', number: 1, type: 'issue', aic: 3.75, aicRunIds: [10, 14], agentAic: 3.25, detectionAic: 0.5 }] }, + { name: 'omits partial component breakdowns', runs: [run(10, 1, [], { aicAgent: 1, aicDetection: 0 }), run(14, 2)], comments: [comment(10, 1), comment(14, 1)], expected: [{ repository: 'repo', number: 1, type: 'issue', aic: 3, aicRunIds: [10, 14] }] }, ]) assert.deepEqual(aicLinkedItems(items, scenario.runs, scenario.comments, 'org'), scenario.expected, scenario.name); }); @@ -92,6 +94,55 @@ test('normalizes only same-repository workflow PR references', () => { assert.deepEqual(normalizedPullRequests({ pull_requests: [{ url: 'https://api.github.com/repos/org/repo/pulls/2' }, { url: 'https://api.github.com/repos/org/repo/pulls/1' }, { url: 'https://api.github.com/repos/org/repo/pulls/2' }, { url: 'https://api.github.com/repos/org/other/pulls/3' }] }, 'org', 'repo'), [{ number: 1, url: 'https://github.com/org/repo/pull/1' }, { number: 2, url: 'https://github.com/org/repo/pull/2' }]); }); +test('serializes sparse workflow-run AIC properties without losing zero values', () => { + const record = workflowRunRecord({ + id: 1, path: 'drafter.lock.yml', aic: 0, aicRecords: 0, aicAgent: 0, + aicDetection: 0, aicCommentId: 0, aicSource: null, aicCommentUrl: undefined, + }, 'org', 'repo'); + assert.deepEqual(record, { + repository: 'repo', id: 1, path: 'drafter.lock.yml', kind: 'drafter', pullRequests: [], + aic: 0, aicRecords: 0, aicAgent: 0, aicDetection: 0, aicCommentId: 0, + }); + for (const property of ['aicSource', 'aicCommentUrl', 'aicCommentActor']) assert.equal(property in record, false); +}); + +test('propagates normalized artifact AIC components when the extraction is complete', () => { + for (const [name, usage, expected] of [ + ['known values', { known: true, aic: 1.2345678, records: 2, source: 'aggregate+detection', agent: { known: true, aic: 1.2 }, detection: { known: true, aic: 0.0345678 } }, { aic: 1.234568, aicArtifactKnown: true, aicSource: 'aggregate+detection', aicRecords: 2, aicAgent: 1.2, aicDetection: 0.034568 }], + ['zero values', { known: true, aic: 0, records: 0, source: 'aggregate_empty+detection_empty', agent: { known: true, aic: 0 }, detection: { known: true, aic: 0 } }, { aic: 0, aicArtifactKnown: true, aicSource: 'aggregate_empty+detection_empty', aicRecords: 0, aicAgent: 0, aicDetection: 0 }], + ['incomplete extraction', { known: false, aic: null, records: 0, source: 'agent_missing+detection_empty', agent: { known: false, aic: null }, detection: { known: true, aic: 0 } }, { aic: null, aicArtifactKnown: false, aicSource: 'agent_missing+detection_empty', aicRecords: 0, aicDetection: 0 }], + ]) assert.deepEqual(artifactAicProperties(usage), expected, name); +}); + +test('merges comment AIC fallback with partial artifact components', () => { + const footer = (agentAic, detectionAic, aic = detectionAic === null ? agentAic : agentAic + detectionAic) => ({ runId: 1, agentAic, detectionAic, aic, actor: 'bot', createdAt: 'created', updatedAt: 'updated', footerRunUrl: 'run', commentUrl: 'comment', commentId: 1, bodySha256: 'body' }); + for (const [name, artifact, record, expected] of [ + ['no artifact with split footer', {}, footer(1.2, 0.0345678), { aic: 1.234568, aicAgent: 1.2, aicDetection: 0.034568, aicSource: 'comment_footer' }], + ['partial detection with split footer', { aicDetection: 2 }, footer(1.2, 9, 10.2), { aic: 3.2, aicAgent: 1.2, aicDetection: 2, aicSource: 'partial_artifact+comment_footer' }], + ['partial agent with split footer', { aicAgent: 3 }, footer(1, 4), { aic: 7, aicAgent: 3, aicDetection: 4, aicSource: 'partial_artifact+comment_footer' }], + ['partial detection with total-only footer', { aicDetection: 2 }, footer(9, null), { aic: 9, aicAgent: undefined, aicDetection: 2, aicSource: 'partial_artifact+comment_footer' }], + ['no artifact with total-only footer', {}, footer(9, null), { aic: 9, aicAgent: undefined, aicDetection: undefined, aicSource: 'comment_footer' }], + ['zero component footer', {}, footer(0, 0), { aic: 0, aicAgent: 0, aicDetection: 0, aicSource: 'comment_footer' }], + ]) { + const run = { id: 1, ...artifact }; + assert.equal(applyCommentAicFallback([run], [record]), 1, name); + assert.deepEqual({ aic: run.aic, aicAgent: run.aicAgent, aicDetection: run.aicDetection, aicSource: run.aicSource, aicRecords: run.aicRecords }, { ...expected, aicRecords: 1 }, name); + assert.equal(run.aicCommentUrl, 'comment', name); + } +}); + +test('counts pure and hybrid comment-footer AIC coverage', () => { + assert.deepEqual(summarizeAicCoverage([ + { aic: 1, aicSource: 'comment_footer' }, + { aic: 2, aicSource: 'partial_artifact+comment_footer' }, + { aic: 3, aicSource: 'aggregate+detection', aicArtifactKnown: true }, + { aic: null, aicSource: 'artifacts_missing' }, + ]), { + relevantRuns: 4, eligibleRuns: 4, ineligibleRuns: 0, runsWithValues: 3, + artifactBackedRuns: 1, commentBackedRuns: 2, missingOrExpired: 1, total: null, + }); +}); + test('validates compact detailed item records and exact serialized evidence', () => { const issue = { repository: 'repo', number: 1, type: 'issue', aic: 0, aicRunIds: [1] }; const pullRequest = { repository: 'repo', number: 2, type: 'pull_request', aic: 1, aicRunIds: [2] }; @@ -100,15 +151,22 @@ test('validates compact detailed item records and exact serialized evidence', () const commentAic = [{ repository: 'repo', runId: 1, itemNumber: 1, itemUrl: issueUrl }]; const workflowRuns = [{ id: 1, repository: 'repo', aic: 0, pullRequests: [] }, { id: 2, repository: 'repo', aic: 1, pullRequests: [{ number: 2, url: pullRequestUrl }] }]; assert.doesNotThrow(() => validateDetailedItems([issue, pullRequest], workflowRuns, commentAic, 'org')); + const componentIssue = { ...issue, agentAic: 0, detectionAic: 0 }; + const componentRuns = [{ id: 1, repository: 'repo', aic: 0, aicAgent: 0, aicDetection: 0, pullRequests: [] }]; + assert.doesNotThrow(() => validateDetailedItems([componentIssue], componentRuns, commentAic, 'org')); for (const [name, items, runs, comments] of [ ['missing evidence', [issue], [{ id: 1, repository: 'repo', aic: 0, pullRequests: [] }], []], ['wrong PR evidence', [pullRequest], [{ id: 2, repository: 'repo', aic: 1, pullRequests: [{ number: 2, url: issueUrl }] }], []], ['wrong type', [{ ...issue, type: 'pull_request' }], workflowRuns, commentAic], ['cross-repository comment URL', [issue], workflowRuns, [{ ...commentAic[0], itemUrl: 'https://github.com/other/repo/issues/1' }]], ['duplicate run ID', [{ ...issue, aicRunIds: [1, 1] }], workflowRuns, commentAic], - ['unknown AIC', [{ ...issue, aicRunIds: [2] }], [{ id: 2, repository: 'repo', aic: null, pullRequests: [] }], commentAic], + ['missing AIC', [{ ...issue, aicRunIds: [2] }], [{ id: 2, repository: 'repo', pullRequests: [] }], commentAic], ['cross-repository run', [{ ...issue, aicRunIds: [3] }], [{ id: 3, repository: 'other', aic: 1, pullRequests: [] }], commentAic], ['wrong total', [{ ...pullRequest, aic: 2 }], workflowRuns, commentAic], + ['missing known components', [issue], componentRuns, commentAic], + ['incorrect agent component', [{ ...componentIssue, agentAic: 1 }], componentRuns, commentAic], + ['one component field', [{ ...issue, agentAic: 0 }], componentRuns, commentAic], + ['components for partial breakdown', [{ ...componentIssue }], workflowRuns, commentAic], ['duplicate item identity', [issue, { ...issue, type: 'pull_request' }], workflowRuns, commentAic], ['unexpected metadata', [{ ...issue, updatedAt: '2026-09-01T00:00:00Z' }], workflowRuns, commentAic], ['non-finite AIC', [{ ...issue, aic: Number.NaN }], workflowRuns, commentAic],