* fix(#2914): never persist a spent emitted-drift ack on next tests/emitted-drift-ack.json held 34 spent #2834 entries merged via #2900. Every entry is scoped to the diff that introduced it (#2789), so once merged to next it is at the base by definition -- spent and inert. Its presence is still load-bearing though: each PR rewrites the paths map wholesale, making a persistent base copy a shared cell. Five of six conflicting PRs in the open queue collided on this file and nothing else. Deletes the stale document and adds a push-to-next guard asserting it stays absent. The guard is deliberately NOT wired into lint:ci -- a PR-lane check against the base is the #2768 shape #2789 exists to end. Closes #2914 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#2914): backfill changeset pr number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2914): per-PR ack fragments instead of one shared mutable file The emitted-drift acknowledgment lived in a single tests/emitted-drift-ack.json whose paths map every PR rewrote wholesale. That is a shared mutable cell: any two PRs needing an ack edit the same lines and conflict. Five of six conflicting PRs in the open queue collided on this file and nothing else. Acks now live as per-PR fragments under tests/emitted-drift-acks/, the same shape .changeset/ already uses to solve this exact problem. Two PRs pick different filenames, so they cannot collide, and fragments lingering on next are harmless rather than toxic. The legacy file's 35 entries are MIGRATED into a fragment, not deleted. An earlier delete-only attempt failed verification twice: the ratchet lost the spec-phase.md acknowledgment from #2779 and reported a 10-byte growth with no ack. Relocating preserves every acknowledgment. The legacy single file is still READ (unioned with the fragments) because five open PRs carry it; dropping support would break all of them. A duplicate path key across sources is a hard error, never last-wins. The push-to-next guard is retargeted accordingly: it now asserts only that the legacy SHARED file never reappears on next. Fragments may persist harmlessly. Closes #2914 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@
|
||||
const test = 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 { execFileSync } = require('node:child_process');
|
||||
@@ -49,7 +50,13 @@ const {
|
||||
currentSizes,
|
||||
readAckFile,
|
||||
readAckFileAtRef,
|
||||
readAckSources,
|
||||
readAckSourcesAtRef,
|
||||
listAckFragmentFiles,
|
||||
listAckFragmentFilesAtRef,
|
||||
ACK_REPO_PATH,
|
||||
ACK_DIR,
|
||||
ACK_DIR_REPO_PATH,
|
||||
baselineFamilyNamesAtRef,
|
||||
MANIFEST_FAMILIES,
|
||||
MINIMUM_MANIFEST_FAMILIES,
|
||||
@@ -62,14 +69,21 @@ const {
|
||||
} = require('./helpers/emitted-runtime.cjs');
|
||||
|
||||
const { EXPECTED_MANIFEST_COUNT, loadManifests } = require('./helpers/emitted-provenance.cjs');
|
||||
const { validateAckText } = require('../scripts/lint-emitted-drift-ack.cjs');
|
||||
const {
|
||||
validateAckText,
|
||||
assertAbsentOnNext,
|
||||
MAX_ACK_FRAGMENTS: MAX_ACK_FRAGMENTS_LINT,
|
||||
} = require('../scripts/lint-emitted-drift-ack.cjs');
|
||||
const {
|
||||
ACK_VERSION,
|
||||
ACK_FILE,
|
||||
ACK_DIR: ACK_DIR_PURE,
|
||||
NEW_FILE_CAP,
|
||||
MAX_ACK_FRAGMENTS,
|
||||
REMEDIATION,
|
||||
sourceSatisfiedBy,
|
||||
parseAck,
|
||||
mergeAckSources,
|
||||
diffEmitted,
|
||||
buildReport,
|
||||
formatReport,
|
||||
@@ -714,6 +728,13 @@ test('the pre-merge lint and the gate parser agree on what is schema-valid', ()
|
||||
['whitespace reason', '{"version":1,"paths":{"a.md":{"reason":" "}}}', false],
|
||||
['missing reason', '{"version":1,"paths":{"a.md":{}}}', false],
|
||||
['numeric reason', '{"version":1,"paths":{"a.md":42}}', false],
|
||||
// #2914 review: a `__proto__`/`constructor`/`prototype` key is a genuine OWN key on
|
||||
// the production path (`JSON.parse`, unlike a JS object literal), and both surfaces
|
||||
// must reject it outright rather than one silently filtering it and the other
|
||||
// erroring or mishandling it.
|
||||
['reserved key __proto__', '{"version":1,"paths":{"__proto__":{"reason":"ok"}}}', false],
|
||||
['reserved key constructor', '{"version":1,"paths":{"constructor":{"reason":"ok"}}}', false],
|
||||
['reserved key prototype', '{"version":1,"paths":{"prototype":{"reason":"ok"}}}', false],
|
||||
];
|
||||
|
||||
for (const [label, raw, expectedValid] of corpus) {
|
||||
@@ -737,6 +758,48 @@ test('the pre-merge lint and the gate parser agree on what is schema-valid', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('a __proto__/constructor/prototype ack key is rejected loudly by both surfaces, never silently dropped (#2914 review)', () => {
|
||||
// Built via JSON.parse — the production path — so the key is a genuine OWN property,
|
||||
// never the JS object-literal special case (`{__proto__: v}` sets the prototype and
|
||||
// yields zero own keys, which is what made the pre-fix regression test vacuous).
|
||||
for (const key of ['__proto__', 'constructor', 'prototype']) {
|
||||
const raw = JSON.stringify({ version: ACK_VERSION, paths: { [key]: { reason: 'hostile' } } });
|
||||
const doc = JSON.parse(raw);
|
||||
assert.deepEqual(Object.keys(doc.paths), [key], `JSON.parse must create a genuine own key for ${key}`);
|
||||
|
||||
const gate = parseAck(doc, { source: 'tests/emitted-drift-acks/1000-a.json' });
|
||||
assert.equal(gate.entries.size, 0, `${key} must never become a live ack entry`);
|
||||
assert.equal(gate.errors.length, 1);
|
||||
assert.match(gate.errors[0], /reserved/);
|
||||
assert.match(gate.errors[0], new RegExp(key));
|
||||
|
||||
const lint = validateAckText(raw, { source: 'tests/emitted-drift-acks/1000-a.json' });
|
||||
assert.equal(lint.schemaErrors.length, 1);
|
||||
assert.match(lint.schemaErrors[0], /reserved/);
|
||||
assert.match(lint.schemaErrors[0], new RegExp(key));
|
||||
|
||||
// Recognizably the SAME finding on both surfaces, not merely both non-empty.
|
||||
assert.equal(
|
||||
gate.errors[0].replace('tests/emitted-drift-acks/1000-a.json', 'SOURCE'),
|
||||
lint.schemaErrors[0].replace('tests/emitted-drift-acks/1000-a.json', 'SOURCE'),
|
||||
`${key}: parseAck and validateAckText must report the same finding`,
|
||||
);
|
||||
|
||||
assert.equal(({}).reason, undefined, 'Object.prototype must stay untouched throughout');
|
||||
}
|
||||
});
|
||||
|
||||
test('the pre-merge lint and the gate helpers agree on the fragment-count cap (#2914 review)', () => {
|
||||
// Duplicated by necessity (scripts/ ships, tests/ does not — see MAX_ACK_FRAGMENTS's
|
||||
// doc comment in both files), so this parity test is what keeps the two values from
|
||||
// silently drifting apart the way the schema rules above are held together.
|
||||
assert.equal(
|
||||
MAX_ACK_FRAGMENTS_LINT, MAX_ACK_FRAGMENTS,
|
||||
'scripts/lint-emitted-drift-ack.cjs and tests/helpers/emitted-diff.cjs must agree on '
|
||||
+ 'MAX_ACK_FRAGMENTS',
|
||||
);
|
||||
});
|
||||
|
||||
test('the lint additionally rejects a present-but-entryless document the parser accepts', () => {
|
||||
// This is policy, not schema, and the one place the two surfaces are MEANT to differ:
|
||||
// `parseAck` must treat `{}` as "no acks" (legal) so an absent-equivalent document
|
||||
@@ -772,6 +835,500 @@ test('the lint rejects a present-but-empty file rather than reading it as absent
|
||||
}
|
||||
});
|
||||
|
||||
test('validateAckText names the SOURCE it is checking, not a hardcoded literal (#2914)', () => {
|
||||
// Generalized so the lint can run the SAME rules over a fragment as over the legacy
|
||||
// file. A message that hardcoded tests/emitted-drift-ack.json would misname every
|
||||
// fragment's own errors.
|
||||
const r = validateAckText('{"version":1,"paths":{"a.md":{"reason":""}}}', {
|
||||
source: 'tests/emitted-drift-acks/1000-a.json',
|
||||
});
|
||||
assert.match(r.schemaErrors[0], /tests\/emitted-drift-acks\/1000-a\.json/);
|
||||
});
|
||||
|
||||
test('declaredKeys: only a schema-trustworthy document contributes keys for collision detection', () => {
|
||||
const { declaredKeys } = require('../scripts/lint-emitted-drift-ack.cjs');
|
||||
assert.deepEqual(declaredKeys(null), []);
|
||||
assert.deepEqual(declaredKeys('{ not json'), [], 'unparseable JSON contributes no keys');
|
||||
assert.deepEqual(declaredKeys('[]'), [], 'a non-object document contributes no keys');
|
||||
assert.deepEqual(declaredKeys('{"paths":{"a.md":{"reason":"r"}}}'), ['a.md']);
|
||||
assert.deepEqual(
|
||||
declaredKeys('{"paths":{"__proto__":{"reason":"r"}}}'), [],
|
||||
'__proto__ is excluded from collision detection as belt-and-suspenders — in practice '
|
||||
+ 'main() never reaches this on such a document, because validateAckText already '
|
||||
+ 'rejects it outright (#2914 review), so there is no schema-valid document left for '
|
||||
+ 'declaredKeys to see it on',
|
||||
);
|
||||
});
|
||||
|
||||
test('listFragmentFiles: absent directory is zero fragments, present directory is sorted .json only', () => {
|
||||
const { listFragmentFiles } = require('../scripts/lint-emitted-drift-ack.cjs');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-frag-'));
|
||||
try {
|
||||
assert.deepEqual(listFragmentFiles(path.join(dir, 'missing')), []);
|
||||
fs.writeFileSync(path.join(dir, '2000-z.json'), '{}');
|
||||
fs.writeFileSync(path.join(dir, '1000-a.json'), '{}');
|
||||
fs.writeFileSync(path.join(dir, 'notes.txt'), 'nope');
|
||||
assert.deepEqual(listFragmentFiles(dir), ['1000-a.json', '2000-z.json']);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('listFragmentFiles: exactly MAX_ACK_FRAGMENTS entries passes, one over fails loudly (#2914 review)', () => {
|
||||
const { listFragmentFiles } = require('../scripts/lint-emitted-drift-ack.cjs');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-frag-cap-'));
|
||||
try {
|
||||
for (let i = 0; i < MAX_ACK_FRAGMENTS; i++) {
|
||||
fs.writeFileSync(path.join(dir, `f-${String(i).padStart(4, '0')}.json`), '{}');
|
||||
}
|
||||
assert.equal(listFragmentFiles(dir).length, MAX_ACK_FRAGMENTS, 'at the cap must still pass');
|
||||
|
||||
fs.writeFileSync(path.join(dir, `f-${String(MAX_ACK_FRAGMENTS).padStart(4, '0')}.json`), '{}');
|
||||
assert.throws(
|
||||
() => listFragmentFiles(dir),
|
||||
(err) => {
|
||||
assert.match(err.message, new RegExp(dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS + 1)));
|
||||
assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS)));
|
||||
return true;
|
||||
},
|
||||
'one over the cap must throw, naming the directory, the cap, and the actual count',
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── guard-no-ack-on-next: presence itself is the failure (#2914) ────────────
|
||||
//
|
||||
// Unlike `validateAckText` above (a PR-lane shape lint that must let a live, well-formed
|
||||
// ack through), `assertAbsentOnNext` runs ONLY against `next` itself — see the
|
||||
// `guard-no-ack-on-next` job in `.github/workflows/test.yml`, gated on push to `next` — and
|
||||
// rejects PRESENCE outright, valid or not. Per the ack-lifecycle law (#2789), an entry
|
||||
// already at the base is spent the moment it merges, so the shape never matters here.
|
||||
// `assertAbsentOnNext` takes only the boolean `present` — an entryless-vs-populated
|
||||
// distinction is collapsed to that boolean before this function ever sees it (see
|
||||
// `main()`'s `fs.existsSync` call in `scripts/lint-emitted-drift-ack.cjs`), so no test
|
||||
// here can exercise that distinction: there is deliberately no separate "entryless"
|
||||
// case below, since one would be identical in input and assertion to the populated
|
||||
// case and would claim coverage the function structurally cannot provide.
|
||||
|
||||
test('assertAbsentOnNext passes when the file is absent — the healthy steady state', () => {
|
||||
const r = assertAbsentOnNext(false);
|
||||
assert.ok(r.ok);
|
||||
assert.match(r.message, /absent \(the healthy steady state\)/);
|
||||
});
|
||||
|
||||
test('assertAbsentOnNext fails when the file is present with entries, naming the file and the remedy', () => {
|
||||
const r = assertAbsentOnNext(true);
|
||||
assert.ok(!r.ok);
|
||||
assert.match(r.message, /tests\/emitted-drift-ack\.json exists on next/);
|
||||
assert.match(r.message, /spent and inert/);
|
||||
assert.match(r.message, /delete the file too/, 'must cite CONTRIBUTING.md\'s delete-the-file rule');
|
||||
assert.match(r.message, /git rm tests\/emitted-drift-ack\.json/, 'the remedy must be named, not just the problem');
|
||||
assert.match(
|
||||
r.message, /tests\/emitted-drift-acks\//,
|
||||
'#2914: the message must explain that acks now go in per-PR fragments, and that a '
|
||||
+ 'persisting fragment (unlike this legacy file) is harmless',
|
||||
);
|
||||
});
|
||||
|
||||
test('assertAbsentOnNext fed from a real next-like tree: absent passes, present fails (regression, #2914)', () => {
|
||||
// A throwaway directory standing in for `next`'s tree, so the check exercises real
|
||||
// fs.existsSync semantics on ACK_REPO_PATH rather than a hand-picked boolean.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-guard-next-'));
|
||||
const ackPath = path.join(dir, ACK_REPO_PATH);
|
||||
fs.mkdirSync(path.dirname(ackPath), { recursive: true });
|
||||
|
||||
assert.ok(
|
||||
assertAbsentOnNext(fs.existsSync(ackPath)).ok,
|
||||
'a fresh tree with no ack file must pass',
|
||||
);
|
||||
|
||||
// Reproduces the exact #2834/#2900 shape: 34 spent entries surviving on next.
|
||||
fs.writeFileSync(ackPath, JSON.stringify({ version: 1, paths: { 'a.md': { reason: 'spent' } } }));
|
||||
const r = assertAbsentOnNext(fs.existsSync(ackPath));
|
||||
assert.ok(!r.ok, 'a tree carrying the file, however well-formed, must fail');
|
||||
assert.match(r.message, /exists on next/);
|
||||
});
|
||||
|
||||
// ─── Per-PR ack fragments: mergeAckSources + readAckSources (#2914) ──────────
|
||||
//
|
||||
// #2914 replaces the single shared tests/emitted-drift-ack.json with per-PR fragments
|
||||
// under tests/emitted-drift-acks/, exactly the shape .changeset/ already uses to solve
|
||||
// the same "every PR rewrites one file wholesale" conflict problem. The legacy file is
|
||||
// still read and unioned in — five open PRs (#2818, #2812, #2728, #2566, #2531) carry it
|
||||
// — so BOTH surfaces must keep working, together and alone.
|
||||
|
||||
// `merged.paths` is deliberately built via `Object.create(null)` (see mergeAckSources's
|
||||
// doc comment: a fragment/legacy source naming a key `__proto__` must set a PROPERTY,
|
||||
// never the prototype). `assert.deepEqual`/`deepStrictEqual` compares `[[Prototype]]`
|
||||
// too, so a direct comparison against an ordinary `{}` literal fails on the prototype
|
||||
// alone even when every key/value matches. Round-tripping through JSON (exactly what a
|
||||
// real committed ack document goes through) normalizes it to a plain object for
|
||||
// assertion purposes without touching the production code under test.
|
||||
const plain = (o) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
test('mergeAckSources: a single source with no entries merges to an empty, legal document', () => {
|
||||
const { merged, errors } = mergeAckSources([]);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(plain(merged), { version: ACK_VERSION, paths: {} });
|
||||
});
|
||||
|
||||
test('mergeAckSources: fragments-only union with no overlap', () => {
|
||||
const { merged, errors } = mergeAckSources([
|
||||
{ source: 'tests/emitted-drift-acks/1000-a.json', doc: { version: ACK_VERSION, paths: { 'a.md': { reason: 'ra' } } } },
|
||||
{ source: 'tests/emitted-drift-acks/1001-b.json', doc: { version: ACK_VERSION, paths: { 'b.md': { reason: 'rb' } } } },
|
||||
]);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(plain(merged.paths), { 'a.md': { reason: 'ra' }, 'b.md': { reason: 'rb' } });
|
||||
});
|
||||
|
||||
test('mergeAckSources: legacy-only (a single source) merges through unchanged', () => {
|
||||
const { merged, errors } = mergeAckSources([
|
||||
{ source: ACK_FILE, doc: { version: ACK_VERSION, paths: { 'a.md': { reason: 'legacy' } } } },
|
||||
]);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(plain(merged.paths), { 'a.md': { reason: 'legacy' } });
|
||||
});
|
||||
|
||||
test('mergeAckSources: legacy file and fragments together, no overlap', () => {
|
||||
const { merged, errors } = mergeAckSources([
|
||||
{ source: ACK_FILE, doc: { version: ACK_VERSION, paths: { 'legacy.md': { reason: 'from legacy' } } } },
|
||||
{ source: 'tests/emitted-drift-acks/1000-a.json', doc: { version: ACK_VERSION, paths: { 'a.md': { reason: 'from fragment' } } } },
|
||||
]);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(plain(merged.paths), {
|
||||
'legacy.md': { reason: 'from legacy' },
|
||||
'a.md': { reason: 'from fragment' },
|
||||
});
|
||||
});
|
||||
|
||||
test('mergeAckSources: a duplicate key across two fragments is a loud error, never silent last-wins', () => {
|
||||
const { merged, errors } = mergeAckSources([
|
||||
{ source: 'tests/emitted-drift-acks/1000-a.json', doc: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'first' } } } },
|
||||
{ source: 'tests/emitted-drift-acks/1001-b.json', doc: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'second' } } } },
|
||||
]);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0], /duplicate ack for/);
|
||||
assert.match(errors[0], /1000-a\.json/, 'the error must name the first source');
|
||||
assert.match(errors[0], /1001-b\.json/, 'the error must name the second source');
|
||||
// First-wins is a deliberate, DOCUMENTED simplification (not silent): the caller is
|
||||
// told loudly via `errors`, and the merged doc still holds a well-defined value.
|
||||
assert.equal(merged.paths[WORKFLOW_KEY].reason, 'first');
|
||||
});
|
||||
|
||||
test('mergeAckSources: an empty fragments directory (represented as zero docs) is legal', () => {
|
||||
const { merged, errors } = mergeAckSources([]);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(plain(merged.paths), {});
|
||||
});
|
||||
|
||||
test('mergeAckSources: a malformed source (bad version) surfaces the same error parseAck would', () => {
|
||||
const { errors } = mergeAckSources([
|
||||
{ source: 'tests/emitted-drift-acks/1000-bad.json', doc: { version: 99, paths: {} } },
|
||||
]);
|
||||
assert.match(errors.join('\n'), /unsupported version 99/);
|
||||
});
|
||||
|
||||
test('mergeAckSources: a __proto__ key from a fragment is rejected, never merged in or used to pollute', () => {
|
||||
// MUST be built via JSON.parse, not a JS object literal: `{ '__proto__': v }` is the
|
||||
// special ObjectLiteral case that SETS THE PROTOTYPE and yields zero own keys, so a
|
||||
// fixture built that way is empty and never exercises this path at all (the exact
|
||||
// reason the previous version of this test was vacuous and failed with "Cannot read
|
||||
// properties of undefined"). `JSON.parse` is the production path and creates a genuine
|
||||
// own key.
|
||||
const doc = JSON.parse('{"version":' + ACK_VERSION + ',"paths":{"__proto__":{"reason":"hostile"}}}');
|
||||
assert.deepEqual(Object.keys(doc.paths), ['__proto__'], 'JSON.parse must create a genuine own key');
|
||||
|
||||
const { merged, errors } = mergeAckSources([
|
||||
{ source: 'tests/emitted-drift-acks/1000-a.json', doc },
|
||||
]);
|
||||
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0], /reserved/);
|
||||
assert.match(errors[0], /__proto__/);
|
||||
assert.deepEqual(plain(merged.paths), {}, 'a reserved key must never be merged into the document');
|
||||
assert.equal(Object.getPrototypeOf(merged.paths), null, 'merged.paths stays null-prototype');
|
||||
assert.equal(({}).reason, undefined, 'Object.prototype must be untouched');
|
||||
});
|
||||
|
||||
test('readAckSources: fragments-only on a real tree', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-frag-'));
|
||||
try {
|
||||
const fragDir = path.join(dir, 'acks');
|
||||
fs.mkdirSync(fragDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(fragDir, '1000-a.json'), JSON.stringify({ version: ACK_VERSION, paths: { 'a.md': { reason: 'ra' } } }));
|
||||
fs.writeFileSync(path.join(fragDir, '1001-b.json'), JSON.stringify({ version: ACK_VERSION, paths: { 'b.md': { reason: 'rb' } } }));
|
||||
|
||||
const { doc, errors } = readAckSources({ legacyPath: path.join(dir, 'emitted-drift-ack.json'), fragmentsDir: fragDir });
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(plain(doc.paths), { 'a.md': { reason: 'ra' }, 'b.md': { reason: 'rb' } });
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('readAckSources: legacy-only on a real tree (no fragments directory at all)', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-legacy-'));
|
||||
try {
|
||||
const legacyPath = path.join(dir, 'emitted-drift-ack.json');
|
||||
fs.writeFileSync(legacyPath, JSON.stringify({ version: ACK_VERSION, paths: { 'legacy.md': { reason: 'from legacy' } } }));
|
||||
|
||||
const { doc, errors } = readAckSources({ legacyPath, fragmentsDir: path.join(dir, 'nonexistent-acks') });
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(plain(doc.paths), { 'legacy.md': { reason: 'from legacy' } });
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('readAckSources: legacy file and fragments together', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-both-'));
|
||||
try {
|
||||
const legacyPath = path.join(dir, 'emitted-drift-ack.json');
|
||||
const fragDir = path.join(dir, 'acks');
|
||||
fs.mkdirSync(fragDir, { recursive: true });
|
||||
fs.writeFileSync(legacyPath, JSON.stringify({ version: ACK_VERSION, paths: { 'legacy.md': { reason: 'from legacy' } } }));
|
||||
fs.writeFileSync(path.join(fragDir, '1000-a.json'), JSON.stringify({ version: ACK_VERSION, paths: { 'a.md': { reason: 'from fragment' } } }));
|
||||
|
||||
const { doc, errors } = readAckSources({ legacyPath, fragmentsDir: fragDir });
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(plain(doc.paths), {
|
||||
'legacy.md': { reason: 'from legacy' },
|
||||
'a.md': { reason: 'from fragment' },
|
||||
});
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('readAckSources: neither the legacy file nor the fragments directory exists — the healthy steady state', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-absent-'));
|
||||
try {
|
||||
const { doc, errors } = readAckSources({
|
||||
legacyPath: path.join(dir, 'emitted-drift-ack.json'),
|
||||
fragmentsDir: path.join(dir, 'acks'),
|
||||
});
|
||||
assert.equal(doc, null);
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('readAckSources: an empty fragments directory (present, zero files) plus no legacy file', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-emptydir-'));
|
||||
try {
|
||||
const fragDir = path.join(dir, 'acks');
|
||||
fs.mkdirSync(fragDir, { recursive: true });
|
||||
const { doc, errors } = readAckSources({ legacyPath: path.join(dir, 'emitted-drift-ack.json'), fragmentsDir: fragDir });
|
||||
assert.equal(doc, null, 'zero fragments and no legacy file is still the healthy steady state');
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('readAckSources: a duplicate key across two fragments fails loudly and would fail the real gate', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-dupe-'));
|
||||
try {
|
||||
const fragDir = path.join(dir, 'acks');
|
||||
fs.mkdirSync(fragDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(fragDir, '1000-a.json'), JSON.stringify({ version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'first' } } }));
|
||||
fs.writeFileSync(path.join(fragDir, '1001-b.json'), JSON.stringify({ version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'second' } } }));
|
||||
|
||||
const { doc: ack, errors: mergeAckErrors } = readAckSources({
|
||||
legacyPath: path.join(dir, 'emitted-drift-ack.json'),
|
||||
fragmentsDir: fragDir,
|
||||
});
|
||||
assert.equal(mergeAckErrors.length, 1);
|
||||
assert.match(mergeAckErrors[0], /duplicate ack for/);
|
||||
|
||||
// Wired exactly as the real-tree test wires it: folded into diffEmitted's own
|
||||
// errors, which must fail the whole gate — never silently pass with one winner.
|
||||
const r = diffEmitted({
|
||||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||||
current: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||||
changedPaths: [],
|
||||
ack,
|
||||
baseAck: null,
|
||||
mergeAckErrors,
|
||||
});
|
||||
assert.ok(!r.ok, 'a duplicate ack across two fragments must fail the gate');
|
||||
assert.match(formatReport(r), /duplicate ack for/);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('readAckSources: a malformed fragment (invalid JSON) throws, naming the fragment file', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-malformed-'));
|
||||
try {
|
||||
const fragDir = path.join(dir, 'acks');
|
||||
fs.mkdirSync(fragDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(fragDir, '1000-bad.json'), '{ not json');
|
||||
|
||||
assert.throws(
|
||||
() => readAckSources({ legacyPath: path.join(dir, 'emitted-drift-ack.json'), fragmentsDir: fragDir }),
|
||||
/1000-bad\.json.*not valid JSON/,
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('readAckSources: listAckFragmentFiles returns sorted .json names only, absent dir is empty', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-listing-'));
|
||||
try {
|
||||
assert.deepEqual(listAckFragmentFiles(path.join(dir, 'missing')), []);
|
||||
|
||||
const fragDir = path.join(dir, 'acks');
|
||||
fs.mkdirSync(fragDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(fragDir, '2000-z.json'), '{}');
|
||||
fs.writeFileSync(path.join(fragDir, '1000-a.json'), '{}');
|
||||
fs.writeFileSync(path.join(fragDir, 'README.md'), 'not a fragment');
|
||||
assert.deepEqual(listAckFragmentFiles(fragDir), ['1000-a.json', '2000-z.json']);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('listAckFragmentFilesAtRef: lists .json fragment names at a ref directly, sorted, non-.json excluded', () => {
|
||||
const run = (args) => {
|
||||
assert.equal(args[0], 'ls-tree');
|
||||
assert.equal(args[args.length - 1], `${ACK_DIR_REPO_PATH}/`);
|
||||
return [
|
||||
`${ACK_DIR_REPO_PATH}/2000-z.json`,
|
||||
`${ACK_DIR_REPO_PATH}/1000-a.json`,
|
||||
`${ACK_DIR_REPO_PATH}/README.md`,
|
||||
].join('\n') + '\n';
|
||||
};
|
||||
assert.deepEqual(listAckFragmentFilesAtRef(SHA_A, { run }), ['1000-a.json', '2000-z.json']);
|
||||
});
|
||||
|
||||
test('listAckFragmentFilesAtRef: an absent fragment directory at the ref is zero names, not a fault', () => {
|
||||
const run = () => '\n';
|
||||
assert.deepEqual(listAckFragmentFilesAtRef(SHA_A, { run }), []);
|
||||
});
|
||||
|
||||
test('listAckFragmentFilesAtRef: a git failure listing the directory throws', () => {
|
||||
const run = () => { throw new Error('injected git failure'); };
|
||||
assert.throws(() => listAckFragmentFilesAtRef(SHA_A, { run }), /could not list tests\/emitted-drift-acks\//);
|
||||
});
|
||||
|
||||
test('listAckFragmentFiles: exactly MAX_ACK_FRAGMENTS entries passes, one over fails loudly (#2914 review)', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-listing-cap-'));
|
||||
try {
|
||||
for (let i = 0; i < MAX_ACK_FRAGMENTS; i++) {
|
||||
fs.writeFileSync(path.join(dir, `f-${String(i).padStart(4, '0')}.json`), '{}');
|
||||
}
|
||||
assert.equal(listAckFragmentFiles(dir).length, MAX_ACK_FRAGMENTS, 'at the cap must still pass');
|
||||
|
||||
fs.writeFileSync(path.join(dir, `f-${String(MAX_ACK_FRAGMENTS).padStart(4, '0')}.json`), '{}');
|
||||
assert.throws(
|
||||
() => listAckFragmentFiles(dir),
|
||||
(err) => {
|
||||
assert.match(err.message, new RegExp(dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS + 1)));
|
||||
assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS)));
|
||||
return true;
|
||||
},
|
||||
'one over the cap must throw, naming the directory, the cap, and the actual count',
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('listAckFragmentFilesAtRef: exactly MAX_ACK_FRAGMENTS entries passes, one over fails loudly (#2914 review)', () => {
|
||||
const makeListing = (count) => Array.from(
|
||||
{ length: count },
|
||||
(_, i) => `${ACK_DIR_REPO_PATH}/f-${String(i).padStart(4, '0')}.json`,
|
||||
).join('\n') + '\n';
|
||||
|
||||
const atCap = () => makeListing(MAX_ACK_FRAGMENTS);
|
||||
assert.equal(
|
||||
listAckFragmentFilesAtRef(SHA_A, { run: atCap }).length,
|
||||
MAX_ACK_FRAGMENTS,
|
||||
'at the cap must still pass',
|
||||
);
|
||||
|
||||
const overCap = () => makeListing(MAX_ACK_FRAGMENTS + 1);
|
||||
assert.throws(
|
||||
() => listAckFragmentFilesAtRef(SHA_A, { run: overCap }),
|
||||
(err) => {
|
||||
assert.match(err.message, new RegExp(`${ACK_DIR_REPO_PATH}/ at "${SHA_A}"`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS + 1)));
|
||||
assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS)));
|
||||
return true;
|
||||
},
|
||||
'one over the cap must throw, naming the directory, the cap, and the actual count',
|
||||
);
|
||||
});
|
||||
|
||||
test('the migrated fragment physically lives at ACK_DIR/0000-legacy-migration.json on this checkout', () => {
|
||||
// `includes`, not `deepEqual`, on purpose: other PRs merging their own fragments over
|
||||
// time must not make this permanent regression test fail — it only pins THIS
|
||||
// fragment's continued existence at the real, non-injected path.
|
||||
const fragmentPath = path.join(ACK_DIR, '0000-legacy-migration.json');
|
||||
assert.ok(fs.existsSync(fragmentPath), 'the migration must have landed at the real fragment directory path');
|
||||
assert.ok(listAckFragmentFiles(ACK_DIR).includes('0000-legacy-migration.json'));
|
||||
});
|
||||
|
||||
test('the migrated legacy fragment still clears the real #2733 spec-phase.md growth (#2914 migration)', () => {
|
||||
// The whole point of MIGRATING rather than deleting: no acknowledgment is lost, only
|
||||
// relocated. This pins the migrated fragment's spec-phase.md entry to the exact
|
||||
// growth #2733 introduced (31987 -> 31997 bytes), so a regression here would have
|
||||
// reproduced the ratchet failure a real verification run already caught once.
|
||||
const fragmentPath = path.join(REPO_ROOT, 'tests', 'emitted-drift-acks', '0000-legacy-migration.json');
|
||||
const doc = JSON.parse(fs.readFileSync(fragmentPath, 'utf8'));
|
||||
const entry = doc.paths['spec-phase.md'];
|
||||
assert.ok(entry, 'the migrated fragment must still carry the spec-phase.md entry from #2733');
|
||||
assert.match(entry.reason, /31987 -> 31997/, 'the exact byte delta must survive the migration');
|
||||
|
||||
// Isolated to JUST this one entry, not the whole 35-entry document: the migrated
|
||||
// fragment carries 34 OTHER already-spent entries for OTHER paths, which this
|
||||
// minimal repro's baseline/current never touches — including the full document here
|
||||
// would report those 34 as freshly-stale (nothing in THIS synthetic diff consumes
|
||||
// them), which is a fact about this test's narrow fixture, not about the migration.
|
||||
const r = diffEmitted({
|
||||
baseline: mf({}),
|
||||
current: mf({}),
|
||||
changedPaths: [],
|
||||
sizeBaseline: { 'spec-phase.md': 31987 },
|
||||
sizeCurrent: { 'spec-phase.md': 31997 },
|
||||
ack: { version: ACK_VERSION, paths: { 'spec-phase.md': entry } },
|
||||
baseAck: null,
|
||||
});
|
||||
assert.equal(r.grown.length, 1);
|
||||
assert.equal(r.grown[0].acked, true, 'the migrated entry must still clear the growth it was written for');
|
||||
assert.deepEqual(r.staleAcks, []);
|
||||
assert.ok(r.ok);
|
||||
});
|
||||
|
||||
test('the full migrated document is safe to land: every entry is SPENT against the legacy file at base, none stale', () => {
|
||||
// The actual migration PR's real shape: `next` still carries the legacy file with
|
||||
// these SAME 35 entries (until this PR removes it), so every entry in the new
|
||||
// fragment is already "spent" (base already explains it) rather than "live" — this
|
||||
// PR introduces no NEW ripple of its own, it only relocates old acknowledgments.
|
||||
// Getting this wrong (e.g. losing a reason's exact text in the move) would turn a
|
||||
// spent entry into a freshly-unexplained "stale" one and red this very migration PR.
|
||||
const fragmentPath = path.join(REPO_ROOT, 'tests', 'emitted-drift-acks', '0000-legacy-migration.json');
|
||||
const doc = JSON.parse(fs.readFileSync(fragmentPath, 'utf8'));
|
||||
|
||||
const r = diffEmitted({
|
||||
baseline: mf({}),
|
||||
current: mf({}),
|
||||
changedPaths: [],
|
||||
ack: doc,
|
||||
baseAck: doc, // the legacy file at `next` HEAD, byte-identical, before this PR removes it
|
||||
});
|
||||
assert.deepEqual(r.staleAcks, [], 'nothing in the migrated document should read as freshly unexplained');
|
||||
assert.equal(r.spentAcks.length, Object.keys(doc.paths).length, 'every migrated entry must be recognized as already spent');
|
||||
assert.ok(r.ok);
|
||||
});
|
||||
|
||||
// ─── readAckFileAtRef: the base-side reader (#2789) ──────────────────────────
|
||||
//
|
||||
// This half never runs in the remote runner — the real-tree test skips there, because a
|
||||
@@ -842,6 +1399,116 @@ test('readAckFileAtRef: refuses an option-shaped ref rather than handing it to g
|
||||
}
|
||||
});
|
||||
|
||||
// ─── readAckSourcesAtRef: the base-side UNION reader (#2914) ─────────────────
|
||||
//
|
||||
// Mirrors readAckFileAtRef's fakeGit harness above, extended to also answer `ls-tree`
|
||||
// on the FRAGMENT DIRECTORY and `show` for each fragment name it lists — a base-side
|
||||
// stand-in for "the legacy file plus every fragment, as they existed at that ref".
|
||||
|
||||
const fakeMultiGit = ({ legacy, fragments = {} } = {}) => (args) => {
|
||||
if (args[0] === 'ls-tree') {
|
||||
const target = args[args.length - 1];
|
||||
if (target === ACK_REPO_PATH) return legacy !== undefined ? `${ACK_REPO_PATH}\n` : '\n';
|
||||
if (target === `${ACK_DIR_REPO_PATH}/`) {
|
||||
const names = Object.keys(fragments);
|
||||
return names.length ? names.map((n) => `${ACK_DIR_REPO_PATH}/${n}`).join('\n') + '\n' : '\n';
|
||||
}
|
||||
// readAckFileAtRef's OWN per-file existence check, once per fragment name it was
|
||||
// told about by the directory listing above — a second, distinct ls-tree call.
|
||||
const name = target.slice(target.lastIndexOf('/') + 1);
|
||||
if (`${ACK_DIR_REPO_PATH}/${name}` === target && Object.prototype.hasOwnProperty.call(fragments, name)) {
|
||||
return `${target}\n`;
|
||||
}
|
||||
throw new Error(`fakeMultiGit: unexpected ls-tree target ${target}`);
|
||||
}
|
||||
if (args[0] === 'show') {
|
||||
const spec = args[1];
|
||||
const p = spec.slice(spec.indexOf(':') + 1);
|
||||
if (p === ACK_REPO_PATH) return legacy;
|
||||
const name = p.slice(p.lastIndexOf('/') + 1);
|
||||
if (Object.prototype.hasOwnProperty.call(fragments, name)) return fragments[name];
|
||||
throw new Error(`fakeMultiGit: unexpected show path ${p}`);
|
||||
}
|
||||
throw new Error(`fakeMultiGit: unexpected git call: ${args.join(' ')}`);
|
||||
};
|
||||
|
||||
test('readAckSourcesAtRef: fragments-only at a ref', () => {
|
||||
const { doc } = readAckSourcesAtRef(SHA_A, {
|
||||
run: fakeMultiGit({
|
||||
fragments: {
|
||||
'1000-a.json': JSON.stringify({ version: ACK_VERSION, paths: { 'a.md': { reason: 'ra' } } }),
|
||||
'1001-b.json': JSON.stringify({ version: ACK_VERSION, paths: { 'b.md': { reason: 'rb' } } }),
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(plain(doc.paths), { 'a.md': { reason: 'ra' }, 'b.md': { reason: 'rb' } });
|
||||
});
|
||||
|
||||
test('readAckSourcesAtRef: legacy-only at a ref (no fragments directory)', () => {
|
||||
const { doc } = readAckSourcesAtRef(SHA_A, {
|
||||
run: fakeMultiGit({ legacy: JSON.stringify({ version: ACK_VERSION, paths: { 'legacy.md': { reason: 'from legacy' } } }) }),
|
||||
});
|
||||
assert.deepEqual(plain(doc.paths), { 'legacy.md': { reason: 'from legacy' } });
|
||||
});
|
||||
|
||||
test('readAckSourcesAtRef: legacy and fragments together at a ref', () => {
|
||||
const { doc } = readAckSourcesAtRef(SHA_A, {
|
||||
run: fakeMultiGit({
|
||||
legacy: JSON.stringify({ version: ACK_VERSION, paths: { 'legacy.md': { reason: 'from legacy' } } }),
|
||||
fragments: { '1000-a.json': JSON.stringify({ version: ACK_VERSION, paths: { 'a.md': { reason: 'from fragment' } } }) },
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(plain(doc.paths), { 'legacy.md': { reason: 'from legacy' }, 'a.md': { reason: 'from fragment' } });
|
||||
});
|
||||
|
||||
test('readAckSourcesAtRef: neither legacy nor any fragment exists at the ref', () => {
|
||||
const { doc } = readAckSourcesAtRef(SHA_A, { run: fakeMultiGit({}) });
|
||||
assert.equal(doc, null);
|
||||
});
|
||||
|
||||
test('readAckSourcesAtRef: an empty fragments directory at the ref, no legacy file', () => {
|
||||
const { doc } = readAckSourcesAtRef(SHA_A, { run: fakeMultiGit({ fragments: {} }) });
|
||||
assert.equal(doc, null, 'zero fragments and no legacy file at the ref is still the healthy steady state');
|
||||
});
|
||||
|
||||
test('readAckSourcesAtRef: a base-side duplicate across fragments does not throw (discarded like other base schema issues)', () => {
|
||||
// Matches this module's existing precedent for the base side (see diffEmitted's real
|
||||
// -tree caller: "Base-side SCHEMA errors are deliberately discarded"). A base-side
|
||||
// duplicate is `next`'s own health, not this diff's to answer for, and
|
||||
// mergeAckSources's first-source-wins keeps the STRICT reading even with no error
|
||||
// surfaced -- an entry can only be spent against the ONE reason actually kept.
|
||||
const { doc } = readAckSourcesAtRef(SHA_A, {
|
||||
run: fakeMultiGit({
|
||||
fragments: {
|
||||
'1000-a.json': JSON.stringify({ version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'first' } } }),
|
||||
'1001-b.json': JSON.stringify({ version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'second' } } }),
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.equal(doc.paths[WORKFLOW_KEY].reason, 'first');
|
||||
});
|
||||
|
||||
test('readAckSourcesAtRef: a fragment that is unreadable at the ref still throws', () => {
|
||||
const fragRelPath = `${ACK_DIR_REPO_PATH}/1000-a.json`;
|
||||
const run = (args) => {
|
||||
if (args[0] === 'ls-tree') {
|
||||
const target = args[args.length - 1];
|
||||
if (target === ACK_REPO_PATH) return '\n';
|
||||
if (target === `${ACK_DIR_REPO_PATH}/`) return `${fragRelPath}\n`;
|
||||
// readAckFileAtRef's OWN existence check for the individual fragment file, prior
|
||||
// to `show` — it exists at this ref, so the failure below is a genuine read fault.
|
||||
if (target === fragRelPath) return `${fragRelPath}\n`;
|
||||
throw new Error(`unexpected ls-tree target: ${target}`);
|
||||
}
|
||||
if (args[0] === 'show') throw new Error('injected git failure');
|
||||
throw new Error(`unexpected git call: ${args.join(' ')}`);
|
||||
};
|
||||
assert.throws(
|
||||
() => readAckSourcesAtRef(SHA_A, { run }),
|
||||
/exists at .* but could not be read/,
|
||||
);
|
||||
});
|
||||
|
||||
test('OMITTING baseAck while an ack is present is a loud error, never a silent pass', () => {
|
||||
// This is what makes the production seam non-revertible in silence. Drop `baseAck:`
|
||||
// from the real-tree call and the gate fails loudly here, instead of quietly restoring
|
||||
@@ -1102,9 +1769,14 @@ test('the taught document derives its version from ACK_VERSION', () => {
|
||||
assert.equal(JSON.parse(REMEDIATION.ackDocument([{ key: 'x.md', reason: 'r' }])).version, ACK_VERSION);
|
||||
});
|
||||
|
||||
test('the remediation surface is frozen and names the ack file once', () => {
|
||||
test('the remediation surface is frozen and points at the fragment directory, not the legacy file', () => {
|
||||
// #2914: the remedy is a NEW fragment under ACK_DIR, never the single legacy file —
|
||||
// asserting `ackFile === ACK_FILE` here would pin the exact bandaid this design
|
||||
// replaces (a shared filename every PR is tempted back onto).
|
||||
assert.ok(Object.isFrozen(REMEDIATION), 'the exported surface must not be mutable');
|
||||
assert.equal(REMEDIATION.ackFile, ACK_FILE, 'one definition, not a second literal');
|
||||
assert.equal(REMEDIATION.ackDir, ACK_DIR_PURE, 'one definition, not a second literal');
|
||||
assert.ok(REMEDIATION.ackFile.startsWith(`${ACK_DIR_PURE}/`), 'the taught path must live under the fragment directory');
|
||||
assert.notEqual(REMEDIATION.ackFile, ACK_FILE, 'the remedy must not be the legacy shared file');
|
||||
});
|
||||
|
||||
test('a ripple and a growth in one report share ONE document', () => {
|
||||
@@ -2395,17 +3067,21 @@ test('differential attribution over the real tree', { timeout: 900_000 }, async
|
||||
assert.ok(baseline && Object.keys(baseline).length > 0, `resolved baseline via ${resolvedBaseline.via} has no families`);
|
||||
|
||||
const changedPaths = resolveChangedPaths(base);
|
||||
const ack = readAckFile();
|
||||
// #2914: unions the legacy single file with every per-PR fragment under
|
||||
// tests/emitted-drift-acks/. `mergeAckErrors` (e.g. two sources naming the same path)
|
||||
// is folded into `diffEmitted`'s own errors below, exactly like any other ack schema
|
||||
// problem — never silently resolved.
|
||||
const { doc: ack, errors: mergeAckErrors } = readAckSources();
|
||||
const current = currentManifests();
|
||||
|
||||
// Consult the base side ONLY when this tree actually has a document to classify.
|
||||
// `readAckFileAtRef` throws on a base it cannot read, which is right — but reading it
|
||||
// unconditionally would DEADLOCK the repo if `next` ever carried a corrupt ack (a bad
|
||||
// merge leaving conflict markers in exactly the file class this epic exists over):
|
||||
// `readAckSourcesAtRef` throws on a base it cannot read, which is right — but reading
|
||||
// it unconditionally would DEADLOCK the repo if `next` ever carried a corrupt ack (a
|
||||
// bad merge leaving conflict markers in exactly the file class this epic exists over):
|
||||
// every PR would go red, INCLUDING the PR that deletes the corrupt file and repairs
|
||||
// base. A tree carrying no ack has nothing to inherit, so it needs no base read — which
|
||||
// is precisely the shape of the repair PR, and it lands and unblocks everyone.
|
||||
const baseAck = ack === null ? null : readAckFileAtRef(baseSha);
|
||||
const baseAck = ack === null ? null : readAckSourcesAtRef(baseSha).doc;
|
||||
|
||||
// Reconcile the family SET across three independent signals, rather than asserting one
|
||||
// count against both sides. The baseline is built at the base ref and the current tree
|
||||
@@ -2443,6 +3119,7 @@ test('differential attribution over the real tree', { timeout: 900_000 }, async
|
||||
baseAck,
|
||||
sizeBaseline: resolvedBaseline.sizeBaseline,
|
||||
sizeCurrent: currentSizes(),
|
||||
mergeAckErrors,
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
* Given the emitted manifests at `next` HEAD and at PR HEAD, plus the repo paths the
|
||||
* PR actually changed, decide which moved emitted paths are EXPLAINED by the diff and
|
||||
* which are not. Unattributable deltas are a hard failure that names them; the only
|
||||
* way through is a committed acknowledgment (`tests/emitted-drift-ack.json`).
|
||||
* way through is a committed acknowledgment — a per-PR fragment under
|
||||
* `tests/emitted-drift-acks/` (#2914), or, for branches predating that split, the
|
||||
* legacy single `tests/emitted-drift-ack.json` — both are read and unioned (#2914).
|
||||
*
|
||||
* ── Why this module is pure ──────────────────────────────────────────────────
|
||||
* No fs, no git, no installer, no clock. The naive shape — one integration test that
|
||||
@@ -35,16 +37,43 @@ const { attributeEmittedPath } = require('./emitted-provenance.cjs');
|
||||
const ACK_VERSION = 1;
|
||||
|
||||
/**
|
||||
* The acknowledgment file, named ONCE (#2778).
|
||||
* Key names that can never be a legitimate emitted path or bare workflow/agent filename,
|
||||
* and that also happen to be the JS-object footguns (`__proto__`, `constructor`,
|
||||
* `prototype`). Rejected LOUDLY by `parseAck` rather than silently dropped: a document
|
||||
* naming one of these is always an authoring mistake (never a real path), and dropping it
|
||||
* quietly would let the SAME document pass `scripts/lint-emitted-drift-ack.cjs`'s
|
||||
* duplicate-detection (which excludes these keys for a different reason — see
|
||||
* `declaredKeys`'s doc comment there) while erroring differently here — exactly the
|
||||
* generative-fix-divergence class this repo's parity test exists to catch (#2914 review).
|
||||
*/
|
||||
const RESERVED_ACK_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
/**
|
||||
* The LEGACY acknowledgment file, named ONCE (#2778), still honored (#2914).
|
||||
*
|
||||
* This string was previously typed by hand in `formatReport`'s unattributable branch, in
|
||||
* `parseAck`'s default `source`, and again as `ACK_PATH` in emitted-runtime.cjs. Adding a
|
||||
* fourth copy for the growth branch is the *generative fix divergence* class this repo
|
||||
* records: parallel surfaces reading one shared value must not be able to drift. One
|
||||
* definition consumed by every branch is cheaper than a parity test over four literals.
|
||||
*
|
||||
* #2914 replaces this SINGLE SHARED FILE with per-PR fragments under `ACK_DIR` — a
|
||||
* single mutable document whose `paths` map every PR rewrites wholesale is a guaranteed
|
||||
* merge-conflict cell between any two PRs that both need an ack (5 of 6 conflicting PRs
|
||||
* in the open queue collided on this file and nothing else). The legacy path is still
|
||||
* read and unioned with the fragments directory so open PRs authored before the split
|
||||
* (#2818, #2812, #2728, #2566, #2531) are not broken by this change.
|
||||
*/
|
||||
const ACK_FILE = 'tests/emitted-drift-ack.json';
|
||||
|
||||
/**
|
||||
* The per-PR fragment directory (#2914) — the `.changeset/`-shaped fix to the same
|
||||
* shared-mutable-file problem `.changeset/` already solves: every fragment is a
|
||||
* separately-named file, so two PRs adding an ack can never conflict with each other,
|
||||
* and a fragment left behind on `next` after merge is inert rather than a shared cell.
|
||||
*/
|
||||
const ACK_DIR = 'tests/emitted-drift-acks';
|
||||
|
||||
/**
|
||||
* Distinguishes "caller omitted the base side" from "caller said there is none".
|
||||
*
|
||||
@@ -90,6 +119,24 @@ const INVISIBLE = new RegExp(
|
||||
*/
|
||||
const NEW_FILE_CAP = 32768;
|
||||
|
||||
/**
|
||||
* Upper bound on how many fragment files a `readdirSync` of `ACK_DIR` (or its
|
||||
* counterpart in `scripts/lint-emitted-drift-ack.cjs`) may return in one pass.
|
||||
*
|
||||
* 500 is ample headroom over any real repo's fragment count, which stays in the
|
||||
* single digits between releases (fragments are deleted once spent). Exceeding it
|
||||
* throws rather than silently truncating: a truncated listing would silently drop
|
||||
* acknowledgments from the merged set, which is exactly the class of silent failure
|
||||
* this whole ack seam exists to prevent — the fix for a directory this large is to
|
||||
* prune spent fragments, never to read only some of them.
|
||||
*
|
||||
* Duplicated (not imported) in `scripts/lint-emitted-drift-ack.cjs`, which cannot
|
||||
* require anything from `tests/` (it ships in the npm package; `tests/` does not).
|
||||
* The two are held to the same value by the schema-parity test in
|
||||
* `tests/emitted-attribution.test.cjs`.
|
||||
*/
|
||||
const MAX_ACK_FRAGMENTS = 500;
|
||||
|
||||
/**
|
||||
* Render the minimal valid acknowledgment document for a set of entries (#2778).
|
||||
*
|
||||
@@ -136,8 +183,16 @@ function ackDocument(entries) {
|
||||
* help text must not be a breaking change.
|
||||
*/
|
||||
const REMEDIATION = Object.freeze({
|
||||
ackFile: ACK_FILE,
|
||||
createIfAbsent: 'create the file if absent — it exists only when something needs acknowledging',
|
||||
// Deliberately NOT a fixed filename (#2914): the remedy is a NEW fragment under
|
||||
// `ACK_DIR`, and the whole point of a fragment directory is that its name is the
|
||||
// contributor's to pick — a fixed suggestion here would tempt everyone back onto one
|
||||
// shared filename, resurrecting the exact merge-conflict cell this design removes.
|
||||
ackFile: `${ACK_DIR}/<a-name-nobody-else-will-use>.json`,
|
||||
ackDir: ACK_DIR,
|
||||
createIfAbsent:
|
||||
`create a NEW file under ${ACK_DIR}/ — pick a name nobody else is using (include `
|
||||
+ 'this issue or PR number, e.g. `2914-fix.json`), and never reuse an existing '
|
||||
+ 'fragment\'s name',
|
||||
doNotRegenerate:
|
||||
'Do NOT regenerate anything to silence this — there is nothing left to regenerate.',
|
||||
/** The size ratchet keys on `entry.name` from readdirSync (emitted-runtime.cjs `currentSizes`). */
|
||||
@@ -147,13 +202,13 @@ const REMEDIATION = Object.freeze({
|
||||
rippleReason: '<why this ripple is deliberate>',
|
||||
growthReason: '<why this growth is deliberate>',
|
||||
staleAckFix:
|
||||
`Delete those entries from ${ACK_FILE}, or correct them to name the ripple you `
|
||||
+ 'actually made. If that leaves no entries, delete the file itself — an empty one '
|
||||
+ 'signals nothing.',
|
||||
`Delete those entries from your fragment under ${ACK_DIR}/, or correct them to name `
|
||||
+ 'the ripple you actually made. If that leaves no entries, delete the file itself '
|
||||
+ '— an empty one signals nothing.',
|
||||
spentAckNote:
|
||||
'These are inert, NOT a failure: the base already carries them, so their ripple is '
|
||||
+ `absorbed and they can no longer clear anything. Delete them from ${ACK_FILE} `
|
||||
+ 'whenever convenient.',
|
||||
+ `absorbed and they can no longer clear anything. Delete them from your fragment `
|
||||
+ `under ${ACK_DIR}/ whenever convenient.`,
|
||||
ackDocument,
|
||||
});
|
||||
|
||||
@@ -209,6 +264,19 @@ function parseAck(doc, { source = 'emitted-drift-ack.json' } = {}) {
|
||||
}
|
||||
|
||||
for (const [rel, value] of Object.entries(paths)) {
|
||||
if (RESERVED_ACK_KEYS.has(rel)) {
|
||||
// Reject loudly rather than silently drop. This is the fix for #2914 review: a
|
||||
// document naming `__proto__`/`constructor`/`prototype` used to be silently
|
||||
// accepted here (a genuine own key when the document comes from `JSON.parse`,
|
||||
// per the production path) while the lint filtered it out of duplicate detection
|
||||
// — two surfaces disagreeing about the SAME key is exactly the drift the parity
|
||||
// test below exists to catch.
|
||||
errors.push(
|
||||
`${source}: ack key "${rel}" is reserved and can never be a valid emitted path `
|
||||
+ 'or workflow/agent filename — remove it',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const reason = value && typeof value === 'object' ? value.reason : value;
|
||||
if (typeof reason !== 'string' || reason.trim() === '') {
|
||||
// "name them AND say why" is the contract (ADR-2719 §3). An ack with no reason
|
||||
@@ -225,6 +293,63 @@ function parseAck(doc, { source = 'emitted-drift-ack.json' } = {}) {
|
||||
return { entries, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Union multiple ack SOURCES into ONE document (#2914).
|
||||
*
|
||||
* `docs` is an ordered list of `{ source, doc }`, where `doc` is a parsed ack document
|
||||
* (or `null`) and `source` is a human label used only in error messages — a fragment's
|
||||
* repo-relative path, or the legacy file's path. Each source is parsed with the SAME
|
||||
* `parseAck` the single-document gate already uses, so the schema can never drift
|
||||
* between "one file" and "many files" — there is exactly one definition of what a valid
|
||||
* entry looks like, reused here rather than re-typed.
|
||||
*
|
||||
* ── The collision rule (#2914) ────────────────────────────────────────────────
|
||||
* Two sources declaring the SAME emitted/growth key is an ERROR, never a silent
|
||||
* last-wins merge. Two per-PR fragments are never supposed to name the same path — if
|
||||
* they do, at least one of them is wrong, or the world has already changed under one of
|
||||
* them since it was written — and silently letting the later source win would let a
|
||||
* fragment quietly retire an earlier one's acknowledgment with zero signal in the diff.
|
||||
* That is exactly the class of silent drift the acknowledgment seam (#2789 spent/live
|
||||
* lifecycle) exists to end: an ack that stops explaining anything must be conspicuous,
|
||||
* never invisible. Failing loudly, with both source names in the message, is the only
|
||||
* reading consistent with the rest of this module's "unknown fails toward the strict
|
||||
* side" law (see `readAckFileAtRef`'s doc comment for the base-side version of the same
|
||||
* principle).
|
||||
*
|
||||
* @param {Array<{source: string, doc: object|null}>} docs
|
||||
* @returns {{ merged: {version: number, paths: object}, errors: string[] }}
|
||||
*/
|
||||
function mergeAckSources(docs) {
|
||||
const errors = [];
|
||||
// Null-prototype for the same reason `ackDocument` uses one above: an ordinary `{}`
|
||||
// would turn an assignment keyed `__proto__` into setting the prototype rather than a
|
||||
// property. `parseAck` now rejects `RESERVED_ACK_KEYS` outright (#2914 review) so
|
||||
// `entries` below can never actually carry one — this is belt-and-suspenders against
|
||||
// the day that stops being true, not the current enforcement point.
|
||||
const paths = Object.create(null);
|
||||
const owner = new Map(); // rel -> source that already claimed it, for the error message
|
||||
|
||||
for (const { source, doc } of docs) {
|
||||
const { entries, errors: parseErrors } = parseAck(doc, { source });
|
||||
errors.push(...parseErrors);
|
||||
for (const [rel, entry] of entries) {
|
||||
if (owner.has(rel)) {
|
||||
errors.push(
|
||||
`duplicate ack for "${rel}": declared in both ${owner.get(rel)} and ${source}. `
|
||||
+ 'Two ack sources may never name the same path — rename or merge the fragments.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
owner.set(rel, source);
|
||||
paths[rel] = entry.runtime !== undefined
|
||||
? { reason: entry.reason, runtime: entry.runtime }
|
||||
: { reason: entry.reason };
|
||||
}
|
||||
}
|
||||
|
||||
return { merged: { version: ACK_VERSION, paths }, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* The conservation law.
|
||||
*
|
||||
@@ -259,6 +384,13 @@ function parseAck(doc, { source = 'emitted-drift-ack.json' } = {}) {
|
||||
* absent there). Required once `ack` is non-null.
|
||||
* @param {object} [opts.sizeBaseline] { [name]: bytes } workflow/agent sizes at next
|
||||
* @param {object} [opts.sizeCurrent] { [name]: bytes } workflow/agent sizes at PR HEAD
|
||||
* @param {string[]} [opts.mergeAckErrors] errors already discovered while UNIONING
|
||||
* `ack` from multiple physical sources (`mergeAckSources`, #2914) — e.g. two fragments
|
||||
* naming the same path. This module never touches the filesystem, so it cannot
|
||||
* discover a cross-file collision on its own; the shell layer that reads the legacy
|
||||
* file plus every fragment computes this and folds it in verbatim so a duplicate
|
||||
* fails the gate exactly like any other ack schema error, rather than silently
|
||||
* resolving via last-wins.
|
||||
*
|
||||
* @returns {{
|
||||
* moved: number, attributed: Array, unattributable: Array, acked: Array,
|
||||
@@ -274,6 +406,7 @@ function diffEmitted({
|
||||
baseAck = BASE_ACK_OMITTED,
|
||||
sizeBaseline = null,
|
||||
sizeCurrent = null,
|
||||
mergeAckErrors = [],
|
||||
} = {}) {
|
||||
const errors = [];
|
||||
|
||||
@@ -306,6 +439,8 @@ function diffEmitted({
|
||||
const changedSet = new Set(changedPaths);
|
||||
const { entries: declaredAcks, errors: ackErrors } = parseAck(ack);
|
||||
errors.push(...ackErrors);
|
||||
// Folded in verbatim, not re-derived: see `mergeAckErrors`'s doc comment above.
|
||||
errors.push(...mergeAckErrors);
|
||||
|
||||
// Keyed on DECLARED ENTRIES, not on the document being non-null. `{}`, `{version:1}`
|
||||
// and `{paths:{}}` all carry zero acks and `parseAck` calls them legal, so demanding a
|
||||
@@ -702,10 +837,13 @@ function formatReport(result, { sampleLimit = 20 } = {}) {
|
||||
module.exports = {
|
||||
ACK_VERSION,
|
||||
ACK_FILE,
|
||||
ACK_DIR,
|
||||
NEW_FILE_CAP,
|
||||
MAX_ACK_FRAGMENTS,
|
||||
REMEDIATION,
|
||||
sourceSatisfiedBy,
|
||||
parseAck,
|
||||
mergeAckSources,
|
||||
diffEmitted,
|
||||
buildReport,
|
||||
formatReport,
|
||||
|
||||
@@ -44,15 +44,45 @@ const {
|
||||
buildParityManifest,
|
||||
PKG_VERSION,
|
||||
} = require('./install-shared.cjs');
|
||||
const { mergeAckSources, MAX_ACK_FRAGMENTS } = require('./emitted-diff.cjs');
|
||||
|
||||
/**
|
||||
* Fail loudly when a fragment listing exceeds `MAX_ACK_FRAGMENTS`, naming the
|
||||
* directory, the cap, and the actual count. Never truncate: a silently-truncated
|
||||
* listing would silently drop acknowledgments, which is exactly the class of silent
|
||||
* failure the ack seam exists to prevent (see `MAX_ACK_FRAGMENTS`'s doc comment in
|
||||
* `emitted-diff.cjs`).
|
||||
*/
|
||||
function assertFragmentCountWithinCap(dirLabel, names) {
|
||||
if (names.length > MAX_ACK_FRAGMENTS) {
|
||||
throw new Error(
|
||||
`emitted-attribution: ${dirLabel} contains ${names.length} ack fragments, `
|
||||
+ `exceeding the cap of ${MAX_ACK_FRAGMENTS}. Refusing to read only some of them — a `
|
||||
+ 'truncated read would silently drop acknowledgments. Prune spent fragments from '
|
||||
+ 'this directory.',
|
||||
);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
/**
|
||||
* Repo-relative and POSIX-separated on every platform: this form is what `git show
|
||||
* <ref>:<path>` requires, and git speaks only forward slashes regardless of host OS.
|
||||
* `ACK_PATH` derives from it so the two can never name different files.
|
||||
*
|
||||
* LEGACY single-file path (#2778), still honored and unioned with `ACK_DIR_REPO_PATH`
|
||||
* below (#2914) — open PRs authored before the fragment split still carry this file.
|
||||
*/
|
||||
const ACK_REPO_PATH = 'tests/emitted-drift-ack.json';
|
||||
const ACK_PATH = path.join(REPO_ROOT, ...ACK_REPO_PATH.split('/'));
|
||||
/**
|
||||
* Per-PR fragment directory (#2914). Every fragment is independently named, so two PRs
|
||||
* that each need an ack can never collide on this path the way they always did on the
|
||||
* single legacy file above.
|
||||
*/
|
||||
const ACK_DIR_REPO_PATH = 'tests/emitted-drift-acks';
|
||||
const ACK_DIR = path.join(REPO_ROOT, ...ACK_DIR_REPO_PATH.split('/'));
|
||||
const FIXTURE_SUBDIR = 'tests/fixtures/golden-install-parity';
|
||||
|
||||
/**
|
||||
@@ -324,8 +354,13 @@ function resolveBaseSha(base = 'origin/next') {
|
||||
* the same "does not exist in" message — so absence is established with `ls-tree`, which
|
||||
* exits 0 with empty output when the path is simply not there and non-zero on a real
|
||||
* fault.
|
||||
*
|
||||
* `repoPath` defaults to the legacy single file, but is generalized (#2914) so the same
|
||||
* read-at-ref logic serves any one fragment under `ACK_DIR_REPO_PATH` too — there is
|
||||
* exactly one implementation of "read this ack path at that ref", reused per source
|
||||
* rather than re-typed per fragment.
|
||||
*/
|
||||
function readAckFileAtRef(base, { cwd = REPO_ROOT, run = git } = {}) {
|
||||
function readAckFileAtRef(base, { cwd = REPO_ROOT, run = git, repoPath = ACK_REPO_PATH } = {}) {
|
||||
// `execFileSync`'s array form stops SHELL metacharacters but not git's own option
|
||||
// parsing: a ref beginning with `-` is read as an option token, and `git show` honors
|
||||
// diff options including `--output=<file>`, which writes. Today every caller passes a
|
||||
@@ -340,7 +375,7 @@ function readAckFileAtRef(base, { cwd = REPO_ROOT, run = git } = {}) {
|
||||
|
||||
let listing;
|
||||
try {
|
||||
listing = run(['ls-tree', '--name-only', base, '--', ACK_REPO_PATH], { cwd });
|
||||
listing = run(['ls-tree', '--name-only', base, '--', repoPath], { cwd });
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`emitted-attribution: could not list the ack at "${base}": ${err.message}. This is a `
|
||||
@@ -352,24 +387,131 @@ function readAckFileAtRef(base, { cwd = REPO_ROOT, run = git } = {}) {
|
||||
|
||||
let raw;
|
||||
try {
|
||||
raw = run(['show', `${base}:${ACK_REPO_PATH}`], { cwd });
|
||||
raw = run(['show', `${base}:${repoPath}`], { cwd });
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`emitted-attribution: ${ACK_REPO_PATH} exists at "${base}" but could not be read: ${err.message}`,
|
||||
`emitted-attribution: ${repoPath} exists at "${base}" but could not be read: ${err.message}`,
|
||||
);
|
||||
}
|
||||
if (raw.trim() === '') {
|
||||
throw new Error(`emitted-attribution: ${ACK_REPO_PATH} is present at "${base}" but empty`);
|
||||
throw new Error(`emitted-attribution: ${repoPath} is present at "${base}" but empty`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`emitted-attribution: ${ACK_REPO_PATH} at "${base}" is not valid JSON: ${err.message}`,
|
||||
`emitted-attribution: ${repoPath} at "${base}" is not valid JSON: ${err.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragment filenames present under `ACK_DIR_REPO_PATH` AT `base`, sorted.
|
||||
*
|
||||
* Mirrors `readAckFileAtRef`'s absence handling: `ls-tree` on a directory that does not
|
||||
* exist at that ref exits 0 with empty output, which this reads as "no fragments there"
|
||||
* — the healthy steady state, not a fault. A genuine git failure (bad ref, corrupt
|
||||
* object) still throws, for the same reason `readAckFileAtRef` throws on one: silently
|
||||
* reading "could not list" as "nothing there" would leave every fragment ack able to
|
||||
* consume a delta it should not.
|
||||
*/
|
||||
function listAckFragmentFilesAtRef(base, { cwd = REPO_ROOT, run = git } = {}) {
|
||||
let out;
|
||||
try {
|
||||
out = run(['ls-tree', '--name-only', base, '--', `${ACK_DIR_REPO_PATH}/`], { cwd });
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`emitted-attribution: could not list ${ACK_DIR_REPO_PATH}/ at "${base}": ${err.message}.`,
|
||||
);
|
||||
}
|
||||
const names = out
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.filter((p) => p.endsWith('.json'))
|
||||
.map((p) => p.slice(p.lastIndexOf('/') + 1))
|
||||
.sort();
|
||||
return assertFragmentCountWithinCap(`${ACK_DIR_REPO_PATH}/ at "${base}"`, names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragment filenames present under `ACK_DIR` on THIS tree (the working copy), sorted.
|
||||
* Absent directory == zero fragments, the healthy steady state — not a fault.
|
||||
*/
|
||||
function listAckFragmentFiles(dir = ACK_DIR) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const names = fs.readdirSync(dir)
|
||||
.filter((name) => name.endsWith('.json'))
|
||||
.sort();
|
||||
return assertFragmentCountWithinCap(dir, names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read + union every ack source on THIS tree (#2914): the legacy single file (if
|
||||
* present) plus every fragment under `ACK_DIR`. Reuses `readAckFile` per physical file
|
||||
* (same absent/empty/unparseable rules for a fragment as for the legacy file — one
|
||||
* definition, not a second one per source) and `mergeAckSources` (tests/helpers/
|
||||
* emitted-diff.cjs) for the union + duplicate-key detection.
|
||||
*
|
||||
* Returns `{ doc: null, errors: [] }` only when NEITHER the legacy file nor any
|
||||
* fragment exists — the healthy steady state matching `readAckFile`'s own `null`
|
||||
* contract, so callers can keep testing `ack === null` to decide whether the base side
|
||||
* needs consulting at all (avoiding the deadlock `readAckFileAtRef`'s doc comment
|
||||
* describes for a corrupt base).
|
||||
*
|
||||
* @returns {{ doc: {version: number, paths: object} | null, errors: string[] }}
|
||||
*/
|
||||
function readAckSources({ legacyPath = ACK_PATH, fragmentsDir = ACK_DIR } = {}) {
|
||||
const docs = [];
|
||||
if (fs.existsSync(legacyPath)) {
|
||||
docs.push({ source: ACK_REPO_PATH, doc: readAckFile(legacyPath) });
|
||||
}
|
||||
for (const name of listAckFragmentFiles(fragmentsDir)) {
|
||||
docs.push({
|
||||
source: `${ACK_DIR_REPO_PATH}/${name}`,
|
||||
doc: readAckFile(path.join(fragmentsDir, name)),
|
||||
});
|
||||
}
|
||||
if (docs.length === 0) return { doc: null, errors: [] };
|
||||
const { merged, errors } = mergeAckSources(docs);
|
||||
return { doc: merged, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read + union every ack source AT `base` (#2914): the legacy single file plus every
|
||||
* fragment, as they existed at that ref. Mirrors `readAckSources` above, one ref-read
|
||||
* per physical source via `readAckFileAtRef`'s now-generalized `repoPath` option.
|
||||
*
|
||||
* Base-side merge/schema errors are DELIBERATELY DISCARDED, matching this module's
|
||||
* existing precedent for the base side (see `diffEmitted`'s caller below: "Base-side
|
||||
* SCHEMA errors are deliberately discarded... a document we cannot read simply inherits
|
||||
* nothing — which is the ARMED reading"). A cross-fragment collision found only at the
|
||||
* base is `next`'s own health, not this diff's to answer for; `mergeAckSources`'s
|
||||
* first-source-wins fallback for a duplicate key is still the STRICT reading here (an
|
||||
* entry can only be "spent" against the ONE reason kept, never either of two), so
|
||||
* discarding the error text costs no protection while avoiding a lint-clean PR being
|
||||
* blocked by a historical duplicate it did not introduce and cannot fix by itself.
|
||||
*
|
||||
* A genuine READ failure (corrupt JSON, unreadable object) on any single source still
|
||||
* throws, exactly as `readAckFileAtRef` already does — only the schema/collision
|
||||
* bookkeeping is discarded, never a fault.
|
||||
*
|
||||
* @returns {{ doc: {version: number, paths: object} | null }}
|
||||
*/
|
||||
function readAckSourcesAtRef(base, { cwd = REPO_ROOT, run = git } = {}) {
|
||||
const docs = [];
|
||||
const legacyDoc = readAckFileAtRef(base, { cwd, run });
|
||||
if (legacyDoc !== null) docs.push({ source: ACK_REPO_PATH, doc: legacyDoc });
|
||||
for (const name of listAckFragmentFilesAtRef(base, { cwd, run })) {
|
||||
const relPath = `${ACK_DIR_REPO_PATH}/${name}`;
|
||||
const doc = readAckFileAtRef(base, { cwd, run, repoPath: relPath });
|
||||
if (doc !== null) docs.push({ source: relPath, doc });
|
||||
}
|
||||
if (docs.length === 0) return { doc: null };
|
||||
const { merged } = mergeAckSources(docs);
|
||||
return { doc: merged };
|
||||
}
|
||||
|
||||
/**
|
||||
* Base-ref candidates, most-specific first.
|
||||
*
|
||||
@@ -755,7 +897,13 @@ module.exports = {
|
||||
REPO_ROOT,
|
||||
ACK_PATH,
|
||||
ACK_REPO_PATH,
|
||||
ACK_DIR,
|
||||
ACK_DIR_REPO_PATH,
|
||||
readAckFileAtRef,
|
||||
listAckFragmentFiles,
|
||||
listAckFragmentFilesAtRef,
|
||||
readAckSources,
|
||||
readAckSourcesAtRef,
|
||||
FIXTURE_SUBDIR,
|
||||
MANIFEST_FAMILIES,
|
||||
MINIMUM_MANIFEST_FAMILIES,
|
||||
|
||||
Reference in New Issue
Block a user