Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ summary: |
Optional opening, shown above the review map.

<figure><svg viewBox="0 0 640 180" role="img">...</svg></figure>

metadata:
explainedBy: Claude Code
sections:
- title: Keep the greeting concise
steps:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
22 changes: 16 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion skills/diffwalk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/authoring/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ export function materializeExplainDocument(
title: explanations.title,
summary: explanations.summary,
source: capture.source,
...(explanations.metadata === undefined ? {} : { metadata: explanations.metadata }),
sections,
})
}
Expand Down
11 changes: 11 additions & 0 deletions src/authoring/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
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<string> {
const absolutePath = resolve(root, path)
const pathWithinRoot = relative(root, absolutePath)
Expand Down
10 changes: 6 additions & 4 deletions src/cli/commands/publish.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -13,16 +14,17 @@ type PublishOptions = z.infer<typeof publishOptionsSchema>

export async function publishCommand(options: PublishOptions): Promise<void> {
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,
Expand Down
16 changes: 16 additions & 0 deletions src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 39 additions & 5 deletions src/report/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -64,17 +65,17 @@ function renderReportBody(
document.summary.trim() === ''
? ''
: `\n <div class="cover-summary prose">${renderMarkdown(document.summary)}</div>`
// 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 = `<div class="review-workspace">
${reviewMap}
<main>
<section class="report-cover">
<h1>${escapeHtml(title)}</h1>
<dl class="source-metadata">
${renderSourceMetadata(document.source)}
</dl>${summary}
</dl>${renderAttribution(document.metadata, hosted)}${summary}
</section>
${sections.map((section) => section.markup).join('\n')}
</main>
Expand Down Expand Up @@ -108,7 +109,7 @@ export function renderHostedReport(
options: ReportOptions = {},
): string {
return renderShell(
renderReportBody(document, options),
renderReportBody(document, options, true),
`<link rel="stylesheet" href="${escapeHtml(assets.stylesHref)}">`,
`<script src="${escapeHtml(assets.clientSrc)}" defer></script>`,
)
Expand Down Expand Up @@ -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(`<dt>Explained by</dt><dd>${escapeHtml(metadata.explainedBy)}</dd>`)
}
// 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(`<dt>Published by</dt><dd>${escapeHtml(metadata.publishedBy)}</dd>`)
}
if (hosted && metadata.publishedAt !== undefined) {
rows.push(`<dt>Published at</dt><dd>${escapeHtml(metadata.publishedAt)}</dd>`)
}
if (rows.length === 0) return ''
return `
<dl class="attribution-metadata">
${rows.join('\n ')}
</dl>
<p class="attribution-note">The names are self-reported attribution, not verified identity.</p>`
}

function renderSourceMetadata(source: ExplainDocument['source']): string {
if (source.kind === 'commit-diff') {
return `<dt>From</dt><dd>${renderEndpoint(source.from)}</dd>
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions test/authoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading