Files
msd-core/tests/emitted-ack-trailer.test.cjs
Tom Boucher fa41bfec5c enhance(#3942): the emitted-drift ack is PR-lifetime data — move it to a commit trailer (#3954)
* test(#3942): failing-first suite for the emitted-drift ack commit trailer

Binds 37 input classes from the phase test matrix to the behavior ADR-3942
specifies, before any of it exists. Stubs return benign empty values rather
than throwing, deliberately: several rows assert that something DOES throw
(cap overflow, uncomputable commit range), and a throwing stub would turn
those green for the wrong reason and destroy the red.

The two rows that carry the design's load:

- merge-base semantics. The range is $(git merge-base base HEAD)..HEAD, not
  base..HEAD, because changedPaths comes from `git diff base...HEAD` (three
  dot). Two-dot would let the ack set and the change set disagree about which
  commits are this PR's. The fixture forks a topic branch, puts a trailer on
  each side, and asserts only the topic-side trailer is in range.

- fail-closed on an uncomputable range. With fragments a depth-1 checkout
  passes VACUOUSLY, every fragment reading as brand-new. With trailers the
  range cannot be computed at all, and returning an empty set would silently
  disarm the gate, so it must throw. The fixture builds a genuine shallow
  clone rather than simulating one.

Also covers the self-inflicted case: this change's own documentation quotes
the trailer syntax, so an example landing at the end of a commit message would
arm a live acknowledgment keyed on the literal placeholder text. Keys carrying
angle brackets or whitespace are rejected.

Authored per the phase artifacts 40-design.md and 50-test-matrix.md.
Not yet run on the remote runner — this commit exists to be tested.

Refs #3942

* chore(#3942): move the emitted-drift ack to a commit trailer

Implements ADR-3942, superseding ADR-2719 section 3 and its #2789 amendment.
Sections 1, 2 and 4-7 are retained: the conservation law is unchanged, only the
storage of its escape hatch moved off the working tree.

An acknowledgment explains one PR's ripple, and the moment that PR merges the
ripple is in the base, so it can never clear anything again. It was stored in
permanent shared state anyway, and every consequence of that mismatch had to be
built and then maintained. The chain is #2789 -> #2914 -> #3078 -> #3842 ->
#3823 -> #3875, each fix generating the next defect, ending in a scheduled
sweeper whose own first PR could not merge itself.

Added
  parseAckTrailers + renderAckTrailer (pure) and readAckTrailers (IO shell),
  reading Emitted-Drift-Ack-Hash: / Emitted-Drift-Ack-Growth: trailers over
  the merge-base range. tests/emitted-ack-trailer.test.cjs, 37 cases, written
  failing-first and confirmed red before any of this existed.

Changed
  diffEmitted takes two structurally distinct key-space maps instead of one
  shared paths map. That closes a latent defect: the spaces were separated by
  convention only, so a growth key satisfied a hash lookup by naming
  coincidence. staleAcks now reports which space a key was declared in.
  REMEDIATION teaches the trailer, per space, with its example rendered through
  renderAckTrailer so the taught grammar cannot drift from what the parser
  accepts.

Removed
  the sweep workflow, the guard-no-ack-on-next job, the standalone linter and
  its lint:ci entry, the fragment directory and its three spent fragments, the
  legacy single-file union, and the baseAck/spentAcks mechanism -- spentness is
  now structural, not computed.

Two range properties carry the design and are pinned by tests rather than
asserted: the range is merge-base scoped, matching git diff base...HEAD, so an
already-merged trailer is out of range by construction; and an uncomputable
range throws instead of reading as zero acknowledgments, which is the inverse
of the fragment guard's vacuous pass.

Three deliberate observable changes, each disclosed in the changeset: the
unread runtime field is gone, the legacy file is no longer read, and cross-space
excusal no longer works.

Ten open PRs carry fragments and will meet a modify/delete conflict. Measured
before landing and accepted deliberately; the one-line migration is in the PR
body.

Verified: lint:ci exit 0. Remote runner to follow on this exact sha.

Refs #3942

* fix(#3942): silent trailer collapse, lost coverage, and an unbounded cap

Six findings from the orthogonal review round, all fixed in place.

BLOCKER -- two trailers of the same name on one commit collapsed silently.
readAckTrailers built `separator=1d` where git needs `separator=%x1d`: the
`separator=` value inside a %(trailers:...) placeholder is itself a
pretty-format string, so the bare hex was emitted as two literal characters
and the split on \x1d never matched. Two same-name trailers therefore joined
into one value with errors empty -- the first reason absorbing the second
entry's key. Silent truncation, the exact class MAX_ACK_TRAILERS throws to
prevent. Confirmed with od -c against real git output before and after.

The failing-first matrix did not catch it because its "both spaces coexist"
row uses Hash plus Growth -- different trailer NAMES -- so the value separator
was never exercised. Two regression tests now cover same-name trailers
directly.

Coverage recovered: normalizeAckReason and INVISIBLE stayed on the live path
via parseAckTrailers but lost every test when the old suite was pruned. Back
under test against the current surface -- all six invisible codepoints
individually, whitespace collapse, trim, CRLF, and two seeded fast-check
properties. Dropping any single codepoint now fails.

MAX_ACK_TRAILERS counted raw trailers before de-duplication, so one trailer
carried forward across rebased commits counted once per commit and could throw
on a legitimate branch. Now counts distinct entries; 100 identical repeats
dedupe to one.

diffEmitted validated baseline, current and changedPaths but not the new
ackHash/ackGrowth, so a bad shape raised an unhandled TypeError instead of an
error verdict -- the same defect shape this file documents for #2778.

Docs: CONTRIBUTING and TESTING-SUITES were rewritten only in their first
sections; the later passages still taught fragments, git rm and the deleted
guard, contradicting the new text directly above them. Finished.

Also extends lint-removed-but-needed to exempt docs/adr and docs/research.
That gate fails on any docs mention of a file deleted in the same diff, which
makes it impossible to document a deletion in the PR performing it -- an ADR's
whole job is naming what it retired. Exemption is narrow and comes with a test
proving the gate still fires for a live consumer elsewhere under docs/. A
guard that cannot fail is worse than no guard. Maintainer-approved.

CONTEXT.md names the retired machinery by role rather than by filename: its
generated projection lands in docs/, which that gate does scan.

Adds docs/how-to/acknowledge-emitted-drift.md. The required docs set is
Reference and Explanation, so the task quadrant can be empty with every gate
green -- and this change has a real multi-step journey, including the fragment
migration ten open PRs now need.

lint:ci exit 0.

Refs #3942

* docs(#3942): correct the duplicate-trailer rule in CONTRIBUTING

Both axes of the code review independently flagged the same passage, without
seeing each other's output.

It claimed two declarations of the same key are always "a hard, loudly-reported
error, not a silent last-wins". That is only half true, and the missing half is
the one contributors hit: identical declarations -- same key, same reason --
dedupe silently, because a trailer legitimately survives a rebase and reappears
on every rebased commit. Failing there would red a branch for doing nothing
wrong, which is exactly why the dedup exists.

Only a same-key/different-reason pair errors, and that one is a genuine
ambiguity about which explanation holds.

As written, the paragraph told a contributor that a rebase-carried trailer
breaks the gate -- the opposite of the behavior. CONTEXT.md's parallel entry
already stated it correctly; this brings CONTRIBUTING into line.

Doc-only, root-level markdown.

Refs #3942

* chore(#3942): backfill changeset PR number to 3954

---------

Co-authored-by: sim <sim@local>
2026-08-27 17:28:39 -04:00

710 lines
31 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));
}
// ─── 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',
);
},
);
});
// ─── 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);
commitMessage(dir, 'init\n\nbaseline\n');
const baseSha = headSha(dir);
commitMessage(dir, 'change\n\nno trailer\n');
const start = Date.now();
assert.throws(
// Speculative `timeoutMs`: the real reader is expected to bound its own git
// subprocess and throw a timeout rather than hang. The stub ignores every
// argument today, so this option name is not load-bearing for the RED result —
// it documents the contract the real implementation must honor.
() => readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir, timeoutMs: 1 }),
/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 },
);
},
);
});