Files
msd-core/tests/emitted-ack-trailer.test.cjs
Tom Boucher b8c70a33c0 fix(#4454): surface skipped explicit --files paths instead of silent success (#4538)
* fix(#4454): surface skipped explicit --files paths instead of silent success

cmdCommit's staging loop deliberately skips a --files path that no longer
exists on disk when the caller passed --files explicitly (#2014 guards
against staging an unwanted deletion for a temporarily-absent file), but
recorded nothing about the skip. The final result reported unqualified
committed: true, so a caller had no way to distinguish "everything in
--files landed" from "some paths were silently excluded because they
didn't exist" -- a real file deletion meant to be committed (e.g.
phase.complete removing .planning/milestone.lock) would silently never
land, with git status still showing it unstaged after a "successful"
commit.

Tracks skipped paths in a skippedFiles array and surfaces them as
skipped_files (matching this result family's existing snake_case
precedent, timed_out) in the success result AND the nothing_to_commit
result (reachable when every named path was missing), included only
when non-empty so the common case's payload shape is unchanged. Also
extended to the SECOND nothing_to_commit result (reached when git
itself reports "nothing to commit" after the nothingToCommit guard was
false -- the residual partial-skip + partialCommitRefused window the
surrounding comments already document) for the same consistency. The
#2014 staging/deletion guard itself is untouched -- purely a visibility
fix, exactly as the issue requested.

Regression tests cover: existing-plus-missing file (the issue's own
repro shape, with the missing path genuinely tracked-then-deleted so
the #2014 assertion is meaningful, not vacuous against an
never-tracked path); only-existing files (no skipped_files key at
all); only-missing files (nothing_to_commit with skipped_files);
default mode unaffected; and multiple missing files reported in order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: recover ack trailers buried by squash-merge concatenation

Discovered while verifying an unrelated PR (#4454): tests/emitted-attribution.test.cjs
failed citing pr-branch.md's growth as unacknowledged, even though PR #4537
(#4447) had genuinely acked it via an Emitted-Drift-Ack-Growth commit trailer.

Root cause: git's %(trailers:...) placeholder (used by readAckTrailers) only
recognizes a trailer block that is the TRUE TERMINAL block of a commit
message. GitHub's squash-merge commit body concatenates every constituent
commit's subject+body in order, then unconditionally appends its own
`---------` separator and Co-authored-by trailers. Confirmed directly
against a real squash commit: %(trailers) returns ONLY GitHub's own two
Co-authored-by lines -- not even the LAST original commit's own trailer
survives, since GitHub's appended suffix breaks the backward scan before
it ever reaches past `---------` to any original commit's content, last
or not. An ack trailer added on any non-final commit of a PR (the common
case -- more commits routinely land after an ack, e.g. a lint fix or a
changeset) is therefore silently invisible forever to any future
comparison against a base predating that squash.

Fix: readAckTrailers now runs a second pass (extractSquashBuriedTrailers)
alongside the existing whole-message read. For each commit in range, if
its raw body contains GitHub's squash-suffix signal, the pre-suffix text
(everything before the LAST such signal -- an earlier bullet's own body
may legitimately contain a markdown horizontal rule that looks the same,
so anchoring on the first occurrence would truncate too early and miss a
later bullet's real ack) is split on squash-bullet (`* <subject>`)
boundaries, and each chunk is independently trailer-parsed via
`git interpret-trailers --parse` -- the same underlying algorithm as
%(trailers:...), but runnable against arbitrary text rather than only a
real commit object. This finds a trailer buried in ANY bullet, not just
the last one.

Scoped tightly to avoid reintroducing the false-positive class
%(trailers:...) was originally chosen to prevent (a mid-body MENTION of
trailer syntax must stay inert): the sub-chunk pass activates only on
commits matching the squash-suffix signal, so an ordinary commit whose
body happens to contain markdown bullets is completely unaffected, and
each chunk still goes through git's own strict per-chunk terminal-block
detection.

Verified end-to-end against a real squash commit (recovers the buried
trailer) and four adversarial fixtures now pinned as regression tests:
ordinary bullet prose with no squash suffix (stays empty); a
squash-shaped commit where one bullet's body merely mentions trailer
syntax mid-paragraph (stays inert, the "row 32" false-positive class,
now verified at per-chunk granularity); and an earlier bullet's own
markdown horizontal rule not truncating the scan before a later bullet's
real ack (the last-match-not-first-match case an isolated review pass
caught during this same fix).

This overrides one-concern-per-PR per CLAUDE.md's Defects & Warnings
policy -- a genuine defect discovered mid-work is fixed inline, not
deferred to a separate issue (spawn_task for this was correctly blocked
by the no-defer guard).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4454): backfill changeset PR number

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:01:12 -04:00

876 lines
39 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* tests/emitted-ack-trailer.test.cjs — #3942 commit-trailer acknowledgment reader/parser.
*
* FAILING-FIRST against the #3942 stubs in tests/helpers/emitted-diff.cjs
* (`parseAckTrailers`, `renderAckTrailer`, `ACK_TRAILER_HASH`, `ACK_TRAILER_GROWTH`,
* `ACK_TRAILER_DELIM`, `MAX_ACK_TRAILERS`) and tests/helpers/emitted-runtime.cjs
* (`readAckTrailers`). See `.gsd/phase/chore-3942-ack-commit-trailer/40-design.md` for
* the grammar/behavior table and `50-test-matrix.md` for the 37-row matrix this file
* covers.
*
* Every one of those exports is a STUB today: it returns a benign, empty-shaped value
* and NEVER throws. So every row below that requires real parsing, real git I/O, a
* thrown error, or a bijective round-trip is RED for the right reason — missing
* implementation, not a test bug. A handful of rows (25, 31, 34) legitimately assert
* an EMPTY result and so pass trivially today; that is the correct, non-vacuous
* behavior for those specific inputs both before and after the real implementation
* lands, not a weak assertion.
*
* Three rows (8, 34, 35) describe diffEmitted's future CONSUMER-side gating
* (`staleAcks` naming a space, shrinkage never needing a trailer, `NEW_FILE_CAP` never
* being excusable) — behavior that depends on diffEmitted being rewired to accept
* separate `ackHash`/`ackGrowth` maps, which is a later step per 40-design.md's blast
* radius table and out of scope for this stub-only change. Each is expressed here as
* the reader-level analog it reduces to (namespace separation, or "the reader has no
* opinion on X"), with a comment explaining the narrowing. See the dispatch report for
* the precise accounting.
*
* ── Row -> describe block map ────────────────────────────────────────────────
* grammar and hostile keys (pure parser) rows 8, 9-18, 28-30
* boundary: MAX_ACK_TRAILERS cap rows 19-21
* real fixture reads via readAckTrailers rows 1-7, 25-27, 31, 34, 35
* hostile IO: range/subprocess/timeout rows 22-24
* round-trip and properties rows 33, 36, 37
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const crypto = require('node:crypto');
const fc = require('fast-check');
const { cleanup } = require('./helpers.cjs');
const { gitOrThrow, GIT_FIXTURE_TIMEOUT_MS } = require('./helpers/git-fixture.cjs');
const {
ACK_TRAILER_HASH,
ACK_TRAILER_GROWTH,
ACK_TRAILER_DELIM,
MAX_ACK_TRAILERS,
parseAckTrailers,
renderAckTrailer,
} = require('./helpers/emitted-diff.cjs');
const { readAckTrailers } = require('./helpers/emitted-runtime.cjs');
// Mirrors emitted-diff.cjs's (unexported) RESERVED_ACK_KEYS — 40-design.md's "Trailer
// key is __proto__ / constructor / prototype" row is explicitly carried over verbatim
// from that JSON-ack design, so the three literal values are pinned here rather than
// imported (nothing in the #3942 stub surface re-exports the JSON-ack constant).
const RESERVED_KEYS = ['__proto__', 'constructor', 'prototype'];
// ─── fixture helpers ──────────────────────────────────────────────────────────
/** A throwaway git repo: `init -b main`, deterministic identity, no GPG prompts. */
function makeTempRepo(prefix) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
gitOrThrow(['init', '-q', '-b', 'main'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
gitOrThrow(['config', 'user.email', 'ack-trailer-fixture@example.com'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
gitOrThrow(['config', 'user.name', 'Ack Trailer Fixture'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
gitOrThrow(['config', 'commit.gpgsign', 'false'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
return dir;
}
/**
* Commit an --allow-empty commit from a message written to a file (never `-m`, so the
* trailer block lands exactly where git's own trailer parser expects it).
*/
function commitMessage(dir, message, { branch } = {}) {
if (branch) {
gitOrThrow(['checkout', '-q', '-b', branch], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
}
const msgFile = path.join(os.tmpdir(), `gsd-ack-trailer-msg-${crypto.randomBytes(6).toString('hex')}.txt`);
fs.writeFileSync(msgFile, message);
try {
gitOrThrow(['commit', '-q', '--allow-empty', '-F', msgFile], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
} finally {
cleanup(msgFile);
}
}
function headSha(dir) {
return gitOrThrow(['rev-parse', 'HEAD'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS }).trim();
}
function checkout(dir, ref) {
gitOrThrow(['checkout', '-q', ref], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
}
/** One rendered trailer LINE — not `renderAckTrailer` (a stub today), but the literal
* grammar it will produce, so fixtures stay correct independent of the stub's state. */
function trailerLine(name, key, reason) {
return `${name}: ${key}${ACK_TRAILER_DELIM}${reason}`;
}
function withCleanup(t, dir) {
t.after(() => cleanup(dir));
}
/**
* Build `commitCount` trivial linear commits on `refs/heads/main` via a
* single `git fast-import` stream (one subprocess spawn, not one per
* commit) and return the root commit's sha.
*
* Used by row 24 to force `git log`'s real formatting work comfortably
* past a small `timeoutMs`, without racing real subprocess completion
* time the way a razor-thin `timeoutMs: 1` against a trivial 2-commit
* repo does (#3985: `process-seam.cjs`'s own documented at-the-boundary
* case — a child finishing right at the deadline can report a real
* `status` alongside `error.code === 'ETIMEDOUT'`, which the seam
* correctly classifies as EXITED, not TIMED_OUT). A large real-work
* margin (tens of thousands of commits vs a ~20ms bound) is reliable on
* any real hardware and any platform — no PATH/PATHEXT/shebang concerns,
* unlike an approach that tries to intercept the `git` binary itself.
*/
function growHistoryFastImport(dir, commitCount) {
let stream = '';
for (let i = 1; i <= commitCount; i++) {
const message = `c${i}\n`;
stream += 'commit refs/heads/main\n';
stream += `mark :${i}\n`;
stream += `committer Ack Trailer Fixture <ack-trailer-fixture@example.com> ${1700000000 + i} +0000\n`;
stream += `data ${Buffer.byteLength(message, 'utf-8')}\n`;
stream += message;
if (i > 1) stream += `from :${i - 1}\n`;
stream += '\n';
}
gitOrThrow(['fast-import', '--quiet'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS, input: stream });
return gitOrThrow(['rev-list', '--max-parents=0', 'HEAD'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS }).trim();
}
// ─── grammar and hostile keys (pure parser) — rows 8, 9-18, 28-30 ────────────
describe('grammar and hostile keys (pure parser)', () => {
test('trailer without a delimiter is rejected — row 9', () => {
const { hash, errors } = parseAckTrailers({ hash: ['no-delimiter-here'], growth: [] });
assert.equal(hash.size, 0);
assert.equal(errors.length, 1);
assert.match(errors[0], /delimiter/i);
});
test('empty reason is rejected — row 10', () => {
const { hash, errors } = parseAckTrailers({ hash: ['some/path — '], growth: [] });
assert.equal(hash.has('some/path'), false);
assert.equal(errors.length, 1);
assert.match(errors[0], /reason/i);
});
test('one-character reason is accepted — row 11', () => {
const { hash, errors } = parseAckTrailers({ hash: ['some/path — x'], growth: [] });
assert.deepEqual(errors, []);
assert.equal(hash.get('some/path').reason, 'x');
});
test('empty key is rejected — row 12', () => {
const { hash, errors } = parseAckTrailers({ hash: [' — reason text'], growth: [] });
assert.equal(hash.size, 0);
assert.equal(errors.length, 1);
assert.match(errors[0], /key/i);
});
test('reserved keys are rejected — row 13', () => {
const values = RESERVED_KEYS.map((k) => `${k} — ok`);
const { hash, errors } = parseAckTrailers({ hash: values, growth: [] });
assert.equal(hash.size, 0);
assert.equal(errors.length, RESERVED_KEYS.length);
for (const k of RESERVED_KEYS) {
assert.ok(errors.some((e) => e.includes(k)), `errors must name ${k}`);
}
});
test('placeholder-shaped key is rejected — row 14', () => {
const { hash, errors } = parseAckTrailers({ hash: ['<emitted/path> — reason'], growth: [] });
assert.equal(hash.size, 0);
assert.equal(errors.length, 1);
});
test('key with whitespace is rejected — row 15', () => {
const { hash, errors } = parseAckTrailers({ hash: ['foo bar.md — reason'], growth: [] });
assert.equal(hash.size, 0);
assert.equal(errors.length, 1);
});
test('identical duplicate trailer is deduped — row 16', () => {
const { hash, errors } = parseAckTrailers({
hash: ['dup/path — same reason', 'dup/path — same reason'],
growth: [],
});
assert.deepEqual(errors, []);
assert.equal(hash.size, 1);
assert.equal(hash.get('dup/path').reason, 'same reason');
});
test('conflicting duplicate trailer is rejected — row 17', () => {
const { hash, errors } = parseAckTrailers({
hash: ['dup/path — reason A', 'dup/path — reason B'],
growth: [],
});
assert.equal(errors.length, 1);
assert.match(errors[0], /ambiguous|conflict/i);
assert.equal(hash.has('dup/path'), false, 'an ambiguous declaration must not silently pick a winner');
});
test('same key in both spaces is legal — row 18', () => {
const { hash, growth, errors } = parseAckTrailers({
hash: ['shared-key — hash reason'],
growth: ['shared-key — growth reason'],
});
assert.deepEqual(errors, []);
assert.equal(hash.get('shared-key').reason, 'hash reason');
assert.equal(growth.get('shared-key').reason, 'growth reason');
});
test(
'stale trailer fails and names its space — row 8 '
+ '(reader-level analog: the two spaces stay in separate maps so a downstream '
+ 'consumer can name which space an unconsumed key belongs to; the staleAcks '
+ 'gating itself is diffEmitted\'s job and is not rewired by this stub-only change)',
() => {
const { hash, growth } = parseAckTrailers({
hash: ['a/b.md — hash-space reason'],
growth: ['c.md — growth-space reason'],
});
assert.equal(hash.has('a/b.md'), true);
assert.equal(growth.has('c.md'), true);
assert.equal(hash.has('c.md'), false, 'growth-space key must not leak into the hash map');
assert.equal(growth.has('a/b.md'), false, 'hash-space key must not leak into the growth map');
},
);
test('trailer value is trimmed — row 28', () => {
const { hash, errors } = parseAckTrailers({
hash: [' padded/path — reason with padding '],
growth: [],
});
assert.deepEqual(errors, []);
assert.equal(hash.get('padded/path').reason, 'reason with padding');
});
test('reason may contain an em dash — row 29', () => {
const { hash, errors } = parseAckTrailers({
hash: ['path/em — reason — with another em dash'],
growth: [],
});
assert.deepEqual(errors, []);
assert.equal(hash.get('path/em').reason, 'reason — with another em dash');
});
test('invisible characters cannot fake a distinct reason — row 30', () => {
// A zero-width space (U+200B) inserted inside an otherwise-identical reason. Once
// stripped, both entries read as the SAME prose — this must dedupe (row 16's rule),
// never register as a genuinely distinct (and therefore ambiguous, row 17) reason.
const { hash, errors } = parseAckTrailers({
hash: ['inv/path — same reason', 'inv/path — sam​e reason'],
growth: [],
});
assert.deepEqual(errors, []);
assert.equal(hash.size, 1);
assert.equal(hash.get('inv/path').reason, 'same reason');
});
});
// ─── boundary: MAX_ACK_TRAILERS cap — rows 19-21 ─────────────────────────────
function buildUniqueHashValues(n) {
return Array.from({ length: n }, (_, i) => `k${i}/path.md — reason ${i}`);
}
describe('boundary: MAX_ACK_TRAILERS cap', () => {
test('trailer count below the cap is accepted — row 19', () => {
const { hash, errors } = parseAckTrailers({
hash: buildUniqueHashValues(MAX_ACK_TRAILERS - 1),
growth: [],
});
assert.deepEqual(errors, []);
assert.equal(hash.size, MAX_ACK_TRAILERS - 1);
});
test('trailer count at the cap is accepted — row 20', () => {
const { hash, errors } = parseAckTrailers({
hash: buildUniqueHashValues(MAX_ACK_TRAILERS),
growth: [],
});
assert.deepEqual(errors, []);
assert.equal(hash.size, MAX_ACK_TRAILERS);
});
test('trailer count above the cap throws, never truncates — row 21', () => {
assert.throws(
() => parseAckTrailers({ hash: buildUniqueHashValues(MAX_ACK_TRAILERS + 1), growth: [] }),
(err) => {
assert.match(err.message, new RegExp(String(MAX_ACK_TRAILERS + 1)), 'must name the actual count');
assert.match(
err.message.replace(String(MAX_ACK_TRAILERS + 1), ''),
new RegExp(String(MAX_ACK_TRAILERS)),
'must name the cap',
);
return true;
},
'one over the cap must throw rather than silently truncating the listing',
);
});
test(
'100 identical repeats of ONE trailer do not throw — the cap is counted AFTER '
+ 'same-key dedup, not on the raw input count. A commit trailer, unlike the pre-'
+ '#3942 fragment-directory listing this cap descends from, legitimately survives a '
+ "rebase: `git log` over the range reports the SAME trailer text once per rebased "
+ 'commit it still lives on, so counting raw occurrences would throw on an entirely '
+ 'legitimate branch that never declared more than one distinct acknowledgment.',
() => {
const values = Array.from(
{ length: 100 },
() => 'rebased/path.md — identical reason on every rebased commit',
);
const { hash, errors } = parseAckTrailers({ hash: values, growth: [] });
assert.deepEqual(errors, []);
assert.equal(hash.size, 1, 'all 100 raw values collapse to the one distinct declaration they represent');
assert.equal(hash.get('rebased/path.md').reason, 'identical reason on every rebased commit');
},
);
});
// ─── real fixture reads via readAckTrailers — rows 1-7, 25-27, 31, 34, 35 ────
describe('real fixture reads via readAckTrailers', () => {
test('hash trailer excuses a rippled path — row 1', (t) => {
const dir = makeTempRepo('gsd-ack-trailer-1-');
withCleanup(t, dir);
commitMessage(dir, 'init\n\nbaseline commit, no trailer\n');
const baseSha = headSha(dir);
commitMessage(
dir,
`ripple the workflow\n\n${trailerLine(ACK_TRAILER_HASH, 'gsd-core/workflows/foo.md', 'deliberate ripple, #3942')}\n`,
);
const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir });
assert.equal(r.errors.length, 0);
assert.equal(r.hash.get('gsd-core/workflows/foo.md')?.reason, 'deliberate ripple, #3942');
assert.equal(r.growth.size, 0);
});
test('growth trailer excuses a grown file — row 2', (t) => {
const dir = makeTempRepo('gsd-ack-trailer-2-');
withCleanup(t, dir);
commitMessage(dir, 'init\n\nbaseline commit, no trailer\n');
const baseSha = headSha(dir);
commitMessage(
dir,
`grow the workflow\n\n${trailerLine(ACK_TRAILER_GROWTH, 'plan-phase.md', 'grew for a new step, #3942')}\n`,
);
const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir });
assert.equal(r.errors.length, 0);
assert.equal(r.growth.get('plan-phase.md')?.reason, 'grew for a new step, #3942');
assert.equal(r.hash.size, 0);
});
test('both spaces coexist in one range — row 3', (t) => {
const dir = makeTempRepo('gsd-ack-trailer-3-');
withCleanup(t, dir);
commitMessage(dir, 'init\n\nbaseline commit, no trailer\n');
const baseSha = headSha(dir);
commitMessage(
dir,
'hash and growth together\n\n'
+ `${trailerLine(ACK_TRAILER_HASH, 'gsd-core/workflows/bar.md', 'ripple reason')}\n`
+ `${trailerLine(ACK_TRAILER_GROWTH, 'bar.md', 'growth reason')}\n`,
);
const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir });
assert.equal(r.errors.length, 0);
assert.equal(r.hash.get('gsd-core/workflows/bar.md')?.reason, 'ripple reason');
assert.equal(r.growth.get('bar.md')?.reason, 'growth reason');
});
test(
'two trailers of the SAME name on one commit arrive as separate entries, not '
+ 'joined into one value (regression: `separator=1d` in the git --format string was '
+ 'a literal two-character value, not the `%x1d` escape — the record NEVER split on '
+ 'the real \\x1d byte the code expects, silently collapsing multiple same-key '
+ 'trailers into one joined string). The existing "both spaces coexist" test (row 3) '
+ 'uses Hash+Growth — two DIFFERENT trailer names — so it only exercises the field '
+ 'separator, never the value separator between multiple values of the SAME key; '
+ 'this test is what actually exercises `separator=` in the git --format string.',
(t) => {
const dir = makeTempRepo('gsd-ack-trailer-sep-');
withCleanup(t, dir);
commitMessage(dir, 'init\n\nbaseline commit, no trailer\n');
const baseSha = headSha(dir);
commitMessage(
dir,
'two same-name trailers, different keys\n\n'
+ `${trailerLine(ACK_TRAILER_HASH, 'one/path.md', 'reason one')}\n`
+ `${trailerLine(ACK_TRAILER_HASH, 'two/path.md', 'reason two')}\n`,
);
const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir });
assert.equal(r.errors.length, 0);
assert.equal(r.hash.size, 2, 'two distinct trailers of the same name must arrive as two entries');
assert.equal(r.hash.get('one/path.md')?.reason, 'reason one');
assert.equal(r.hash.get('two/path.md')?.reason, 'reason two');
},
);
test(
'two trailers of the SAME name and the SAME key with different reasons on one '
+ 'commit are surfaced as an ambiguous-conflict error, not silently merged '
+ '(regression, same root cause as the test immediately above: under the broken '
+ 'separator, this exact input produced NO error and a corrupted reason string like '
+ '"reason one1dtwo/path.md — reason two" — a truncation the ambiguous-duplicate '
+ 'error exists to catch, but never fired because the value never reached the '
+ 'parser split into two pieces)',
(t) => {
const dir = makeTempRepo('gsd-ack-trailer-sep-conflict-');
withCleanup(t, dir);
commitMessage(dir, 'init\n\nbaseline commit, no trailer\n');
const baseSha = headSha(dir);
commitMessage(
dir,
'two same-name trailers, same key, conflicting reasons\n\n'
+ `${trailerLine(ACK_TRAILER_HASH, 'same/path.md', 'reason A')}\n`
+ `${trailerLine(ACK_TRAILER_HASH, 'same/path.md', 'reason B')}\n`,
);
const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir });
assert.equal(r.hash.has('same/path.md'), false, 'an ambiguous declaration must not silently pick a winner');
assert.equal(r.errors.length, 1, 'the split must yield exactly two distinct raw values, not one merged one');
assert.match(r.errors[0], /ambiguous|conflict/i);
},
);
test('clean tree needs no trailer — row 4', (t) => {
const dir = makeTempRepo('gsd-ack-trailer-4-');
withCleanup(t, dir);
commitMessage(dir, 'init\n\nbaseline commit, no trailer\n');
const baseSha = headSha(dir);
commitMessage(dir, 'no-op change\n\nnothing to acknowledge\n');
const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir });
assert.equal(r.errors.length, 0);
assert.equal(r.hash.size, 0);
assert.equal(r.growth.size, 0);
});
test('growth trailer does not excuse a hash ripple — row 5', (t) => {
const dir = makeTempRepo('gsd-ack-trailer-5-');
withCleanup(t, dir);
commitMessage(dir, 'init\n\nbaseline commit, no trailer\n');
const baseSha = headSha(dir);
// A Growth trailer naming a slash-shaped emitted PATH, as if trying to excuse a
// hash ripple from the wrong namespace — the latent defect this row closes.
commitMessage(
dir,
`mis-scoped ack\n\n${trailerLine(ACK_TRAILER_GROWTH, 'gsd-core/workflows/baz.md', 'wrong space')}\n`,
);
const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir });
assert.equal(r.growth.get('gsd-core/workflows/baz.md')?.reason, 'wrong space');
assert.equal(r.hash.has('gsd-core/workflows/baz.md'), false, 'the growth space must never leak into the hash space');
});
test('hash trailer does not excuse growth — row 6', (t) => {
const dir = makeTempRepo('gsd-ack-trailer-6-');
withCleanup(t, dir);
commitMessage(dir, 'init\n\nbaseline commit, no trailer\n');
const baseSha = headSha(dir);
commitMessage(dir, `mis-scoped ack\n\n${trailerLine(ACK_TRAILER_HASH, 'qux.md', 'wrong space')}\n`);
const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir });
assert.equal(r.hash.get('qux.md')?.reason, 'wrong space');
assert.equal(r.growth.has('qux.md'), false, 'the hash space must never leak into the growth space');
});
test('trailer before the merge-base is out of range — row 7', (t) => {
const dir = makeTempRepo('gsd-ack-trailer-7-');
withCleanup(t, dir);
commitMessage(dir, 'fork point\n\nnothing to acknowledge yet\n');
commitMessage(
dir,
`topic ripple\n\n${trailerLine(ACK_TRAILER_HASH, 'topic-key.md', 'on the topic branch')}\n`,
{ branch: 'topic' },
);
checkout(dir, 'main');
commitMessage(dir, `main ripple\n\n${trailerLine(ACK_TRAILER_HASH, 'main-key.md', 'on main, after the fork')}\n`);
checkout(dir, 'topic');
const r = readAckTrailers({ baseRef: 'main', headRef: 'HEAD', cwd: dir });
assert.equal(r.hash.has('topic-key.md'), true, 'the topic-side trailer is in range via the merge-base');
assert.equal(r.hash.has('main-key.md'), false, 'the main-side trailer, off the fork, must be out of range');
});
test('empty range yields no acks — row 25', (t) => {
const dir = makeTempRepo('gsd-ack-trailer-25-');
withCleanup(t, dir);
commitMessage(dir, 'only commit\n\nno trailer\n');
const sha = headSha(dir);
const r = readAckTrailers({ baseRef: sha, headRef: 'HEAD', cwd: dir });
assert.equal(r.errors.length, 0);
assert.equal(r.hash.size, 0);
assert.equal(r.growth.size, 0);
});
test('single-commit range is read — row 26', (t) => {
const dir = makeTempRepo('gsd-ack-trailer-26-');
withCleanup(t, dir);
commitMessage(dir, 'base\n\nno trailer\n');
const baseSha = headSha(dir);
commitMessage(dir, `only one commit\n\n${trailerLine(ACK_TRAILER_HASH, 'single.md', 'only one commit in range')}\n`);
const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir });
assert.equal(r.hash.get('single.md')?.reason, 'only one commit in range');
assert.equal(r.hash.size, 1);
});
test('CRLF commit message parses identically — row 27', (t) => {
const dir = makeTempRepo('gsd-ack-trailer-27-');
withCleanup(t, dir);
commitMessage(dir, 'base\n\nno trailer\n');
const baseSha = headSha(dir);
const crlfMessage =
`crlf commit\r\n\r\n${trailerLine(ACK_TRAILER_HASH, 'crlf-key.md', 'same reason regardless of line ending')}\r\n`;
const msgFile = path.join(os.tmpdir(), `gsd-ack-trailer-crlf-${crypto.randomBytes(6).toString('hex')}.txt`);
fs.writeFileSync(msgFile, crlfMessage);
try {
gitOrThrow(['commit', '-q', '--allow-empty', '-F', msgFile], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
} finally {
cleanup(msgFile);
}
const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir });
assert.equal(
r.hash.get('crlf-key.md')?.reason,
'same reason regardless of line ending',
'a CRLF commit message must parse identically to LF — no stray \\r in the reason',
);
});
test('merge commit without a trailer is ignored — row 31', (t) => {
const dir = makeTempRepo('gsd-ack-trailer-31-');
withCleanup(t, dir);
commitMessage(dir, 'fork\n\nno trailer\n');
const forkSha = headSha(dir);
commitMessage(dir, 'topic change\n\nno trailer\n', { branch: 'topic' });
checkout(dir, 'main');
commitMessage(dir, 'main change\n\nno trailer\n');
const mergeMsgFile = path.join(os.tmpdir(), `gsd-ack-trailer-merge-${crypto.randomBytes(6).toString('hex')}.txt`);
fs.writeFileSync(mergeMsgFile, "Merge branch 'topic'\n\nno trailer here either\n");
try {
gitOrThrow(['merge', '--no-ff', '-q', '-F', mergeMsgFile, 'topic'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
} finally {
cleanup(mergeMsgFile);
}
const r = readAckTrailers({ baseRef: forkSha, headRef: 'HEAD', cwd: dir });
assert.equal(r.errors.length, 0);
assert.equal(r.hash.size, 0);
assert.equal(r.growth.size, 0);
});
test('mid-body mention is not a trailer — row 32', (t) => {
const dir = makeTempRepo('gsd-ack-trailer-32-');
withCleanup(t, dir);
commitMessage(dir, 'base\n\nno trailer\n');
const baseSha = headSha(dir);
const message =
'docs: teach the trailer syntax\n\n'
+ `This body mentions ${trailerLine(ACK_TRAILER_HASH, 'fake/path.md', 'sneaky inline mention')} inline, `
+ 'as an example within a sentence.\n\n'
+ 'A trailing paragraph that is not trailer-shaped, so the mention above cannot be '
+ 'mistaken for the trailer block.\n';
commitMessage(dir, message);
const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir });
assert.equal(r.hash.has('fake/path.md'), false, 'a mid-body mention must never parse as a live trailer');
assert.equal(r.errors.length, 0);
});
test(
'shrinkage needs no trailer — row 34 '
+ '(reader-level analog: the reader is diff-content-agnostic; the "never gated" '
+ 'behavior itself lives in diffEmitted, not rewired by this stub-only change)',
(t) => {
const dir = makeTempRepo('gsd-ack-trailer-34-');
withCleanup(t, dir);
commitMessage(dir, 'large\n\nno trailer\n');
const baseSha = headSha(dir);
commitMessage(dir, 'shrunk\n\nno trailer needed for shrinkage\n');
const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir });
assert.equal(r.errors.length, 0);
assert.equal(r.hash.size, 0);
assert.equal(r.growth.size, 0);
},
);
test(
'NEW_FILE_CAP is not excusable by a trailer — row 35 '
+ '(reader-level analog: the reader has no opinion on NEW_FILE_CAP and must read '
+ 'the trailer like any other hash entry; refusing to let it excuse the cap is '
+ 'diffEmitted\'s job, not rewired by this stub-only change)',
(t) => {
const dir = makeTempRepo('gsd-ack-trailer-35-');
withCleanup(t, dir);
commitMessage(dir, 'base\n\nno trailer\n');
const baseSha = headSha(dir);
commitMessage(
dir,
'new oversized file\n\n'
+ `${trailerLine(ACK_TRAILER_HASH, 'gsd-core/workflows/huge-new-file.md', 'trying to excuse a new-file-cap violation')}\n`,
);
const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir });
assert.equal(
r.hash.get('gsd-core/workflows/huge-new-file.md')?.reason,
'trying to excuse a new-file-cap violation',
);
},
);
// #4454 follow-on: a trailer buried mid-message by squash-merge concatenation is
// recovered per-bullet, without reintroducing the false-positive class the
// whole-message %(trailers:...) design deliberately avoids (row 32). These three
// rows mirror a real squash commit's shape directly (GitHub's own suffix, `* `
// bullet boundaries) rather than a synthetic simplification of it, since the whole
// point is that the ordinary `%(trailers:...)` reader cannot see through this shape.
test(
'a growth trailer buried mid-message by squash-merge concatenation is recovered — #4454',
(t) => {
const dir = makeTempRepo('gsd-ack-trailer-4454-buried-');
withCleanup(t, dir);
commitMessage(dir, 'base\n\nno trailer\n');
const baseSha = headSha(dir);
// Mirrors GitHub's real squash-merge shape: several `* <subject>` bullets
// concatenated, the ack trailer landing on a NON-LAST one, followed by more
// bullets and then GitHub's own appended suffix — the exact shape that made
// #4447's ack invisible to a later PR's emitted-attribution check.
commitMessage(
dir,
'fix(#9999): example squash-merged PR (#1000)\n\n'
+ '* fix(#9999): first commit\n\n'
+ 'Some body text for the first commit.\n\n'
+ '* fix(#9999): second commit grows a workflow file\n\n'
+ 'Body text.\n\n'
+ `${trailerLine(ACK_TRAILER_GROWTH, 'some-workflow.md', 'deliberate growth for #9999')}\n\n`
+ '* fix(#9999): third commit, unrelated\n\n'
+ 'More body text, landing AFTER the trailer above in the squashed message.\n\n'
+ '---------\n\n'
+ 'Co-authored-by: real-author <real@example.com>\n',
);
const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir });
assert.equal(r.errors.length, 0);
assert.equal(
r.growth.get('some-workflow.md')?.reason,
'deliberate growth for #9999',
'the buried trailer must be recovered even though a later bullet and ' +
"GitHub's own appended suffix follow it in the squashed message",
);
},
);
test(
'an ordinary commit with markdown bullets but no squash suffix finds nothing — #4454 (no false positive)',
(t) => {
const dir = makeTempRepo('gsd-ack-trailer-4454-ordinary-');
withCleanup(t, dir);
commitMessage(dir, 'base\n\nno trailer\n');
const baseSha = headSha(dir);
// A normal commit body using markdown bullets for ordinary prose — no GitHub
// squash suffix anywhere, so the sub-chunk recovery pass must not activate at
// all, regardless of what the bullets contain.
commitMessage(
dir,
'docs: explain the ack mechanism\n\n'
+ 'This adds documentation. Notes:\n\n'
+ '* first bullet, ordinary prose\n'
+ `* second bullet mentioning ${ACK_TRAILER_GROWTH}: foo.md${ACK_TRAILER_DELIM}not a real trailer, just prose in a bullet\n`
+ '* third bullet\n\n'
+ 'No real trailer in this commit.\n',
);
const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir });
assert.equal(r.errors.length, 0);
assert.equal(r.growth.size, 0, 'ordinary bullet prose must never be recognized as a trailer');
assert.equal(r.hash.size, 0);
},
);
test(
'a squash-shaped commit where one bullet merely MENTIONS trailer syntax mid-paragraph stays inert — #4454 (row 32, per-chunk)',
(t) => {
const dir = makeTempRepo('gsd-ack-trailer-4454-mention-');
withCleanup(t, dir);
commitMessage(dir, 'base\n\nno trailer\n');
const baseSha = headSha(dir);
// Squash-shaped (has the GitHub suffix), but the trailer-looking text inside
// its one bullet is MID-PARAGRAPH — followed by more prose within that SAME
// chunk — so it is not that chunk's own terminal line and must stay inert,
// exactly as the whole-message design already guaranteed for the un-squashed
// case (row 32).
commitMessage(
dir,
'chore: squash-shaped with mid-chunk mention (#1000)\n\n'
+ '* docs: explain the ack mechanism\n\n'
+ 'This docs commit teaches contributors the trailer format, e.g.\n'
+ `${trailerLine(ACK_TRAILER_GROWTH, 'some-file.md', 'example text in the docs body')}\n`
+ 'followed by more prose so it is NOT this chunk\'s terminal line.\n\n'
+ 'Co-Authored-By: Someone <someone@example.com>\n\n'
+ '---------\n\n'
+ 'Co-authored-by: real-author <real@example.com>\n',
);
const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir });
assert.equal(r.errors.length, 0);
assert.equal(r.growth.size, 0, 'a mid-paragraph mention of trailer syntax must never be recognized');
assert.equal(r.hash.size, 0);
},
);
// Isolated review finding (2026-09-08): a bare `.match()` against the squash-suffix
// pattern returns the FIRST occurrence scanning left-to-right, not the last. GitHub's
// OWN appended suffix is always the true tail of the message, so an EARLIER bullet's
// own body legitimately using a markdown horizontal rule (also `---------`-shaped,
// e.g. as a section break in prose) truncated the scan too early and silently
// excluded a LATER bullet's real ack from the split/parse pass — reintroducing the
// exact #4454 bug this whole function exists to fix. Reproduced directly against a
// real fixture before the fix (readAckTrailers returned an EMPTY growth map here);
// must find the ack after it.
test(
'an earlier bullet\'s own markdown HR does not truncate the scan before a LATER bullet\'s real ack — #4454 (last-match, not first-match)',
(t) => {
const dir = makeTempRepo('gsd-ack-trailer-4454-earlier-hr-');
withCleanup(t, dir);
commitMessage(dir, 'base\n\nno trailer\n');
const baseSha = headSha(dir);
commitMessage(
dir,
'chore: squash-shaped with an EARLIER HR before the real ack bullet (#1000)\n\n'
+ '* docs: a bullet with its own legitimate markdown HR\n\n'
+ 'Some docs text explaining something.\n\n'
+ '---------\n\n'
+ 'That has its own HR divider used as a section break in ordinary prose.\n\n'
+ '* fix: second bullet carries the real ack\n\n'
+ 'Body text.\n\n'
+ `${trailerLine(ACK_TRAILER_GROWTH, 'foo.md', 'real ack, must be recovered')}\n\n`
+ '* fix: third bullet, unrelated\n\n'
+ 'More text.\n\n'
+ '---------\n\n'
+ 'Co-authored-by: real-author <real@example.com>\n',
);
const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir });
assert.equal(r.errors.length, 0);
assert.equal(
r.growth.get('foo.md')?.reason, 'real ack, must be recovered',
'the real ack must be found even though an EARLIER bullet contains its own ' +
'HR-shaped divider before it',
);
},
);
});
// ─── hostile IO: uncomputable range, subprocess failure, timeout — rows 22-24 ─
describe('hostile IO: uncomputable range, subprocess failure, timeout', () => {
test('uncomputable range throws, never passes vacuously — row 22', (t) => {
const origin = makeTempRepo('gsd-ack-trailer-22-origin-');
withCleanup(t, origin);
commitMessage(origin, 'origin A\n\nfirst commit\n');
const shaA = headSha(origin);
commitMessage(origin, 'origin B\n\nsecond commit\n');
commitMessage(origin, 'origin C\n\nthird commit\n');
const clone = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-trailer-22-clone-'));
t.after(() => cleanup(clone));
gitOrThrow(['clone', '-q', '--depth', '1', `file://${origin}`, clone], {
cwd: origin,
timeoutMs: GIT_FIXTURE_TIMEOUT_MS,
});
assert.throws(
() => readAckTrailers({ baseRef: shaA, headRef: 'HEAD', cwd: clone }),
/./,
'a genuinely uncomputable merge-base (shallow clone missing the base object) must '
+ 'throw, never return an empty result',
);
});
test('git failure surfaces, not swallowed — row 23', (t) => {
const notARepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-trailer-23-'));
t.after(() => cleanup(notARepo));
assert.throws(
() => readAckTrailers({ baseRef: 'main', headRef: 'HEAD', cwd: notARepo }),
/./,
'a git failure (not a repository) must throw with the failure surfaced, never be '
+ 'read as "no trailers"',
);
});
test('git log is bounded by a timeout — row 24', (t) => {
const dir = makeTempRepo('gsd-ack-trailer-24-');
withCleanup(t, dir);
const rootSha = growHistoryFastImport(dir, 50000);
const start = Date.now();
assert.throws(
() => readAckTrailers({ baseRef: rootSha, headRef: 'HEAD', cwd: dir, timeoutMs: 20 }),
/time/i,
'an unreadable-in-time git call must throw a timeout, never hang',
);
assert.ok(
Date.now() - start < GIT_FIXTURE_TIMEOUT_MS,
'must fail fast — never ride out the full seam default',
);
});
});
// ─── round-trip and properties — rows 33, 36, 37 ─────────────────────────────
describe('round-trip and properties', () => {
test('taught syntax round-trips through the reader — row 33', () => {
const line = renderAckTrailer(ACK_TRAILER_HASH, 'gsd-core/workflows/plan-phase.md', 'converter change, #3942');
const prefix = `${ACK_TRAILER_HASH}:`;
assert.ok(line.startsWith(prefix), 'rendered line must start with its own trailer name');
const value = line.slice(prefix.length).trimStart();
const { hash, errors } = parseAckTrailers({ hash: [value], growth: [] });
assert.deepEqual(errors, []);
assert.equal(hash.get('gsd-core/workflows/plan-phase.md')?.reason, 'converter change, #3942');
});
const KEY_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789_./-'.split('');
const REASON_ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ,.#-'.split('');
const keyArb = fc.array(fc.constantFrom(...KEY_ALPHABET), { minLength: 1, maxLength: 24 })
.map((chars) => chars.join(''))
.filter((k) => !RESERVED_KEYS.includes(k));
const reasonArb = fc.array(fc.constantFrom(...REASON_ALPHABET), { minLength: 1, maxLength: 40 })
.map((chars) => chars.join('').trim())
.filter((s) => s.length > 0);
test('prop: trailer render/parse is bijective — row 36', () => {
fc.assert(
fc.property(keyArb, reasonArb, (key, reason) => {
const line = renderAckTrailer(ACK_TRAILER_HASH, key, reason);
const prefix = `${ACK_TRAILER_HASH}:`;
assert.ok(line.startsWith(prefix), 'rendered line must start with the trailer name');
const value = line.slice(prefix.length).trimStart();
const { hash, errors } = parseAckTrailers({ hash: [value], growth: [] });
assert.deepEqual(errors, []);
assert.equal(hash.get(key)?.reason, reason);
}),
{ seed: 3942, numRuns: 300 },
);
});
const uniqueKeysArb = fc.uniqueArray(keyArb, { minLength: 1, maxLength: 10 });
test(
'prop: every parsed entry lands in exactly one space, never silently dropped — row 37 '
+ '(reader-level analog of "every moved path lands in exactly one bucket": the full '
+ 'attributed/unattributable/acked conservation law is diffEmitted\'s, not this reader\'s)',
() => {
fc.assert(
fc.property(uniqueKeysArb, reasonArb, (keys, reason) => {
const hashValues = keys.map((k) => `${k}${ACK_TRAILER_DELIM}${reason}`);
const { hash, errors } = parseAckTrailers({ hash: hashValues, growth: [] });
assert.deepEqual(errors, []);
assert.equal(hash.size, keys.length, 'every unique, well-formed key must be conserved — none silently dropped');
for (const k of keys) {
assert.equal(hash.get(k).reason, reason);
}
}),
{ seed: 3942, numRuns: 300 },
);
},
);
});