From ca6171a37b69e331ab90dfbecc5327e89c8d63e3 Mon Sep 17 00:00:00 2001 From: Art Pai Date: Fri, 11 Sep 2026 22:17:14 +1000 Subject: [PATCH] Attribute reports to an explainer and publisher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A version 1 ExplainDocument now accepts an optional, strictly validated metadata object: explainedBy is authored in explanations.yaml, publish adds publishedBy from git config user.name, and the review service stamps its own publishedAt. Local views and exports show only the author, hosted reports show all three, and absent values are omitted without blocking any path. Co-Authored-By: ことね --- README.md | 18 +++++-- docs/architecture.md | 22 ++++++--- skills/diffwalk/SKILL.md | 8 +++- src/authoring/capture.ts | 1 + src/authoring/git.ts | 11 +++++ src/cli/commands/publish.ts | 10 ++-- src/format.ts | 16 +++++++ src/publish.ts | 10 ++++ src/report/shell.ts | 44 +++++++++++++++-- test/authoring.test.ts | 14 ++++++ test/cli.test.ts | 96 +++++++++++++++++++++++++++++++++++++ test/explanations.test.ts | 24 ++++++++++ test/format.test.ts | 85 ++++++++++++++++++++++++++++++++ test/git.test.ts | 44 ++++++++++++++++- test/publish.test.ts | 27 ++++++++++- test/report.test.ts | 91 ++++++++++++++++++++++++++++++++++- worker/index.test.ts | 84 ++++++++++++++++++++++++++++++-- worker/index.ts | 15 +++++- 18 files changed, 591 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 50e2e3d..04ab4e0 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,9 @@ summary: | Optional opening, shown above the review map.
...
+ +metadata: + explainedBy: Claude Code sections: - title: Keep the greeting concise steps: @@ -114,7 +117,8 @@ sections: ``` `title` is required: it becomes the review heading and the browser tab, which is how two -shared links tell themselves apart. `summary` is optional. +shared links tell themselves apart. `summary` is optional. `metadata.explainedBy` is an +optional, self-reported author shown in every local view and export. Every change must be shown at least once. Showing one in more than one step is allowed and reported, because re-showing a hunk is how an argument gets built. Validate, then @@ -216,6 +220,12 @@ Publishing is anonymous and unlisted, not private. The link cannot be guessed, b anyone holding it can read the review without signing in. Treat the link as the secret, and do not publish a document you would not hand to everyone who might receive it. +A hosted report can show attribution: `explainedBy` from the explanations, `publishedBy` +from `git config user.name` when one is configured, and a `publishedAt` timestamp the +service stamps when it accepts the upload. These names are self-reported attribution, not +verified identity. Publishing adds them to the uploaded document only; it never rewrites +`explanations.yaml` or `capture.json`, and a missing value is simply omitted. + Publishing retains the review ID, link, service, and revocation token in the current walk's `published.json` (or next to an explicit `--input`/`--explanations` pair). The file stays local and is never uploaded, but it holds the revocation token, so keep it @@ -261,8 +271,10 @@ the CLI at another deployment with `--service` or `DIFFWALK_SERVICE_URL`. `diffwalk export json` materializes capture plus explanations and writes `diffwalk.json` inside the current walk by default. It is the portable ExplainDocument JSON (format -version 1) for integrations or archiving. View, HTML export, and publish do not require -it; they validate and materialize directly from the authoring files. +version 1) for integrations or archiving. An authored `metadata.explainedBy` is +preserved; a local export never claims a publisher or publication time. View, HTML +export, and publish do not require it; they validate and materialize directly from the +authoring files. ## Captured data sensitivity diff --git a/docs/architecture.md b/docs/architecture.md index 531f348..9c3b74d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,7 +11,8 @@ exact corresponding diffs in a deliberate order. never contains authored sections. `explanations.yaml` is the only author-edited file: it names the `captureId` it was authored against, carries a required `title` and an optional `summary`, and holds ordered sections of `{ title, steps[] }` where a step is - `{ text?, changes[]? }` with at least one of the two. + `{ text?, changes[]? }` with at least one of the two. An optional + `metadata.explainedBy` records who wrote the walkthrough. - `captureId` identifies captured code contents, not the capture timestamp. It is a SHA-256 over a canonical serialization of the captured file snapshots (status, path, old path, old/new modes, old content, new content), so identical captures pair @@ -37,6 +38,13 @@ exact corresponding diffs in a deliberate order. builds an argument, so `check` names the repeats and still succeeds; only an unexplained change fails. Completeness is the guarantee a reader relies on, not uniqueness. +- A version 1 ExplainDocument may carry an optional, strict top-level `metadata` object. + `explainedBy` is authored in `explanations.yaml` and survives materialization; + `publishedBy` is added at publish time from `git config user.name`; `publishedAt` is + stamped by the review service when it accepts the upload. The names are self-reported + attribution, not verified identity. Local views and exports show only `explainedBy`; + the hosted report shows all three. Absent values are omitted and never block viewing, + exporting, or publishing, and unknown metadata keys are rejected. - `check`, `view`, `export`, and `publish` read capture plus explanations, validate the pairing and assignments, and materialize exact patches in memory. No `document.json` is required at runtime; `export json` writes the portable ExplainDocument (format @@ -93,7 +101,7 @@ exact corresponding diffs in a deliberate order. ## Source map - `src/format.ts`: Zod schemas for the machine-owned capture and the author-edited - explanations, plus the version 1 ExplainDocument. + explanations, plus the version 1 ExplainDocument and its optional attribution metadata. - `src/authoring/git.ts`: captures staged, unstaged, deleted, renamed, and untracked UTF-8 files from an immutable Git base commit, optionally reading the index or limiting the capture to named paths. @@ -115,8 +123,9 @@ exact corresponding diffs in a deliberate order. - `src/report.ts`: atomic report writes and client-bundle loading. - `src/report/shell.ts`: the one report shell, embedded-data escaping, and shell styles, rendered with inlined assets for the offline file or linked assets for the hosted page. -- `src/publish.ts`: review service origin checks, publish credential lookup, and the - publish, update, and unpublish requests. +- `src/publish.ts`: review service origin checks, publish credential lookup, the + publish, update, and unpublish requests, and adding the Git user name as + `metadata.publishedBy` without mutating the authoring files. - `src/report/client.ts`: browser entry that mounts a `FileDiff` per file and switches unified/split through `setOptions`. - `test/*.test.ts`: focused tests for schemas, capture identity, strict YAML parsing, @@ -127,8 +136,9 @@ exact corresponding diffs in a deliberate order. inspect file behavior (including preservation of authored explanations and stale pairing), inspection commands, validation, HTML/JSON exports, and rejection of the removed `build`, `report`, and draft workflows. -- `worker/index.ts`: the Cloudflare Worker that stores, renders, and revokes reports and - sets the review origin's Content Security Policy, security headers, and caching. +- `worker/index.ts`: the Cloudflare Worker that stores, renders, and revokes reports, + stamps `metadata.publishedAt` when it accepts an upload, and sets the review origin's + Content Security Policy, security headers, and caching. - `worker/reports.ts`: report IDs, revocation tokens, token digests, constant-time secret comparison, and the bounded document size. - `worker/build-assets.ts`: writes the shared stylesheet and client bundle into the Static diff --git a/skills/diffwalk/SKILL.md b/skills/diffwalk/SKILL.md index b7516ac..3afea50 100644 --- a/skills/diffwalk/SKILL.md +++ b/skills/diffwalk/SKILL.md @@ -39,6 +39,8 @@ explained. title: What this whole change set does summary: | Optional opening for someone deciding whether to read. + metadata: + explainedBy: Claude Code sections: - title: A concise change title steps: @@ -53,7 +55,11 @@ explained. ``` `title` is required: it becomes the review heading and the browser tab, which is how - two shared links tell themselves apart. + two shared links tell themselves apart. `metadata.explainedBy` optionally names the + person or agent who wrote the walkthrough. It is self-reported attribution, not a + verified identity, and it shows in local views and exports. Publishing fills + `metadata.publishedBy` from `git config user.name` and the service stamps + `metadata.publishedAt`; do not write either one in `explanations.yaml`. A step carries `text`, `changes`, or both, so prose and diffs interleave in the order you write them. Prefer several short steps over one long one: the point of a step is diff --git a/src/authoring/capture.ts b/src/authoring/capture.ts index 48bd0f8..99fd2ec 100644 --- a/src/authoring/capture.ts +++ b/src/authoring/capture.ts @@ -194,6 +194,7 @@ export function materializeExplainDocument( title: explanations.title, summary: explanations.summary, source: capture.source, + ...(explanations.metadata === undefined ? {} : { metadata: explanations.metadata }), sections, }) } diff --git a/src/authoring/git.ts b/src/authoring/git.ts index 6673843..af9597d 100644 --- a/src/authoring/git.ts +++ b/src/authoring/git.ts @@ -202,6 +202,17 @@ export async function commitForRevision(revision: string, root = process.cwd()): return (await gitText(['rev-parse', '--verify', '--end-of-options', `${revision}^{commit}`], root)).trim() } +// Reflects whatever Git would stamp on the next commit. A missing or empty name is the +// only reason to omit it, so every other failure also means "unavailable". +export async function gitUserName(root = process.cwd()): Promise { + try { + const name = (await gitText(['config', 'user.name'], root)).trim() + return name === '' ? undefined : name + } catch { + return undefined + } +} + async function workingTreeFile(path: string, root: string): Promise { const absolutePath = resolve(root, path) const pathWithinRoot = relative(root, absolutePath) diff --git a/src/cli/commands/publish.ts b/src/cli/commands/publish.ts index 6e0daa0..f01ae36 100644 --- a/src/cli/commands/publish.ts +++ b/src/cli/commands/publish.ts @@ -1,7 +1,8 @@ import { z } from 'zod' +import { gitUserName } from '../../authoring/git' import { authoringOptionsSchema, materialize } from '../../authoring/input' import { readPublishedReview, writePublishedReview } from '../../authoring/published' -import { publishDocument, reportService, updateDocument } from '../../publish' +import { publishDocument, reportService, updateDocument, withPublisher } from '../../publish' import type { ExplainDocument } from '../../format' import { UsageError } from '../usage' @@ -13,16 +14,17 @@ type PublishOptions = z.infer export async function publishCommand(options: PublishOptions): Promise { const { document, paths } = await materialize(options) + const outgoing = withPublisher(document, await gitUserName()) if (options.update === true) { - await updatePublishedReview(document, paths.published, options.service) + await updatePublishedReview(outgoing, paths.published, options.service) return } const service = reportService(options.service) - const published = await publishDocument(document, service) + const published = await publishDocument(outgoing, service) // The token is shown before the retention write so a failed write can never leave a // live review whose only credential was never surfaced. - console.log(`Published ${document.sections.length} explanation sections to ${published.url}`) + console.log(`Published ${outgoing.sections.length} explanation sections to ${published.url}`) console.log(`Revocation token: ${published.revocationToken}`) await writePublishedReview(paths.published, { id: published.id, diff --git a/src/format.ts b/src/format.ts index eef01fc..59076f1 100644 --- a/src/format.ts +++ b/src/format.ts @@ -111,11 +111,18 @@ export const explanationSectionSchema = z }) .strict() +export const explanationMetadataSchema = z + .object({ + explainedBy: z.string().min(1).optional(), + }) + .strict() + export const explanationsSchema = z .object({ captureId: z.string().min(1), title: z.string().min(1), summary: z.preprocess((value) => value ?? '', z.string()).default(''), + metadata: explanationMetadataSchema.optional(), sections: z.array(explanationSectionSchema), }) .strict() @@ -134,12 +141,21 @@ export const documentStepSchema = z message: 'captured change IDs require a diff', }) +export const documentMetadataSchema = z + .object({ + explainedBy: z.string().min(1).optional(), + publishedBy: z.string().min(1).optional(), + publishedAt: z.string().datetime().optional(), + }) + .strict() + export const explainDocumentSchema = z .object({ formatVersion: z.literal(1), title: z.string().min(1), summary: z.string().default(''), source: documentSourceSchema, + metadata: documentMetadataSchema.optional(), sections: z .array( z diff --git a/src/publish.ts b/src/publish.ts index 1fb2ef9..223e875 100644 --- a/src/publish.ts +++ b/src/publish.ts @@ -6,6 +6,16 @@ export interface PublishedReport { revocationToken: string } +// The publisher is self-reported attribution, not a credential. It is read at publish time +// so the authoring files never carry it, and omitted cleanly when Git has no user name. +export function withPublisher( + document: ExplainDocument, + publishedBy: string | undefined, +): ExplainDocument { + if (publishedBy === undefined) return document + return { ...document, metadata: { ...document.metadata, publishedBy } } +} + const defaultService = 'https://review.diffwalk.dev' export function reportService(explicit: string | undefined): string { diff --git a/src/report/shell.ts b/src/report/shell.ts index 7fd021b..53625c9 100644 --- a/src/report/shell.ts +++ b/src/report/shell.ts @@ -37,6 +37,7 @@ interface ReportBody { function renderReportBody( document: ExplainDocument, options: ReportOptions = {}, + hosted = false, ): ReportBody { const title = options.title ?? document.title const layout = options.layout ?? 'split' @@ -64,9 +65,9 @@ function renderReportBody( document.summary.trim() === '' ? '' : `\n
${renderMarkdown(document.summary)}
` - // Title, provenance, and summary are one opening, so they share one card. The layout - // toggle lives in the sticky map instead: it is a reading control, wanted while - // scrolled into a diff, and the card scrolls away. + // Title, provenance, attribution, and summary are one opening, so they share one card. + // The layout toggle lives in the sticky map instead: it is a reading control, wanted + // while scrolled into a diff, and the card scrolls away. const markup = `
${reviewMap}
@@ -74,7 +75,7 @@ ${reviewMap}

${escapeHtml(title)}

${summary} + ${renderAttribution(document.metadata, hosted)}${summary} ${sections.map((section) => section.markup).join('\n')}
@@ -108,7 +109,7 @@ export function renderHostedReport( options: ReportOptions = {}, ): string { return renderShell( - renderReportBody(document, options), + renderReportBody(document, options, true), ``, ``, ) @@ -240,6 +241,28 @@ function sectionIndex(index: number): string { return String(index + 1).padStart(2, '0') } +function renderAttribution(metadata: ExplainDocument['metadata'], hosted: boolean): string { + if (metadata === undefined) return '' + const rows: string[] = [] + if (metadata.explainedBy !== undefined) { + rows.push(`
Explained by
${escapeHtml(metadata.explainedBy)}
`) + } + // A local preview or export must not claim a publisher or a publication time, even if + // a document somehow carries one. Only the review service renders those rows. + if (hosted && metadata.publishedBy !== undefined) { + rows.push(`
Published by
${escapeHtml(metadata.publishedBy)}
`) + } + if (hosted && metadata.publishedAt !== undefined) { + rows.push(`
Published at
${escapeHtml(metadata.publishedAt)}
`) + } + if (rows.length === 0) return '' + return ` + +

The names are self-reported attribution, not verified identity.

` +} + function renderSourceMetadata(source: ExplainDocument['source']): string { if (source.kind === 'commit-diff') { return `
From
${renderEndpoint(source.from)}
@@ -314,6 +337,17 @@ body { .source-metadata dt { color: #7e8d82; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; } .source-metadata dd { margin: 0; min-width: 0; overflow: hidden; color: #4e5d53; text-overflow: ellipsis; white-space: nowrap; } .source-metadata code { color: #263a2d; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; } +.attribution-metadata { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 0 8px; + margin: 12px 0 0; + font-size: 11px; + line-height: 1.5; +} +.attribution-metadata dt { color: #7e8d82; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; } +.attribution-metadata dd { min-width: 0; margin: 0; color: #4e5d53; overflow-wrap: anywhere; } +.attribution-note { margin: 6px 0 0; color: #7e8d82; font-size: 11px; font-style: italic; } .review-controls { display: grid; gap: 8px; diff --git a/test/authoring.test.ts b/test/authoring.test.ts index 581d571..78b3916 100644 --- a/test/authoring.test.ts +++ b/test/authoring.test.ts @@ -452,6 +452,20 @@ describe('explain materialization', () => { expect(duplicatedChangeIds(allChangesAssigned(capture))).toEqual([]) }) + test('preserves the authored explainedBy attribution and omits absent metadata', () => { + const capture = captureWithTwoChanges() + + const attributed = { + ...allChangesAssigned(capture), + metadata: { explainedBy: 'Claude Code' }, + } + expect(materializeExplainDocument(capture, attributed).metadata).toEqual({ + explainedBy: 'Claude Code', + }) + + expect(materializeExplainDocument(capture, allChangesAssigned(capture)).metadata).toBeUndefined() + }) + test('rejects a change block that no longer matches captured content', () => { const capture = captureWithTwoChanges() const explanations = allChangesAssigned(capture) diff --git a/test/cli.test.ts b/test/cli.test.ts index fedc35b..12b12c1 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1053,6 +1053,46 @@ describe('JSON export', () => { }) }) +describe('author attribution', () => { + async function authorWithAttribution(repo: string) { + await runCli(['inspect'], repo) + await authorEveryChange(repo) + await writeExplanations( + repo, + (await readExplanationsYaml(repo)).replace( + 'title: A change set', + 'title: A change set\nmetadata:\n explainedBy: Claude Code', + ), + ) + } + + test('retains explainedBy in the JSON export', async () => { + const repo = await fixtureRepo() + await authorWithAttribution(repo) + const output = join(repo, 'out', 'document.json') + + const result = await runCli(['export', 'json', '--output', output], repo) + + expect(result.exitCode).toBe(0) + const document = JSON.parse(await readFile(output, 'utf8')) as { metadata?: unknown } + expect(document.metadata).toEqual({ explainedBy: 'Claude Code' }) + }) + + test('displays explainedBy locally without claiming a publisher or time', async () => { + const repo = await fixtureRepo() + await authorWithAttribution(repo) + const output = join(repo, 'out', 'report.html') + + const result = await runCli(['export', 'html', '--output', output], repo) + + expect(result.exitCode).toBe(0) + const html = await readFile(output, 'utf8') + expect(html).toContain('
Explained by
Claude Code
') + expect(html).not.toContain('Published by') + expect(html).not.toContain('Published at') + }) +}) + interface FakeService { origin: string published: unknown[] @@ -1142,6 +1182,62 @@ describe('publish', () => { } }) + test('adds the Git user name without rewriting the authoring files', async () => { + const repo = await fixtureRepo() + await runCli(['inspect'], repo) + await authorEveryChange(repo) + await writeExplanations( + repo, + (await readExplanationsYaml(repo)).replace( + 'title: A change set', + 'title: A change set\nmetadata:\n explainedBy: Claude Code', + ), + ) + const explanationsPath = join(await currentWalkDir(repo), 'explanations.yaml') + const capturePath = join(await currentWalkDir(repo), 'capture.json') + const explanationsBefore = await readFile(explanationsPath, 'utf8') + const captureBefore = await readFile(capturePath, 'utf8') + const service = startFakeService() + + try { + const result = await runCli(['publish', '--service', service.origin], repo) + + expect(result.exitCode).toBe(0) + const uploaded = service.published[0] as { metadata?: Record } + expect(uploaded.metadata).toEqual({ explainedBy: 'Claude Code', publishedBy: 'Test' }) + expect(JSON.stringify(uploaded)).not.toContain('test@example.com') + expect(await readFile(explanationsPath, 'utf8')).toBe(explanationsBefore) + expect(await readFile(capturePath, 'utf8')).toBe(captureBefore) + } finally { + service.stop() + } + }) + + test('omits the publisher when Git has no user name', async () => { + const repo = await fixtureRepo() + await runCli(['inspect'], repo) + await authorEveryChange(repo) + await writeExplanations( + repo, + (await readExplanationsYaml(repo)).replace( + 'title: A change set', + 'title: A change set\nmetadata:\n explainedBy: Claude Code', + ), + ) + await git(['config', 'user.name', ''], repo) + const service = startFakeService() + + try { + const result = await runCli(['publish', '--service', service.origin], repo) + + expect(result.exitCode).toBe(0) + const uploaded = service.published[0] as { metadata?: unknown } + expect(uploaded.metadata).toEqual({ explainedBy: 'Claude Code' }) + } finally { + service.stop() + } + }) + test('publishing without authored explanations fails before contacting the service', async () => { const repo = await fixtureRepo() await runCli(['inspect'], repo) diff --git a/test/explanations.test.ts b/test/explanations.test.ts index fe25e15..ab76ad2 100644 --- a/test/explanations.test.ts +++ b/test/explanations.test.ts @@ -181,4 +181,28 @@ sections: [] `), ).toThrow() }) + + test('parses an optional explainedBy attribution author', () => { + const explanations = parseExplanations(`${head}metadata: + explainedBy: Claude Code +sections: [] +`) + + expect(explanations.metadata).toEqual({ explainedBy: 'Claude Code' }) + }) + + test('omits author metadata when the file does not name one', () => { + expect(parseExplanations(`${head}sections: []\n`).metadata).toBeUndefined() + }) + + test('rejects metadata keys the authoring file must not set', () => { + for (const body of [ + `metadata:\n publishedBy: Art\n`, + `metadata:\n publishedAt: "2026-09-02T06:10:00.000Z"\n`, + `metadata:\n explainedBy: Claude Code\n extra: nope\n`, + `metadata:\n explainedBy: 7\n`, + ]) { + expect(() => parseExplanations(`${head}${body}sections: []\n`)).toThrow() + } + }) }) diff --git a/test/format.test.ts b/test/format.test.ts index eb4eaa0..2056ab6 100644 --- a/test/format.test.ts +++ b/test/format.test.ts @@ -222,6 +222,65 @@ describe('document source variants', () => { }) }) +describe('document metadata', () => { + function attributed(metadata: unknown): unknown { + return { + formatVersion: 1, + title: 'Attribution', + source: proposalSource, + metadata, + sections: [{ title: 'Attribution', steps: [{ text: 'Text.', diff: diff() }] }], + } + } + + test('accepts full, partial, and absent attribution metadata', () => { + const full = explainDocumentSchema.parse( + attributed({ + explainedBy: 'Claude Code', + publishedBy: 'Art', + publishedAt: '2026-09-02T06:10:00.000Z', + }), + ) + expect(full.metadata).toEqual({ + explainedBy: 'Claude Code', + publishedBy: 'Art', + publishedAt: '2026-09-02T06:10:00.000Z', + }) + + for (const metadata of [ + { explainedBy: 'Claude Code' }, + { publishedBy: 'Art' }, + { publishedAt: '2026-09-02T06:10:00.000Z' }, + {}, + ]) { + expect(explainDocumentSchema.parse(attributed(metadata)).metadata).toEqual(metadata) + } + + expect(explainDocumentSchema.parse(attributed(undefined)).metadata).toBeUndefined() + expect(explainDocumentSchema.parse(attributed({})).metadata).toEqual({}) + }) + + test('rejects unknown metadata keys and malformed values', () => { + for (const metadata of [ + { explainedBy: 'Claude Code', extra: 'nope' }, + { explainedBy: '' }, + { publishedBy: 7 }, + { publishedAt: 'yesterday' }, + { publishedAt: '2026-09-02' }, + 'not an object', + ]) { + expect(() => explainDocumentSchema.parse(attributed(metadata))).toThrow() + } + }) + + test('round-trips attribution metadata through JSON', () => { + const document = explainDocumentSchema.parse( + attributed({ explainedBy: 'Claude Code', publishedBy: 'Art' }), + ) + expect(explainDocumentSchema.parse(JSON.parse(JSON.stringify(document)))).toEqual(document) + }) +}) + describe('capture schema', () => { test('accepts the machine-owned capture shape with no sections', () => { const parsed = captureSchema.parse(capture) @@ -378,4 +437,30 @@ describe('explanations schema', () => { }), ).toThrow() }) + + test('accepts an optional explainedBy author and nothing else', () => { + const withAuthor = explanationsSchema.parse({ + captureId: 'x', + title: 'x', + metadata: { explainedBy: 'Claude Code' }, + sections: [], + }) + expect(withAuthor.metadata).toEqual({ explainedBy: 'Claude Code' }) + expect( + explanationsSchema.parse({ captureId: 'x', title: 'x', sections: [] }).metadata, + ).toBeUndefined() + + // publishedBy and publishedAt are supplied by other layers, so the authoring file + // rejects them instead of silently dropping authored data. + for (const metadata of [ + { publishedBy: 'Art' }, + { publishedAt: '2026-09-02T06:10:00.000Z' }, + { explainedBy: 'Claude Code', extra: 'nope' }, + { explainedBy: '' }, + ]) { + expect(() => + explanationsSchema.parse({ captureId: 'x', title: 'x', metadata, sections: [] }), + ).toThrow() + } + }) }) diff --git a/test/git.test.ts b/test/git.test.ts index 222225a..6b00620 100644 --- a/test/git.test.ts +++ b/test/git.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from 'bun:test' import { chmod, mkdir, mkdtemp, rename, rm, symlink, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { captureGitChanges, captureGitRevisionChanges } from '../src/authoring/git' +import { captureGitChanges, captureGitRevisionChanges, gitUserName } from '../src/authoring/git' const directories: string[] = [] @@ -712,6 +712,48 @@ describe('captureGitChanges selection', () => { }) }) +describe('gitUserName', () => { + test('reads the configured Git user name', async () => { + const directory = await mkdtemp(join(tmpdir(), 'diffwalk-git-')) + directories.push(directory) + await initializeRepository(directory) + + expect(await gitUserName(directory)).toBe('Test') + }) + + test('returns undefined when Git has no user name to report', async () => { + const directory = await mkdtemp(join(tmpdir(), 'diffwalk-git-')) + directories.push(directory) + await initializeRepository(directory) + await git(['config', 'user.name', ''], directory) + + expect(await gitUserName(directory)).toBeUndefined() + }) + + test('returns undefined instead of throwing when the config cannot be read', async () => { + const directory = await mkdtemp(join(tmpdir(), 'diffwalk-git-')) + directories.push(directory) + await git(['init', '-q'], directory) + const previous = { + global: process.env['GIT_CONFIG_GLOBAL'], + system: process.env['GIT_CONFIG_SYSTEM'], + } + process.env['GIT_CONFIG_GLOBAL'] = '/dev/null' + process.env['GIT_CONFIG_SYSTEM'] = '/dev/null' + try { + expect(await gitUserName(directory)).toBeUndefined() + } finally { + for (const [key, value] of [ + ['GIT_CONFIG_GLOBAL', previous.global], + ['GIT_CONFIG_SYSTEM', previous.system], + ] as const) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + }) +}) + async function initializeRepository(directory: string) { await git(['init', '-q'], directory) await git(['config', 'user.name', 'Test'], directory) diff --git a/test/publish.test.ts b/test/publish.test.ts index 7b2f32e..ebc0f7f 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -1,6 +1,12 @@ import { afterEach, describe, expect, test } from 'bun:test' import type { ExplainDocument } from '../src/format' -import { publishDocument, reportService, unpublishDocument, updateDocument } from '../src/publish' +import { + publishDocument, + reportService, + unpublishDocument, + updateDocument, + withPublisher, +} from '../src/publish' const originalFetch = globalThis.fetch const originalEnvironment = { ...process.env } @@ -85,6 +91,25 @@ describe('reportService', () => { }) }) +describe('withPublisher', () => { + test('adds the Git user name beside the authored author without mutating the source', () => { + const authored: ExplainDocument = { + ...document, + metadata: { explainedBy: 'Claude Code' }, + } + + const outgoing = withPublisher(authored, 'Art') + + expect(outgoing.metadata).toEqual({ explainedBy: 'Claude Code', publishedBy: 'Art' }) + expect(authored.metadata).toEqual({ explainedBy: 'Claude Code' }) + expect(outgoing.sections).toBe(authored.sections) + }) + + test('returns the document unchanged when Git has no user name', () => { + expect(withPublisher(document, undefined)).toBe(document) + }) +}) + describe('publishDocument', () => { test('sends the document as anonymous JSON and returns the report', async () => { const calls = stubFetch(() => json(201, { id: 'abc', revocationToken: 'revoke-me' })) diff --git a/test/report.test.ts b/test/report.test.ts index c79eed7..eb19c2f 100644 --- a/test/report.test.ts +++ b/test/report.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import type { ExplainDocument } from '../src/format' import { renderMarkdown } from '../src/report/markdown' import { fileDiffStats, parseSectionPatch } from '../src/report/patches' -import { loadReportClient, renderReport, writeReport } from '../src/report' +import { loadReportClient, renderHostedReport, renderReport, writeReport } from '../src/report' import { reportTargets } from '../src/report/targets' const directories: string[] = [] @@ -756,3 +756,92 @@ describe('writeReport', () => { expect((await readdir(directory)).filter((name) => name.endsWith('.tmp'))).toEqual([]) }) }) + +describe('attribution metadata', () => { + const assets = { stylesHref: '/report.css', clientSrc: '/report-client.js' } + + function attributed(metadata: ExplainDocument['metadata']): ExplainDocument { + return { ...document([section(simplePatch(), 'Plain')]), metadata } + } + + test('a hosted report shows the author, publisher, and publication time', () => { + const html = renderHostedReport( + attributed({ + explainedBy: 'Claude Code', + publishedBy: 'Art', + publishedAt: '2026-09-02T06:10:00.000Z', + }), + assets, + ) + + expect(html).toContain('
Explained by
Claude Code
') + expect(html).toContain('
Published by
Art
') + expect(html).toContain('
Published at
2026-09-02T06:10:00.000Z
') + expect(html).toContain('class="attribution-note"') + expect(html).toContain('self-reported attribution, not verified identity') + }) + + test('a local report keeps the author but never claims a publisher or time', () => { + const html = renderReport( + attributed({ + explainedBy: 'Claude Code', + publishedBy: 'Art', + publishedAt: '2026-09-02T06:10:00.000Z', + }), + stubClient, + ) + + expect(html).toContain('
Explained by
Claude Code
') + expect(html).not.toContain('Published by') + expect(html).not.toContain('Published at') + expect(html).not.toContain('
Art
') + }) + + test('partial metadata renders only the rows that are present', () => { + const authorOnly = renderHostedReport(attributed({ explainedBy: 'Claude Code' }), assets) + expect(authorOnly).toContain('
Explained by
') + expect(authorOnly).not.toContain('
Published by
') + expect(authorOnly).not.toContain('
Published at
') + + const timeOnly = renderHostedReport(attributed({ publishedAt: '2026-09-02T06:10:00.000Z' }), assets) + expect(timeOnly).toContain('
Published at
') + expect(timeOnly).not.toContain('
Explained by
') + expect(timeOnly).not.toContain('
Published by
') + }) + + test('absent metadata adds no attribution markup at all', () => { + const html = renderHostedReport(document([section(simplePatch(), 'Plain')]), assets) + + expect(html).not.toContain('attribution-metadata') + expect(html).not.toContain('attribution-note') + expect(html).not.toContain('Explained by') + }) + + test('local output with only service fields renders no attribution block', () => { + const html = renderReport( + attributed({ publishedBy: 'Art', publishedAt: '2026-09-02T06:10:00.000Z' }), + stubClient, + ) + + expect(html).not.toContain('