* 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>
876 lines
39 KiB
JavaScript
876 lines
39 KiB
JavaScript
'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 — same 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 },
|
||
);
|
||
},
|
||
);
|
||
});
|