From 61338b8f5d0733e962ed1b7f40cc15f54a9e4247 Mon Sep 17 00:00:00 2001 From: Denys Kashkovskyi Date: Mon, 7 Sep 2026 23:28:53 +0200 Subject: [PATCH 1/2] Restrict graph contributor mutations to worker operations --- src/code_graph/sharing/control_server.ts | 6 + .../unit/code-graph.sharing-authority.test.ts | 139 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 test/unit/code-graph.sharing-authority.test.ts diff --git a/src/code_graph/sharing/control_server.ts b/src/code_graph/sharing/control_server.ts index 87925fa3..ed550530 100644 --- a/src/code_graph/sharing/control_server.ts +++ b/src/code_graph/sharing/control_server.ts @@ -178,6 +178,9 @@ const handleGraphShareHttp = ( const request = yield* HttpServerRequest.HttpServerRequest; const pathname = requestUrlPath(request.url); const method = request.method; + if (method === 'POST' && pathname === '/v1/assembly-leases') { + return HttpServerResponse.jsonUnsafe({error: 'publisher-operation-forbidden'}, {status: 403}); + } const casHex = parseGraphShareHttpCasPath(pathname); if (casHex !== undefined) { if (method === 'GET' || method === 'HEAD') return yield* serveCasBlob(options.casRoot, casHex, method === 'HEAD'); @@ -324,6 +327,9 @@ const receiveTag = Effect.fn('codeGraph.sharing.receiveTag')(function* ( request: HttpServerRequest.HttpServerRequest, ) { assertGraphShareDiscoveryTag(name); + if (!name.startsWith('tn-action-')) { + return HttpServerResponse.jsonUnsafe({error: 'publisher-operation-forbidden'}, {status: 403}); + } yield* readBoundedBody(request, GRAPH_SHARE_CONTROL_MAX_BODY_BYTES); const decoded = yield* HttpServerRequest.schemaBodyJson(GraphShareHttpTagBody, STRICT).pipe(Effect.option); if (decoded._tag === 'None') { diff --git a/test/unit/code-graph.sharing-authority.test.ts b/test/unit/code-graph.sharing-authority.test.ts new file mode 100644 index 00000000..50f1a957 --- /dev/null +++ b/test/unit/code-graph.sharing-authority.test.ts @@ -0,0 +1,139 @@ +import * as BunHttpClient from '@effect/platform-bun/BunHttpClient'; +import * as BunServices from '@effect/platform-bun/BunServices'; +import {describe, expect, it as effectIt} from '@effect/vitest'; +import {Deferred, Effect, FileSystem, Layer, Path} from 'effect'; +import {TestClock} from 'effect/testing'; +import * as FC from 'effect/testing/FastCheck'; +import * as HttpClient from 'effect/unstable/http/HttpClient'; +import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest'; +import {provideTestLayer} from '../helpers/effect-layer.js'; +import {writePrivateJsonFile} from '../../src/code_graph/sharing/atomic.js'; +import {graphShareControlGetTag, graphShareControlPutTag} from '../../src/code_graph/sharing/control_client.js'; +import {recordPublishedFrontier, runGraphShareControlServer} from '../../src/code_graph/sharing/control_server.js'; +import {sha256Digest} from '../../src/code_graph/sharing/digest.js'; +import {graphSharingLayout, graphSharingTagPath} from '../../src/code_graph/sharing/layout.js'; +import {graphShareFrontierDiscoveryTag} from '../../src/code_graph/sharing/namespace.js'; +import {SystemInfo} from '../../src/effect/system.js'; + +const sharingLayer = Layer.mergeAll(BunServices.layer, BunHttpClient.layer, SystemInfo.layer); +const hex40 = FC.array(FC.constantFrom(...'0123456789abcdef'), {minLength: 40, maxLength: 40}).map(value => + value.join(''), +); + +const startCoordinator = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const threadnoteHome = yield* fs.makeTempDirectoryScoped({prefix: 'threadnote-graph-authority-'}); + const casRoot = path.join(threadnoteHome, 'cas'); + yield* fs.makeDirectory(casRoot, {recursive: true, mode: 0o700}); + const options = {casRoot, organization: 'acme', repositoryId: 'a'.repeat(64), threadnoteHome}; + const ready = yield* Deferred.make<{readonly url: string}>(); + yield* Effect.forkScoped( + runGraphShareControlServer({ + ...options, + listen: {hostname: '127.0.0.1', port: 0}, + onListening: info => Deferred.succeed(ready, info).pipe(Effect.asVoid), + }), + ); + return {...options, ...(yield* Deferred.await(ready))}; +}); + +describe('graph contributor authority', () => { + effectIt.effect.prop( + 'contributor requests cannot create or replace publisher discovery tags', + {suffix: hex40, body: FC.string({maxLength: 128})}, + ({suffix, body}) => + TestClock.withLive( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const client = yield* HttpClient.HttpClient; + const server = yield* startCoordinator; + const original = {digest: sha256Digest('original'), schemaVersion: 1}; + for (const prefix of ['tn-frontier-', 'tn-work-']) { + const tag = `${prefix}${suffix}`; + const tagPath = graphSharingTagPath(path, server.casRoot, tag); + const validBody = JSON.stringify({digest: sha256Digest(body)}); + const attempt = (payload: string) => + client.execute( + HttpClientRequest.put(`${server.url}/v1/tags/${tag}`).pipe( + HttpClientRequest.bodyUint8Array(new TextEncoder().encode(payload), 'application/json'), + ), + ); + expect((yield* attempt(validBody)).status).toBe(403); + expect(yield* fs.exists(tagPath)).toBe(false); + yield* writePrivateJsonFile(tagPath, original); + const before = yield* fs.readFileString(tagPath); + expect((yield* attempt(validBody)).status).toBe(403); + expect((yield* attempt('{')).status).toBe(403); + expect(yield* fs.readFileString(tagPath)).toBe(before); + expect(yield* graphShareControlGetTag(server.url, tag)).toBe(original.digest); + } + }).pipe(provideTestLayer(sharingLayer)), + ), + {fastCheck: {numRuns: 20}}, + ); + + effectIt.effect('keeps internal publisher advancement and contributor action-cache writes available', () => + TestClock.withLive( + Effect.gen(function* () { + const server = yield* startCoordinator; + const branch = 'refs/heads/main'; + const tag = graphShareFrontierDiscoveryTag(server.repositoryId, branch); + const published = { + branch, + descriptorDigest: sha256Digest('descriptor-one'), + envelopeDigest: sha256Digest('envelope-one'), + generation: 1, + manifestDigest: sha256Digest('manifest-one'), + repositoryId: server.repositoryId, + sourceCommit: 'b'.repeat(40), + }; + yield* recordPublishedFrontier(server, published); + const next = {...published, descriptorDigest: sha256Digest('descriptor-two'), generation: 2}; + yield* recordPublishedFrontier(server, next); + expect(yield* graphShareControlGetTag(server.url, tag)).toBe(next.descriptorDigest); + const actionTag = `tn-action-${'c'.repeat(64)}`; + const actionDigest = sha256Digest('worker-result'); + yield* graphShareControlPutTag(server.url, actionTag, actionDigest); + expect(yield* graphShareControlGetTag(server.url, actionTag)).toBe(actionDigest); + expect(yield* graphShareControlGetTag(server.url, tag)).toBe(next.descriptorDigest); + }).pipe(provideTestLayer(sharingLayer)), + ), + ); + + effectIt.effect('denies assembly leases without changing coordinator or canonical state', () => + TestClock.withLive( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const client = yield* HttpClient.HttpClient; + const server = yield* startCoordinator; + const branch = 'refs/heads/main'; + const tag = graphShareFrontierDiscoveryTag(server.repositoryId, branch); + const published = { + branch, + descriptorDigest: sha256Digest('descriptor'), + envelopeDigest: sha256Digest('envelope'), + generation: 1, + manifestDigest: sha256Digest('manifest'), + repositoryId: server.repositoryId, + sourceCommit: 'b'.repeat(40), + }; + yield* recordPublishedFrontier(server, published); + const statePath = graphSharingLayout(path, server.threadnoteHome).coordinatorStatePath; + const before = yield* fs.readFileString(statePath); + for (const body of [JSON.stringify({batchId: 'c'.repeat(40), idempotencyKey: 'worker-lease'}), '{']) { + const response = yield* client.execute( + HttpClientRequest.post(`${server.url}/v1/assembly-leases`).pipe( + HttpClientRequest.bodyUint8Array(new TextEncoder().encode(body), 'application/json'), + ), + ); + expect(response.status).toBe(403); + } + expect(yield* fs.readFileString(statePath)).toBe(before); + expect(yield* graphShareControlGetTag(server.url, tag)).toBe(published.descriptorDigest); + }).pipe(provideTestLayer(sharingLayer)), + ), + ); +}); From 0d835a8f3c35b68c22aed558530582aa0dff46fb Mon Sep 17 00:00:00 2001 From: Denys Kashkovskyi Date: Tue, 8 Sep 2026 00:13:35 +0200 Subject: [PATCH 2/2] Expose bounded graph publisher contribution evidence --- .../sharing/publication_evidence.ts | 49 ++++ src/code_graph/sharing/publisher_cycle.ts | 33 ++- ...graph.sharing-publication-evidence.test.ts | 213 ++++++++++++++++++ ...graph.sharing-publication-evidence.test.ts | 40 ++++ 4 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 src/code_graph/sharing/publication_evidence.ts create mode 100644 test/integration/code-graph.sharing-publication-evidence.test.ts create mode 100644 test/unit/code-graph.sharing-publication-evidence.test.ts diff --git a/src/code_graph/sharing/publication_evidence.ts b/src/code_graph/sharing/publication_evidence.ts new file mode 100644 index 00000000..408b6a67 --- /dev/null +++ b/src/code_graph/sharing/publication_evidence.ts @@ -0,0 +1,49 @@ +import type {CodeGraphIndexSummary} from '../types.js'; +import type {Sha256Digest} from './digest.js'; + +const MAX_RESULT_DIGESTS = 128; + +export type GraphPublisherHydrationEvidence = + | {readonly status: 'completed'; readonly hydratedResults: number} + | {readonly status: 'failed'; readonly hydratedResults: null}; + +/** Local diagnostics only. Cache hydration and aggregate reuse do not prove per-worker attribution. */ +export interface GraphPublisherContributionEvidence { + readonly canonicalInputPolicy: 'publisher-recompute'; + readonly hydration: GraphPublisherHydrationEvidence; + readonly index: { + readonly reusedFiles: number; + readonly skippedFiles: number; + readonly snapshotId: string; + readonly totalFiles: number; + }; + readonly resultDigestsTruncated: boolean; + readonly resultManifestDigests: readonly Sha256Digest[]; + readonly selectedResults: number; + /** Receipt integrity and schema checks only; contributor identity and semantics are not authenticated. */ + readonly verifiedResults: number; +} + +export function graphPublisherContributionEvidence(input: { + readonly hydration: GraphPublisherHydrationEvidence; + readonly index: Pick & { + readonly snapshot: Pick; + }; + readonly selectedResults: number; + readonly verifiedResultDigests: readonly Sha256Digest[]; +}): GraphPublisherContributionEvidence { + return { + canonicalInputPolicy: 'publisher-recompute', + hydration: input.hydration, + index: { + reusedFiles: input.index.reusedFiles, + skippedFiles: input.index.skippedFiles, + snapshotId: input.index.snapshot.id, + totalFiles: input.index.snapshot.fileCount, + }, + resultDigestsTruncated: input.verifiedResultDigests.length > MAX_RESULT_DIGESTS, + resultManifestDigests: [...input.verifiedResultDigests].sort().slice(0, MAX_RESULT_DIGESTS), + selectedResults: input.selectedResults, + verifiedResults: input.verifiedResultDigests.length, + }; +} diff --git a/src/code_graph/sharing/publisher_cycle.ts b/src/code_graph/sharing/publisher_cycle.ts index 8d2b4cf8..cb20c4b6 100644 --- a/src/code_graph/sharing/publisher_cycle.ts +++ b/src/code_graph/sharing/publisher_cycle.ts @@ -65,6 +65,11 @@ import { type GraphShareProfileV1, } from './profile.js'; import {selectGraphShareResultsForFrozenMachine} from './receipts.js'; +import { + graphPublisherContributionEvidence, + type GraphPublisherContributionEvidence, + type GraphPublisherHydrationEvidence, +} from './publication_evidence.js'; import {resolveGraphShareCasRoot} from './trust.js'; import type {RepositoryIdentity} from '../types.js'; @@ -86,6 +91,7 @@ const FORCE_FREEZE_THRESHOLDS: GraphShareFrontierThresholds = { export interface GraphPublisherAdvanceResult { readonly checkpointDigest: Sha256Digest; + readonly contributionEvidence?: GraphPublisherContributionEvidence; readonly descriptorDigest?: Sha256Digest; readonly envelopeDigest: Sha256Digest; readonly generation: number; @@ -167,7 +173,7 @@ export const advanceGraphPublisherFrontier = Effect.fn('codeGraph.sharing.advanc return currentPointer(current, pointer, machine.phase); } const selected = selectGraphShareResultsForFrozenMachine(coordinator.receipts, machine); - const verified = []; + const verified: VerifiedGraphShareParseReceipt[] = []; for (const announcement of selected.selected) { const receipt = yield* verifyGraphShareParseReceipt({ announcement, @@ -182,11 +188,21 @@ export const advanceGraphPublisherFrontier = Effect.fn('codeGraph.sharing.advanc } verified.push(receipt.value); } - yield* hydratePublisherFacts(config, identity, verified).pipe(Effect.ignore); + const hydration: GraphPublisherHydrationEvidence = yield* hydratePublisherFacts(config, identity, verified).pipe( + Effect.match({ + onFailure: () => ({status: 'failed' as const, hydratedResults: null}), + onSuccess: result => ({status: 'completed' as const, hydratedResults: result.hydrated}), + }), + ); const published = yield* Effect.gen(function* () { const indexer = yield* CodeGraphIndexer; const store = yield* CodeGraphStore; - yield* indexer.index({cwd, ensureVectors: false, force: true, threadnoteHome: config.agentContextHome}); + const indexed = yield* indexer.index({ + cwd, + ensureVectors: false, + force: true, + threadnoteHome: config.agentContextHome, + }); const layout = codeGraphLayout(path, config.agentContextHome, identity.checkoutId, identity.worktreeId); const ready = yield* store.readySnapshot(layout.databasePath, identity.worktreeId); if ( @@ -203,7 +219,16 @@ export const advanceGraphPublisherFrontier = Effect.fn('codeGraph.sharing.advanc yield* persistMachine(coordinatorOptions, machine, options.onMachine, options.stateRef); machine = verifyGraphShareBatch(machine); yield* persistMachine(coordinatorOptions, machine, options.onMachine, options.stateRef); - return yield* exportSignedGeneration(config, options, current, identity.repositoryId, profile); + const exported = yield* exportSignedGeneration(config, options, current, identity.repositoryId, profile); + return { + ...exported, + contributionEvidence: graphPublisherContributionEvidence({ + hydration, + index: indexed, + selectedResults: selected.selected.length, + verifiedResultDigests: verified.map(item => item.announcement.resultManifestDigest), + }), + }; }).pipe( Effect.tapError(() => { machine = failGraphShareBatch(machine); diff --git a/test/integration/code-graph.sharing-publication-evidence.test.ts b/test/integration/code-graph.sharing-publication-evidence.test.ts new file mode 100644 index 00000000..76ad1179 --- /dev/null +++ b/test/integration/code-graph.sharing-publication-evidence.test.ts @@ -0,0 +1,213 @@ +import {describe, expect, it as effectIt} from '@effect/vitest'; +import {Deferred, Effect, Fiber, FileSystem, Path} from 'effect'; +import {TestClock} from 'effect/testing'; +import {provideTestLayer} from '../helpers/effect-layer.js'; +import {ApplicationLayer} from '../../src/effect/runtime.js'; +import {runCommandEffect} from '../../src/effect/command.js'; +import {CodeGraphIndexer} from '../../src/code_graph/indexer.js'; +import {CodeGraphStore} from '../../src/code_graph/store.js'; +import {CodeGraphStoreError} from '../../src/code_graph/types.js'; +import {codeGraphLayout} from '../../src/code_graph/layout.js'; +import {resolveRepositoryIdentity} from '../../src/code_graph/repository.js'; +import {runCodeGraphCheckpointExport} from '../../src/code_graph/checkpoint/commands.js'; +import {runGraphShareJoin} from '../../src/code_graph/sharing/client.js'; +import {graphShareControlGetStatus} from '../../src/code_graph/sharing/control_client.js'; +import {parseSha256Digest} from '../../src/code_graph/sharing/digest.js'; +import {verifyGraphShareParseReceipt} from '../../src/code_graph/sharing/parse_cache.js'; +import {advanceGraphPublisherFrontier} from '../../src/code_graph/sharing/publisher_cycle.js'; +import { + runGraphPublisherBootstrap, + runGraphPublisherListen, + runGraphShareInit, +} from '../../src/code_graph/sharing/publisher.js'; + +describe('publisher contribution evidence with an independent clean control', () => { + for (const failHydration of [false, true]) { + effectIt.effect( + failHydration + ? 'reports partial hydration failure while publishing a correct graph' + : 'distinguishes received facts from canonical publisher recomputation', + () => + TestClock.withLive( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const indexer = yield* CodeGraphIndexer; + const store = yield* CodeGraphStore; + const root = yield* fs.makeTempDirectoryScoped({prefix: 'threadnote-publication-evidence-'}); + const repo = path.join(root, 'publisher-repo'); + const contributor = path.join(root, 'contributor-repo'); + const home = path.join(root, 'publisher-home'); + const workerHome = path.join(root, 'worker-home'); + const controlHome = path.join(root, 'control-home'); + const cas = path.join(root, 'cas'); + const controlCas = path.join(root, 'control-cas'); + const origin = 'https://github.com/acme/publication-evidence.git'; + yield* fs.makeDirectory(path.join(repo, 'src'), {recursive: true}); + yield* fs.writeFileString(path.join(repo, 'package.json'), '{"private":true,"type":"module"}\n'); + yield* fs.writeFileString(path.join(repo, 'src/a.ts'), 'export const alpha = 1;\n'); + yield* fs.writeFileString( + path.join(repo, 'src/b.ts'), + "import {alpha} from './a.js'; export const beta = alpha + 1;\n", + ); + yield* git(repo, ['init', '-q', '--initial-branch=main']); + yield* git(repo, ['remote', 'add', 'origin', origin]); + yield* commit(repo, 'baseline'); + yield* runGraphShareInit(config(home), {cas, cwd: repo, organization: 'acme', writeConfig: true}); + yield* commit(repo, 'enroll'); + yield* indexer.index({cwd: repo, ensureVectors: false, threadnoteHome: home}); + const baseline = yield* runGraphPublisherBootstrap(config(home), {cas, cwd: repo}); + // Copy the same baseline before any contributor receipt or target-commit facts exist. + yield* fs.copy(home, controlHome); + yield* fs.copy(cas, controlCas); + yield* git(root, ['clone', '-q', repo, contributor]); + yield* git(contributor, ['remote', 'set-url', 'origin', origin]); + const ready = yield* Deferred.make(); + const listener = yield* Effect.forkScoped( + runGraphPublisherListen(config(home), { + cas, + cwd: repo, + listen: '127.0.0.1:0', + onReady: output => Deferred.succeed(ready, output.coordinatorUrl).pipe(Effect.asVoid), + }), + ); + const url = yield* Deferred.await(ready); + yield* runGraphShareJoin(config(workerHome), { + cas: path.join(root, 'worker-cas'), + coordinator: url, + cwd: contributor, + }); + // Every eligible source file changes, so old local parse facts cannot explain target reuse. + yield* fs.writeFileString( + path.join(contributor, 'package.json'), + '{"private":true,"type":"module","name":"target"}\n', + ); + yield* fs.writeFileString( + path.join(contributor, 'src/a.ts'), + 'export function alpha(value: number) { return value * 7; }\n', + ); + yield* fs.writeFileString( + path.join(contributor, 'src/b.ts'), + "import {alpha} from './a.js'; export function beta(value: number) { return alpha(value) + 3; }\n", + ); + yield* commit(contributor, 'target'); + const target = yield* resolveRepositoryIdentity(contributor); + yield* indexer.index({cwd: contributor, ensureVectors: false, threadnoteHome: workerHome}); + const status = yield* graphShareControlGetStatus(url); + const receipts = status.receipts.filter(receipt => receipt.batchId === target.headCommit); + expect(receipts).toHaveLength(3); + const identity = yield* resolveRepositoryIdentity(repo); + expect(identity.headCommit).toBe(baseline.sourceCommit); + const databasePath = codeGraphLayout(path, home, identity.checkoutId, identity.worktreeId).databasePath; + for (const announcement of receipts) { + const verified = yield* verifyGraphShareParseReceipt({ + announcement: { + ...announcement, + attestationDigest: parseSha256Digest(announcement.attestationDigest), + resultManifestDigest: parseSha256Digest(announcement.resultManifestDigest), + semanticDigest: parseSha256Digest(announcement.semanticDigest), + }, + casRoot: cas, + repositoryId: identity.repositoryId, + }); + const cached = yield* store.cachedCommittedFileKeys(databasePath, verified.parsed.extractorSet, [ + {path: verified.parsed.normalizedPath, contentHash: verified.parsed.contentHash}, + ]); + expect(cached.size).toBe(0); + } + // Stop the watch before advancing the publisher clone; publication is now exactly controlled. + yield* Fiber.interrupt(listener); + yield* git(repo, ['fetch', '-q', contributor, 'main']); + yield* git(repo, ['merge', '--ff-only', 'FETCH_HEAD']); + const control = yield* advanceGraphPublisherFrontier(config(controlHome), { + cas: controlCas, + cwd: repo, + forceFreeze: true, + }); + expect(control.published).toBe(true); + expect(control.contributionEvidence).toMatchObject({ + selectedResults: 0, + verifiedResults: 0, + hydration: {status: 'completed', hydratedResults: 0}, + index: {reusedFiles: 0, totalFiles: 3}, + }); + const publication = advanceGraphPublisherFrontier(config(home), {cas, cwd: repo, forceFreeze: true}); + let hydrationCalls = 0; + const advanced = yield* failHydration + ? publication.pipe( + Effect.provideService(CodeGraphStore, { + ...store, + cacheFacts: (...args) => + Effect.gen(function* () { + hydrationCalls += 1; + if (hydrationCalls === 2) + return yield* CodeGraphStoreError.of('synthetic hydration fault; do not expose this error'); + return yield* store.cacheFacts(...args); + }), + }), + ) + : publication; + expect(advanced.published).toBe(true); + expect(advanced.sourceCommit).toBe(target.headCommit); + expect(advanced.generation).toBe(2); + expect(advanced.contributionEvidence).toMatchObject({ + selectedResults: 3, + verifiedResults: 3, + resultDigestsTruncated: false, + hydration: failHydration + ? {status: 'failed', hydratedResults: null} + : {status: 'completed', hydratedResults: 3}, + index: {reusedFiles: 0, totalFiles: 3}, + }); + expect(advanced.contributionEvidence?.resultManifestDigests).toEqual( + receipts.map(item => item.resultManifestDigest).sort(), + ); + expect(JSON.stringify(advanced)).not.toContain('synthetic hydration fault'); + // Compare whole logical graphs, independently built from the same baseline and target Git source. + const actual = yield* runCodeGraphCheckpointExport(config(home), { + cwd: repo, + output: path.join(root, 'actual.cgcp'), + quiet: true, + }); + const clean = yield* runCodeGraphCheckpointExport(config(controlHome), { + cwd: repo, + output: path.join(root, 'clean.cgcp'), + quiet: true, + }); + expect(actual.logicalDigest).toBe(clean.logicalDigest); + const forced = yield* indexer.index({cwd: repo, force: true, ensureVectors: false, threadnoteHome: home}); + expect(forced.reusedFiles).toBe(0); + const unchanged = yield* advanceGraphPublisherFrontier(config(home), {cas, cwd: repo, forceFreeze: true}); + expect(unchanged.published).toBe(false); + expect(unchanged.contributionEvidence).toBeUndefined(); + }).pipe(provideTestLayer(ApplicationLayer)), + ), + 180_000, + ); + } +}); + +function config(home: string) { + return { + account: 'local' as const, + agentContextHome: home, + agentId: 'threadnote', + manifestPath: `${home}/seed-manifest.yaml`, + user: 'local', + }; +} +function git(repo: string, args: readonly string[]) { + return runCommandEffect('git', ['-C', repo, ...args]); +} +const commit = Effect.fn(function* (repo: string, message: string) { + yield* git(repo, ['add', '.']); + yield* git(repo, [ + '-c', + 'user.name=Threadnote Test', + '-c', + 'user.email=test@example.invalid', + 'commit', + '-qm', + message, + ]); +}); diff --git a/test/unit/code-graph.sharing-publication-evidence.test.ts b/test/unit/code-graph.sharing-publication-evidence.test.ts new file mode 100644 index 00000000..55a17b33 --- /dev/null +++ b/test/unit/code-graph.sharing-publication-evidence.test.ts @@ -0,0 +1,40 @@ +import {describe, expect, it} from 'vitest'; +import * as FC from 'effect/testing/FastCheck'; +import {sha256Digest} from '../../src/code_graph/sharing/digest.js'; +import {graphPublisherContributionEvidence} from '../../src/code_graph/sharing/publication_evidence.js'; + +describe('publisher contribution evidence', () => { + it('bounds digest evidence independently of arrival order while preserving counts and inputs', () => { + FC.assert( + FC.property( + FC.oneof( + FC.array(FC.string({maxLength: 20}), {maxLength: 128}), + FC.array(FC.string({maxLength: 20}), {minLength: 129, maxLength: 200}), + ), + values => { + const digests = values.map(sha256Digest); + const before = [...digests]; + const input = { + hydration: {status: 'completed' as const, hydratedResults: digests.length}, + index: {reusedFiles: 3, skippedFiles: 1, snapshot: {id: 'snapshot', fileCount: 5}}, + selectedResults: digests.length, + verifiedResultDigests: digests, + }; + const result = graphPublisherContributionEvidence(input); + expect(result.canonicalInputPolicy).toBe('publisher-recompute'); + expect(graphPublisherContributionEvidence({...input, verifiedResultDigests: [...digests].reverse()})).toEqual( + result, + ); + expect(digests).toEqual(before); + expect(result.resultManifestDigests.length).toBe(Math.min(128, digests.length)); + expect(result.resultDigestsTruncated).toBe(digests.length > 128); + expect(result.verifiedResults).toBe(digests.length); + expect(result.selectedResults).toBe(digests.length); + expect(result.index).toEqual({reusedFiles: 3, skippedFiles: 1, snapshotId: 'snapshot', totalFiles: 5}); + expect(result.resultManifestDigests.every(digest => digests.includes(digest))).toBe(true); + }, + ), + {numRuns: 40}, + ); + }); +});