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('