diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b6be6fb4..53c5ca90 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "commitlore", "displayName": "CommitLore", - "version": "1.2.4", + "version": "1.2.5", "description": "Recorded decisions from git history, delivered to the agent before it edits. Constraints, alternatives already ruled out, and warnings left by whoever was here last.", "author": { "name": "MongLong0214", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index da184b0d..268d8063 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "commitlore", - "version": "1.2.4", + "version": "1.2.5", "description": "Decision memory from Git history, with verified capture for coding sessions.", "author": { "name": "MongLong0214", diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bf2c752..e668d61e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,65 @@ Release notes for 1.0.0, 1.0.1 and 1.0.2 are on the [GitHub releases page](https://github.com/MongLong0214/commitlore/releases); they were not written here. +## 1.2.5 + +Two flags on `capture` did nothing and said nothing; a third refusal knew the +fix and did not say it. + +**`capture --out` exited 0 and never wrote the file (#878).** The flag is +documented as "write the pending nonce to a file", and prompt-only mode — get the +nonce, hand the prompt to a model, come back with `--draft` — is the step it +exists for. That was the one run where the pipeline reported `nonce: null` while +prepare had already persisted a real transaction under a real nonce, so the write +was guarded out. Measured by the reporter as 3 runs, 3 exit 0, 3 missing files, +with and without `--diff`. The nonce is now reported, so `--out` writes it and +`--json` carries it. + +**`capture --diff` was accepted, ignored by prepare, and honoured by verify +(#877).** Passing the *same* `--diff` file to both steps failed whenever that +file was not byte-identical to the staged diff: +`discarded record 0 (source-mismatch): diff hash does not match the prepared +transaction`. That message is about the draft's sources, and the fault was +entirely the flag's — the reporter spent several attempts re-checking quotes and +locators that were never wrong. + +The refusal itself was right and stays. A capture transaction binds to the staged +diff, which prepare, verify and stage each recompute independently, because every +binding is computed server-side and never from the caller. `--diff` cannot select +a different diff; it can only assert what is staged. So it is now refused where +that is decidable — up front, exit 2, naming the flag and naming the way out +(`git reset --soft`, which is what the reporter had to find on their own) — rather +than several steps later against the record. A run refused this way also leaves no +pending transaction behind; the old path wrote two per attempt. + +`--diff` byte-identical to the staged diff keeps working, and the help text now +says what the flag does rather than implying an override it never had. + +**`unknown-key` now names the `X-` form the author could have written (#881).** +Claude Code instructs every session to end its commit message with +`Claude-Session:`, so the key arrives by default, the hook refuses it, the commit +is lost, and an agent that cannot see the repository's earlier commits writes it +again. Reported twice in one day in two repositories by the same author, the +second time after already knowing about the first, because `X-` is not the kind of +thing that stays in mind between repositories. + +``` +31: unknown-key Claude-Session — got "Claude-Session", + want "a key from SPEC §3, or X-Claude-Session if this is your own metadata" +``` + +Nothing is accepted that was not accepted before, and nothing is rewritten: SPEC +§6 says the message is not modified, and silently renaming someone's trailer would +be worse than refusing it. This only says the name. Two keys are deliberately not +offered the prefix — one whose value reads as a sentence, which keeps the #647 +answer, because that author's problem is that their prose became metadata; and one +that differs from a SPEC §3 key only by case, because `X-limit` would be a valid +record carrying the wrong key. + +`spec/fixtures/invalid/03-unknown-key.expected.json` moves with it. The violation +it pins is unchanged and `Constraint:` is still refused; only the advisory `want` +text differs. + ## 1.2.4 `doctor` blamed the hook that worked, and prescribed reinstalling it. diff --git a/README.ja.md b/README.ja.md index 2fb227b1..1977f407 100644 --- a/README.ja.md +++ b/README.ja.md @@ -47,18 +47,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5 ```
先にインストーラーを読みたいですか? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh -sh install.sh v1.2.4 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh +sh install.sh v1.2.5 # あるいはスクリプトを使わずに。スクリプトが作るチェックアウトは自分でも作れます。 -git clone --depth 1 --branch v1.2.4 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.5 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -107,13 +107,13 @@ CommitLore はその判断をコードのそばに残します。 macOS と Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.ps1))) v1.2.5 ``` Node.js 22.23.2+ と Git が必要です。スクリプトは何かを書き込む前に両方を確認します。 diff --git a/README.ko.md b/README.ko.md index a5883f2c..6b39adb0 100644 --- a/README.ko.md +++ b/README.ko.md @@ -47,18 +47,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5 ```
먼저 설치기를 읽어 보고 싶나요? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh -sh install.sh v1.2.4 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh +sh install.sh v1.2.5 # 또는 스크립트를 건너뜁니다. 스크립트가 만드는 체크아웃은 직접 만들 수 있습니다. -git clone --depth 1 --branch v1.2.4 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.5 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -107,13 +107,13 @@ CommitLore는 그 판단을 코드 곁에 보관합니다. macOS와 Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.ps1))) v1.2.5 ``` Node.js 22.23.2+와 Git이 필요합니다. 스크립트는 무엇이든 쓰기 전에 둘을 확인합니다. diff --git a/README.md b/README.md index 365acbd0..3a242739 100644 --- a/README.md +++ b/README.md @@ -48,18 +48,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5 ```
Prefer to read the installer first? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh -sh install.sh v1.2.4 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh +sh install.sh v1.2.5 # Or skip the script: the checkout it makes is one you can make yourself. -git clone --depth 1 --branch v1.2.4 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.5 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -109,13 +109,13 @@ preserve, not for narrating every change. macOS and Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.ps1))) v1.2.5 ``` Requires Node.js 22.23.2+ and Git. The script checks both before it writes anything. diff --git a/README.zh-CN.md b/README.zh-CN.md index 8790036f..df475c1a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -47,18 +47,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5 ```
想先阅读安装器吗? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh -sh install.sh v1.2.4 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh +sh install.sh v1.2.5 # 或者跳过脚本:它创建的检出,你自己也能创建。 -git clone --depth 1 --branch v1.2.4 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.5 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -105,13 +105,13 @@ CommitLore 把那份判断留在代码旁边。 macOS 和 Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.ps1))) v1.2.5 ``` 需要 Node.js 22.23.2+ 和 Git。脚本会在写入任何内容前检查两者。 diff --git a/install.ps1 b/install.ps1 index 24558bcb..7d38ee6f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,8 +1,8 @@ <# Installs commitlore from source on Windows, for any agent that is not Claude Code. - irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1 | iex - & ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4 + irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.ps1 | iex + & ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.ps1))) v1.2.5 Claude Code users do not need this script. The repository is itself a plugin marketplace (ADR-0011), so two /plugin commands register the MCP server, the diff --git a/install.sh b/install.sh index c69df874..c1d78dce 100755 --- a/install.sh +++ b/install.sh @@ -1,8 +1,8 @@ #!/bin/sh # Installs commitlore from source, for any agent that is not Claude Code. # -# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 +# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5 # # **Claude Code users do not need this script.** The repository is itself a # plugin marketplace (ADR-0011), so two `/plugin` commands register the MCP diff --git a/package-lock.json b/package-lock.json index 5730f28a..afc2d547 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "commitlore", - "version": "1.2.4", + "version": "1.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "commitlore", - "version": "1.2.4", + "version": "1.2.5", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index b1b7b646..aaccc9ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "commitlore", - "version": "1.2.4", + "version": "1.2.5", "description": "Git-native, lifecycle-aware decision memory for coding agents", "license": "MIT", "private": true, diff --git a/server.json b/server.json index 6e4f9cb6..19048891 100644 --- a/server.json +++ b/server.json @@ -8,7 +8,7 @@ "source": "github" }, "websiteUrl": "https://github.com/MongLong0214/commitlore#readme", - "version": "1.2.4", + "version": "1.2.5", "_meta": { "io.modelcontextprotocol.registry/publisher-provided": { "registryFit": "Distribution is a tagged git checkout plus a Claude Code plugin marketplace (ADR-0011 registry-free git distribution, ADR-0026 no compiled executables and no uploaded release asset), so no official package type applies and this record relies on websiteUrl plus publisher metadata.", @@ -18,8 +18,8 @@ "/plugin marketplace add MongLong0214/commitlore", "/plugin install commitlore@commitlore" ], - "installer": "curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4", - "release": "https://github.com/MongLong0214/commitlore/releases/tag/v1.2.4" + "installer": "curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5", + "release": "https://github.com/MongLong0214/commitlore/releases/tag/v1.2.5" }, "runtime": { "transport": "stdio", diff --git a/spec/fixtures/invalid/03-unknown-key.expected.json b/spec/fixtures/invalid/03-unknown-key.expected.json index 8d3a13f2..d6ccc0c0 100644 --- a/spec/fixtures/invalid/03-unknown-key.expected.json +++ b/spec/fixtures/invalid/03-unknown-key.expected.json @@ -4,6 +4,6 @@ { "key": "Constraint", "value": "must ship by friday per the compliance deadline" } ], "violations": [ - { "key": "Constraint", "value": "must ship by friday per the compliance deadline", "rule": "unknown-key", "got": "Constraint", "want": "a key from SPEC §3 or X-" } + { "key": "Constraint", "value": "must ship by friday per the compliance deadline", "rule": "unknown-key", "got": "Constraint", "want": "a key from SPEC §3, or X-Constraint if this is your own metadata" } ] } diff --git a/src/commands/capture.ts b/src/commands/capture.ts index 90e70de9..bbeaa178 100644 --- a/src/commands/capture.ts +++ b/src/commands/capture.ts @@ -210,14 +210,37 @@ const runCapturePipeline = (opts: { const { transcriptPath, diffPath, draftPath, cwd } = opts; const transcript = readCallerFile(transcriptPath); - // Prepare hashes `git diff --cached` itself, so verification has to be given - // the same bytes. This used to default to the empty string, whose hash never - // matches -- every record was refused with `source-mismatch` and the command - // printed `no record staged`, so `capture --draft` could not succeed at all - // unless the caller happened to pass a --diff file byte-identical to the - // staged diff. A caller-supplied --diff that differs is still a real mismatch - // and is still refused. - const diff = diffPath ? readCallerFile(diffPath) : execGitOrThrow(['diff', '--cached'], { cwd }); + // The transaction binds to the staged diff and only to that: prepare hashes + // `git diff --cached` itself, and stage recomputes it a third time before + // writing, because every binding is computed server-side and never from the + // caller (capture-stage.ts). So `--diff` can assert what is staged; it cannot + // override it. + // + // It used to default to the empty string, whose hash never matches -- every + // record was refused with `source-mismatch` and the command printed `no record + // staged`, so `capture --draft` could not succeed at all unless the caller + // happened to pass a --diff file byte-identical to the staged diff. + // + // A --diff that differs is still refused. What #877 is about is where that + // refusal surfaced: prepare wrote a pending file for a run that could not + // succeed, and verify then blamed the draft -- `source-mismatch: diff hash + // does not match the prepared transaction` -- for a fault belonging entirely + // to the flag. The reporter re-checked quotes and locators that were never + // wrong. It is refused here instead, before prepare, naming the flag. + const callerDiff = diffPath === undefined ? undefined : readCallerFile(diffPath); + const diff = execGitOrThrow(['diff', '--cached'], { cwd }); + if (callerDiff !== undefined && callerDiff !== diff) { + throw markCaptureError( + new Error( + `--diff ${JSON.stringify(diffPath)} is not the staged diff. A capture transaction ` + + 'binds to the staged diff -- prepare, verify and stage each recompute it, so --diff ' + + 'can assert what is staged but cannot override it. Stage the change you are ' + + 'recording; to record against a commit that already exists, soft-reset it first ' + + '(git reset --soft HEAD~1).', + ), + 'usage', + ); + } // 1. Prepare: compute bindings, generate prompt, persist prepared transaction const prepareResult = prepareCaptureContext({ @@ -239,10 +262,17 @@ const runCapturePipeline = (opts: { } // 2. If no draft provided, print the prompt contract and exit (prompt-only mode) + // + // The nonce is reported, not dropped (#878). Prepare has already persisted the + // transaction under it, and prompt-only is the step `--out` exists for: get the + // nonce, hand the prompt to a model, come back with --draft. Returning null + // here made `if (options.out && result.nonce)` false on exactly that run, so + // --out wrote nothing and said nothing, and a scripted caller had no handle on + // which transaction it was completing until verify failed several steps later. if (!draftPath) { return { outcome: 'empty', - nonce: null, + nonce: prepareResult.nonce, staged: false, prompt: prepareResult.prompt, transcript_window: prepareResult.transcript_window, @@ -396,7 +426,11 @@ export const register = (program: Command): void => { 'path to the session transcript file (the prompt carries its last 256 KiB; ' + 'COMMITLORE_TRANSCRIPT_BUDGET_BYTES changes that, and verification always reads all of it)', ) - .option('--diff ', 'path to the diff file (defaults to the staged diff)') + .option( + '--diff ', + 'assert the staged diff equals this file; the transaction always binds to the staged diff, ' + + 'so this cannot select a different one', + ) .option('--draft ', 'path to the draft JSON file (omit for prompt-only mode)') .option('--out ', 'write the pending nonce to a file') .option('--shadow', 'measure historical capture candidates without writing anything') diff --git a/src/core/schema.ts b/src/core/schema.ts index 4f72f8ef..9580a1ed 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -75,6 +75,43 @@ const FORMAT_WANT: Readonly> = { const UNKNOWN_KEY_WANT = 'a key from SPEC §3 or X-'; +/** + * Name the `X-` form the author could have written, rather than its shape (#881). + * + * `X-` is already in the answer, and it was not enough: Claude Code tells + * every session to end its commit message with `Claude-Session:`, so the key + * arrives by default, the hook refuses it, the commit is lost, and the agent — + * which cannot see the repository's earlier commits — writes it again. Reported + * twice in one day in two repositories by the same author, the second time after + * already knowing about the first, because `X-` is not the kind of thing that + * stays in mind between repositories. Naming `X-Claude-Session` turns a refused + * commit into a corrected one. + * + * Not a rewrite: SPEC §6 says the message is not modified, and silently renaming + * someone's trailer would be worse than refusing it. This only says the name. + * + * Two keys are deliberately not offered the prefix: + * + * - one whose value reads as a sentence, which keeps the #647 answer instead — + * that author's problem is that their prose became metadata, and `X-` is the + * wrong door for them. That branch is chosen before this function is called. + * - one that differs from a SPEC §3 key only by case. `limit:` is `Limit:` + * miswritten, not an extension, and `X-limit` would be a valid record holding + * the wrong key. SPEC §3 matches case-sensitively and `KNOWN_KEYS` still does, + * so the plain answer — which names §3 first — is the useful one here. + * + * Anything the prefix cannot rescue (a leading digit, an underscore) falls back + * too: `EXTENSION_KEY_RE` is asked rather than assumed. + */ +const unknownKeyWant = (key: string): string => { + if ((KNOWN_KEYS as readonly string[]).some((known) => known.toLowerCase() === key.toLowerCase())) { + return UNKNOWN_KEY_WANT; + } + const prefixed = `X-${key}`; + if (!EXTENSION_KEY_RE.test(prefixed)) return UNKNOWN_KEY_WANT; + return `a key from SPEC §3, or ${prefixed} if this is your own metadata`; +}; + /** * The same violation, told to someone who did not write a trailer (#647). * @@ -204,7 +241,7 @@ const violationFor = (trailer: Trailer, field: string): Violation | null => { value: trailer.value, rule: 'unknown-key', got: trailer.key, - want: looksLikeProse(trailer.value) ? PROSE_KEY_WANT : UNKNOWN_KEY_WANT, + want: looksLikeProse(trailer.value) ? PROSE_KEY_WANT : unknownKeyWant(trailer.key), }; } diff --git a/test/capture.test.ts b/test/capture.test.ts index 2bf50641..8aed8c51 100644 --- a/test/capture.test.ts +++ b/test/capture.test.ts @@ -496,6 +496,104 @@ describe('commitlore capture', () => { }); }); +describe('commitlore capture --out (#878)', () => { + // Prompt-only is the step --out exists for -- get the nonce, hand the prompt + // to a model, come back with --draft -- and it was the one run where the + // pipeline returned `nonce: null`, so the write was guarded out. Exit 0, no + // file, nothing said. Three runs because the report measured three. + it('writes the pending nonce in prompt-only mode', () => { + const cwd = makeRepo(); + const { transcriptPath } = makeFixtures(cwd); + + for (const run of [1, 2, 3]) { + const outPath = join(cwd, `nonce-${run}.txt`); + const result = runCapture(['--transcript', transcriptPath, '--out', outPath], { cwd }); + + expect(result.exitCode).toBe(0); + expect(existsSync(outPath), `run ${run} wrote no file`).toBe(true); + expect(readFileSync(outPath, 'utf8').trim()).toMatch(/^[0-9a-f]{32}$/); + } + }); + + // The file is worthless unless it names the transaction prepare actually + // persisted -- a well-formed nonce nothing can be staged under would be the + // same silence in a different shape. + it('writes a nonce that names a real pending transaction', () => { + const cwd = makeRepo(); + const { transcriptPath } = makeFixtures(cwd); + const outPath = join(cwd, 'nonce.txt'); + + runCapture(['--transcript', transcriptPath, '--out', outPath], { cwd }); + + const nonce = readFileSync(outPath, 'utf8').trim(); + expect(listPending(cwd)).toContain(`${nonce}.json`); + }); + + it('reports the nonce in the JSON envelope too', () => { + const cwd = makeRepo(); + const { transcriptPath } = makeFixtures(cwd); + + const result = runCapture(['--transcript', transcriptPath, '--json'], { cwd }); + + const parsed = JSON.parse(result.stdout) as { outcome: string; nonce: string | null }; + expect(parsed.outcome).toBe('empty'); + expect(parsed.nonce).toMatch(/^[0-9a-f]{32}$/); + }); +}); + +describe('commitlore capture --diff (#877)', () => { + /** A real diff that is not the staged one: the previous commit's. */ + const writeForeignDiff = (cwd: string): string => { + const path = join(cwd, 'other.patch'); + writeFileSync(path, execSync('git show HEAD', { cwd, encoding: 'utf8' })); + return path; + }; + + // The transaction binds to the staged diff and nothing else, so a --diff that + // differs cannot be honoured. It was refused before this too -- but at verify, + // as `source-mismatch` against the draft's sources, which sent the reporter to + // re-check quotes and locators that were never wrong. + it('refuses a --diff that is not the staged diff, naming the flag', () => { + const cwd = makeRepo(); + const { transcriptPath } = makeFixtures(cwd); + const foreign = writeForeignDiff(cwd); + + const result = runCapture(['--transcript', transcriptPath, '--diff', foreign], { cwd }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('--diff'); + expect(result.stderr, 'the flag is named, not the draft').not.toContain('source-mismatch'); + expect(result.stderr, 'and the way out is named').toContain('git reset --soft'); + }); + + // Refused before prepare, so a run that cannot succeed leaves no transaction + // for `capture gc` to collect. Two were left behind per attempt before. + it('writes no pending transaction for a --diff it refuses', () => { + const cwd = makeRepo(); + const { transcriptPath, draftPath } = makeFixtures(cwd); + const foreign = writeForeignDiff(cwd); + + runCapture(['--transcript', transcriptPath, '--diff', foreign, '--draft', draftPath], { cwd }); + + expect(listPending(cwd)).toEqual([]); + }); + + // The control. Without it the fix degenerates into "reject every --diff", + // which passes the two assertions above and breaks every caller that has one. + it('still stages when --diff is byte-identical to the staged diff', () => { + const cwd = makeRepo(); + const { transcriptPath, diffPath, draftPath } = makeFixtures(cwd); + + const result = runCapture( + ['--transcript', transcriptPath, '--diff', diffPath, '--draft', draftPath], + { cwd }, + ); + + expect(result.exitCode).toBe(0); + expect(listStagedPending(cwd).length).toBe(1); + }); +}); + describe('commitlore capture --unattended (#511)', () => { it('is refused where the repository did not opt in, and stages nothing', () => { const cwd = makeRepo(); diff --git a/test/help-text-honesty.test.ts b/test/help-text-honesty.test.ts index 62f696c0..962f24b0 100644 --- a/test/help-text-honesty.test.ts +++ b/test/help-text-honesty.test.ts @@ -91,12 +91,24 @@ describe('#303 user-facing text names only packages the manifest carries', () => */ describe('#359 capture --diff documents the default it actually has', () => { const captureSource = readFileSync(join(REPO_ROOT, 'src/commands/capture.ts'), 'utf8'); - const diffOption = captureSource - .split('\n') - .find((line) => line.includes(".option('--diff '")); + + // The whole `.option(...)` call, not one line of it. r-diffdefault recorded + // reading a single line as this check's known limit, and #877 collected on it: + // the description outgrew one line, the call became multi-line, and all three + // assertions below stopped seeing any string at all. A guard that silently + // matches nothing reports the same green as one that passed. + const optionCall = (flag: string): string | undefined => { + const chunk = captureSource.split('.option(').find((part) => part.trimStart().startsWith(flag)); + if (chunk === undefined) return undefined; + // Chained calls are indented four spaces; stop at the next one. + const end = chunk.indexOf('\n .'); + return end === -1 ? chunk : chunk.slice(0, end); + }; + const diffOption = optionCall("'--diff '"); it('the option line exists to be checked', () => { expect(diffOption).toBeDefined(); + expect(diffOption).toContain('--diff '); }); it('does not tell the caller the default is empty', () => { @@ -107,6 +119,15 @@ describe('#359 capture --diff documents the default it actually has', () => { expect(diffOption).toMatch(/staged/i); }); + // #877 narrowed what the flag means without changing what it reads. The + // transaction binds to the staged diff at prepare, verify and stage, so a + // description promising that `--diff` selects a different diff would be the + // #359 defect again in the other direction: the caller passes one, every + // record comes back `source-mismatch`, and the help text told them to. + it('does not promise that --diff selects a diff other than the staged one', () => { + expect(diffOption).toMatch(/assert|cannot (select|override)/i); + }); + // Teeth: the sentence above is only worth asserting while the code still // behaves that way. If the default ever moves off the staged diff, this fails // and the help text has to be re-decided rather than quietly drifting again. diff --git a/test/schema.test.ts b/test/schema.test.ts index e02a05b4..746a1fbf 100644 --- a/test/schema.test.ts +++ b/test/schema.test.ts @@ -193,13 +193,51 @@ describe('validateRecord', () => { }); // The other half, and the reason the value decides rather than the key: a - // key someone meant as a key gets the short answer. spec/fixtures/invalid/ - // 03-unknown-key.txt pins this same reading through the conformance suite. - it('keeps the plain answer for a key that was meant as a key', () => { + // key someone meant as a key is not told their sentence became metadata. + // What it is told changed in #881 — the answer now names the X- form the + // author could have written instead of describing its shape. spec/fixtures/ + // invalid/03-unknown-key.json pins this same reading through the conformance + // suite; the violation it pins is unchanged, and `Constraint` is still + // refused. Only the advice moved, which test/validate.test.ts already + // records as advisory prose rather than the conformance contract. + it('names the extension form for a key that was meant as a key', () => { const violations = validateRecord([ { key: 'Constraint', value: 'must ship by friday per the compliance deadline' }, ]); + expect(violations.map((v) => v.rule)).toEqual(['unknown-key']); + expect(violations[0]?.want).toBe( + 'a key from SPEC §3, or X-Constraint if this is your own metadata', + ); + }); + + // #881: the reported case. Claude Code tells every session to end its commit + // message with this key, so it arrives by default and the refusal has to + // carry the repair or the same commit is written, refused and written again. + it('names X-Claude-Session when refusing Claude-Session', () => { + const violations = validateRecord([ + { key: 'Claude-Session', value: 'https://claude.ai/code/session_abc123' }, + ]); + + expect(violations.map((v) => v.rule)).toEqual(['unknown-key']); + expect(violations[0]?.want).toContain('X-Claude-Session'); + expect(violations[0]?.want, 'SPEC §3 still comes first').toContain('a key from SPEC §3'); + }); + + // The guard the hint made necessary. `limit:` is `Limit:` miswritten, not an + // extension, and `X-limit` would be a valid record carrying the wrong key. + it('does not offer the X- form for a SPEC §3 key that differs only by case', () => { + const violations = validateRecord([{ key: 'limit', value: 'the cache times out at 30s' }]); + + expect(violations.map((v) => v.rule)).toEqual(['unknown-key']); + expect(violations[0]?.want).toBe('a key from SPEC §3 or X-'); + }); + + // A key the prefix cannot rescue: EXTENSION_KEY_RE requires a letter after + // `X-`, so `X-2fa-mode` is not valid either and must not be suggested. + it('keeps the plain answer for a key that X- cannot make valid', () => { + const violations = validateRecord([{ key: '2fa-mode', value: 'totp' }]); + expect(violations.map((v) => v.rule)).toEqual(['unknown-key']); expect(violations[0]?.want).toBe('a key from SPEC §3 or X-'); });