diff --git a/scripts/workflow-registry-audit.mjs b/scripts/workflow-registry-audit.mjs index 9d51f3fc3..92e9da706 100644 --- a/scripts/workflow-registry-audit.mjs +++ b/scripts/workflow-registry-audit.mjs @@ -1,4 +1,5 @@ const REPOSITORY_WORKFLOW_PREFIX = ".github/workflows/"; +const EXPECTED_REPOSITORY = "ContextualWisdomLab/noema"; const LOWERCASE_SHA_40 = /^[0-9a-f]{40}$/; const PERCENT_ENCODING = /%[0-9a-f]{2}/i; const MAX_DIAGNOSTIC_DETAIL_LENGTH = 2048; @@ -252,6 +253,14 @@ function classifyRecord( */ export function classifyWorkflowRegistry(input) { const failures = []; + + if (input?.repository !== EXPECTED_REPOSITORY) { + failures.push({ + code: "repository_identity_invalid", + detail: `Workflow registry evidence must be bound to exact repository ${EXPECTED_REPOSITORY}.`, + }); + } + const trackedWorkflowPathsProblem = workflowPathInventoryFailure( input?.trackedWorkflowPaths, "tracked_workflow_paths_invalid", @@ -307,12 +316,19 @@ export function classifyWorkflowRegistry(input) { } const firstPathById = new Map(); + const firstIdByPath = new Map(); for (const record of workflows) { if (!Number.isSafeInteger(record?.id) || typeof record?.path !== "string") { continue; } const firstPath = firstPathById.get(record.id); - if (firstPath !== undefined && firstPath !== record.path) { + if (firstPath === record.path) { + failures.push({ + code: "workflow_record_duplicate", + workflow_id: record.id, + detail: `Workflow registry repeated id ${record.id} for path ${record.path}; duplicate records cannot prove a complete registry snapshot.`, + }); + } else if (firstPath !== undefined) { failures.push({ code: "workflow_id_reused", workflow_id: record.id, @@ -321,6 +337,17 @@ export function classifyWorkflowRegistry(input) { } else { firstPathById.set(record.id, record.path); } + + const firstId = firstIdByPath.get(record.path); + if (firstId === undefined) { + firstIdByPath.set(record.path, record.id); + } else if (firstId !== record.id) { + failures.push({ + code: "workflow_path_reused", + workflow_id: record.id, + detail: `Workflow path ${record.path} is associated with conflicting ids ${firstId} and ${record.id}.`, + }); + } } const classified = workflows.map((record) => @@ -395,6 +422,24 @@ function collectionFailure({ repository, observedAt, defaultBranchSha, error }) */ export async function collectWorkflowRegistryAudit(input) { const observedAt = input.now(); + if (input.repository !== EXPECTED_REPOSITORY) { + return { + schema_version: 1, + repository_full_name: input.repository ?? null, + default_branch_sha: null, + observed_at: observedAt, + pagination_receipts: [], + status: "FAIL", + failures: [ + { + code: "repository_identity_invalid", + detail: `Workflow registry evidence must be bound to exact repository ${EXPECTED_REPOSITORY}.`, + }, + ], + workflows: [], + }; + } + let initialBranch; let workflowPages; let activePullRequestWorkflowPaths; diff --git a/test/workflow-registry-audit.test.ts b/test/workflow-registry-audit.test.ts index f0baaf655..639b0a98f 100644 --- a/test/workflow-registry-audit.test.ts +++ b/test/workflow-registry-audit.test.ts @@ -299,7 +299,7 @@ describe("workflow registry audit", () => { ); }); - it("allows duplicate observations of the same id/path without inventing reuse", () => { + it("fails closed when the same workflow id/path is repeated", () => { const duplicate = workflow({ id: 710, path: ".github/workflows/ci.yml" }); const result = classifyWorkflowRegistry({ repository: "ContextualWisdomLab/noema", @@ -311,8 +311,13 @@ describe("workflow registry audit", () => { pagination: completePagination(2), }); - expect(result.status).toBe("PASS"); - expect(result.failures).toEqual([]); + expect(result.status).toBe("FAIL"); + expect(result.failures).toContainEqual( + expect.objectContaining({ + code: "workflow_record_duplicate", + workflow_id: 710, + }), + ); }); it("rejects branch identity that is not exact lowercase 40-hex", () => { @@ -452,4 +457,4 @@ describe("workflow registry audit", () => { NO_COLOR: "1", }); }); -}); +}); \ No newline at end of file diff --git a/test/workflow-registry-collector-repository-binding.test.ts b/test/workflow-registry-collector-repository-binding.test.ts new file mode 100644 index 000000000..5cf73650a --- /dev/null +++ b/test/workflow-registry-collector-repository-binding.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; +import { collectWorkflowRegistryAudit } from "../scripts/workflow-registry-audit.mjs"; + +describe("workflow registry collector repository binding", () => { + it.each([ + "ContextualWisdomLab/other", + "contextualwisdomlab/noema", + " ContextualWisdomLab/noema ", + null, + ])("refuses repository %j before any GitHub collection call", async (repository) => { + const resolveDefaultBranch = vi.fn(async () => ({ + sha: "1fbe857a5cf52b5af31e2db5e4676876289e3e23", + workflowPaths: [".github/workflows/ci.yml"], + })); + const listWorkflowPage = vi.fn(async () => ({ + totalCount: 0, + workflows: [], + hasNext: false, + })); + const listActivePullRequestWorkflowPaths = vi.fn(async () => []); + + const result = await collectWorkflowRegistryAudit({ + repository, + resolveDefaultBranch, + listWorkflowPage, + listActivePullRequestWorkflowPaths, + now: () => "2026-08-13T15:45:00.000Z", + }); + + expect(result).toMatchObject({ + repository_full_name: repository, + default_branch_sha: null, + status: "FAIL", + failures: [ + { + code: "repository_identity_invalid", + detail: + "Workflow registry evidence must be bound to exact repository ContextualWisdomLab/noema.", + }, + ], + workflows: [], + }); + expect(resolveDefaultBranch).not.toHaveBeenCalled(); + expect(listWorkflowPage).not.toHaveBeenCalled(); + expect(listActivePullRequestWorkflowPaths).not.toHaveBeenCalled(); + }); +}); diff --git a/test/workflow-registry-duplicate-records.test.ts b/test/workflow-registry-duplicate-records.test.ts new file mode 100644 index 000000000..b87e797c2 --- /dev/null +++ b/test/workflow-registry-duplicate-records.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { classifyWorkflowRegistry } from "../scripts/workflow-registry-audit.mjs"; + +describe("workflow registry duplicate record integrity", () => { + it("fails closed when pagination repeats the same workflow identity", () => { + const workflow = { + id: 42, + path: ".github/workflows/ci.yml", + state: "active", + }; + + const result = classifyWorkflowRegistry({ + repository: "ContextualWisdomLab/noema", + defaultBranchSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + observedAt: "2026-08-14T00:00:00.000Z", + workflows: [workflow, { ...workflow }], + trackedWorkflowPaths: [workflow.path], + activePullRequestWorkflowPaths: [], + pagination: { + totalCount: 2, + receipts: [{ page: 1, itemCount: 2, hasNext: false }], + }, + }); + + expect(result.status).toBe("FAIL"); + expect(result.failures).toContainEqual({ + code: "workflow_record_duplicate", + workflow_id: 42, + detail: + "Workflow registry repeated id 42 for path .github/workflows/ci.yml; duplicate records cannot prove a complete registry snapshot.", + }); + }); + + it("fails closed when different workflow identities claim the same repository path", () => { + const workflowPath = ".github/workflows/ci.yml"; + const result = classifyWorkflowRegistry({ + repository: "ContextualWisdomLab/noema", + defaultBranchSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + observedAt: "2026-08-14T00:00:00.000Z", + workflows: [ + { id: 42, path: workflowPath, state: "active" }, + { id: 43, path: workflowPath, state: "active" }, + ], + trackedWorkflowPaths: [workflowPath], + activePullRequestWorkflowPaths: [], + pagination: { + totalCount: 2, + receipts: [{ page: 1, itemCount: 2, hasNext: false }], + }, + }); + + expect(result.status).toBe("FAIL"); + expect(result.failures).toContainEqual({ + code: "workflow_path_reused", + workflow_id: 43, + detail: + "Workflow path .github/workflows/ci.yml is associated with conflicting ids 42 and 43.", + }); + }); +}); diff --git a/test/workflow-registry-repository-binding.test.ts b/test/workflow-registry-repository-binding.test.ts new file mode 100644 index 000000000..01033616b --- /dev/null +++ b/test/workflow-registry-repository-binding.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { classifyWorkflowRegistry } from "../scripts/workflow-registry-audit.mjs"; + +const validInput = { + defaultBranchSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + observedAt: "2026-08-13T15:00:00.000Z", + workflows: [], + trackedWorkflowPaths: [], + activePullRequestWorkflowPaths: [], + pagination: { totalCount: 0, receipts: [] }, +}; + +describe("workflow registry repository binding", () => { + it.each([ + "ContextualWisdomLab/other", + "contextualwisdomlab/noema", + " ContextualWisdomLab/noema ", + null, + ])( + "rejects evidence bound to %s", + (repository) => { + const result = classifyWorkflowRegistry({ ...validInput, repository }); + + expect(result.status).toBe("FAIL"); + expect(result.failures).toContainEqual({ + code: "repository_identity_invalid", + detail: + "Workflow registry evidence must be bound to exact repository ContextualWisdomLab/noema.", + }); + }, + ); +}); \ No newline at end of file