v1.91.338.0 fix(sealed): waivers must name the full commit sha (GST-268) (#3649)

* fix(sealed): waivers must name the full commit sha (GST-268)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(sealed): one ignored-waiver note per line and commit; split the waiver loops into cases

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(pregate): quote the ignored-waiver stderr line as printed

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(sealed): create SHA-256 scratch repos with --object-format, not the init config

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(sealed): ignored-waiver hint names the finding's commit to review, not a sha to write

Choose the shortened-vs-another-commit wording by prefix against the
finding's commit (fixes the SHA-256 40-hex mislabel and non-prefix short
waivers), and check applied waivers with a Set instead of rescanning.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-30 18:15:13 +00:00
1 parent 94a10cc904
commit 53858424d0
9 files changed
+153 -21

No files matched your search

+11
View File
@@ -1,5 +1,16 @@
# Changelog
## [1.91.338.0] - 2026-10-30
**A sealed-guard waiver now counts only when it names the full commit sha.** A `.gstack/sealed-allow.tsv` line used to accept a sha as short as 7 hex characters and waive any commit whose sha started with it. A PR author could generate a commit with the same first 7 characters in minutes and publish different content at the same path under an old, reviewed waiver. A waiver now matches only the finding's full commit sha (40 hex characters, 64 in a SHA-256 repository). A shortened, uppercase or mistyped sha is ignored, so the finding still refuses, and stderr says which line was ignored and which commit the finding is in, to review before anyone waives it.
### Changed
- `lib/sealed-guard.ts`: waivers compare the whole commit sha; `gstack-pregate sealed --json` adds `ignoredWaivers` (rule, path, the sha as written, the finding's commit) for waiver lines that name a finding's rule and path but not its commit, once per line and commit. Lines that match no finding, and waivers that applied to another commit in the push, stay silent.
- The refusal hint, the `SEALED_OUTPUT_REFUSED` fix text, docs/pregate.md and docs/troubleshooting.md say the sha must be the full commit sha.
### Tests
- `test/sealed-guard.test.ts`: 7-, 12- and 39-character prefixes, an uppercase sha and one extra character do not waive and are named; the full sha waives, trailing whitespace included; another commit's full sha does not waive; a short line with no matching finding prints nothing; a waiver applied to one commit is not reported for a later one; a text rule with two matching lines names a short waiver once; a SHA-256 repository accepts 64 characters and rejects 40, 63 and 65; error reports carry an empty list. The two existing waiver tests now write the full sha.
## [1.91.337.0] - 2026-10-30
### Changed
+1 -1
View File
@@ -1 +1 @@
1.91.337.0
1.91.338.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.337.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.338.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+6 -1
View File
@@ -843,7 +843,12 @@ gstack-pregate sealed [--range <a..b>] [--remote origin] [--base <branch>] [--js
`git ls-remote <remote> <ref>`. A malformed or
unreadable policy refuses. Waivers come only from the base branch's
`.gstack/sealed-allow.tsv` (`rule<TAB>path<TAB>sha<TAB>reason`) and are
listed on every push.
listed on every push. A waiver counts only when `sha` is the finding's full
commit sha (40 hex characters, 64 in a SHA-256 repository); a shortened or
mistyped one is ignored, so the finding still refuses, and stderr names it
(`ignored waiver <rule> <path> sha <value> (...)`) with the finding's full
commit, which needs a review before anyone waives it.
`--json` lists them as `ignoredWaivers`.
- **Visibility.** `sealed.visibility` from the base branch, else a cache under
the state root (`sealed-visibility.json`: a `public` answer for 24 hours, a
`private` one for 1 hour before it may turn a refusal into a warning), else
+2 -1
View File
@@ -3813,7 +3813,8 @@ and counts) can be exempted from the path rules with a `sealed.allow_paths`
glob in the base branch's `.gstack/pregate.json`; the text rules still read it
([docs/pregate.md#sealed](pregate.md#sealed)). A reviewed false positive gets
one line in the base branch's
`.gstack/sealed-allow.tsv`: `rule<TAB>path<TAB>sha<TAB>reason`. A private
`.gstack/sealed-allow.tsv`: `rule<TAB>path<TAB>sha<TAB>reason`, where `sha` is
the full commit sha (a shortened one is ignored and named on stderr). A private
destination sets `"visibility": "private"` under `sealed` in the base branch's
`.gstack/pregate.json`. Capy's pull-request tool may not run local hooks, so
run `gstack-pregate sealed` yourself before any push or PR creation.
+1 -1
View File
@@ -513,7 +513,7 @@ export const RESULT_CODES = {
SEALED_OUTPUT_REFUSED: {
anchor: 'sealed-output-refused',
summary: 'a push or PR would publish sealed or eval output (a sealed path, a transcript, a quoted answer, a seed or a local home path) to a public or unknown destination, or the sealed policy is unreadable',
fix: 'remove it from every published commit, or add one reviewed `rule<TAB>path<TAB>sha<TAB>reason` line to the base branch\'s .gstack/sealed-allow.tsv; a private destination sets sealed.visibility "private" in the base branch\'s .gstack/pregate.json',
fix: 'remove it from every published commit, or add one reviewed `rule<TAB>path<TAB>full-commit-sha<TAB>reason` line to the base branch\'s .gstack/sealed-allow.tsv (a shortened sha is ignored); a private destination sets sealed.visibility "private" in the base branch\'s .gstack/pregate.json',
},
EVAL_GATE_UNPRICED: {
anchor: 'eval-gate-unpriced',
+20 -9
View File
@@ -22,7 +22,7 @@
* destination base branch's `sealed` and the pushed one (a push can tighten,
* never relax); `allow_paths` exceptions come only from the base branch's
* policy and remove path findings before waivers, which come only from the
* base branch's `.gstack/sealed-allow.tsv`. A malformed or unreadable policy refuses (fail
* base branch's `.gstack/sealed-allow.tsv` and must name the full commit sha. A malformed or unreadable policy refuses (fail
* closed). Visibility: the base override, else a cache under the state root
* (a `public` answer for 24 h, a `private` one for 1 h before it may
* downgrade a refusal), else `gh repo view` (5 s); unknown counts as public.
@@ -104,13 +104,13 @@ export function unionPolicies(base: SealedPolicy | null, pushed: SealedPolicy |
return { paths: u('paths'), transcripts: u('transcripts'), answer_markers: u('answer_markers'), seed_patterns: u('seed_patterns'), local_paths: !!(base?.local_paths || pushed?.local_paths), allow_paths: base?.allow_paths ?? [], case_insensitive: !!(base?.case_insensitive || pushed?.case_insensitive), visibility };
}
/** Reviewed waivers: `rule<TAB>path<TAB>sha<TAB>reason` lines in the base branch's allow file. */
/** Reviewed waivers: `rule<TAB>path<TAB>sha<TAB>reason` lines in the base branch's allow file; a waiver counts only when its sha equals the finding's full commit sha. */
export function allowEntriesAt(repoRoot: string, treeish: string | null): AllowEntry[] {
if (!treeish) return [];
const show = git(repoRoot, ['cat-file', 'blob', `${treeish}:${SEALED_ALLOW_PATH}`]);
if (!show.ok) return [];
return show.out.split('\n').filter(l => l.trim() && !l.startsWith('#')).map(l => l.split('\t')).filter(f => f.length >= 4 && /^[0-9a-f]{7,64}$/.test(f[2]!) && f[3]!.trim())
.map(f => ({ rule: f[0]!.trim(), path: f[1]!.trim(), sha: f[2]!, reason: f.slice(3).join('\t').trim() }));
return show.out.split('\n').filter(l => l.trim() && !l.startsWith('#')).map(l => l.split('\t')).filter(f => f.length >= 4 && f[2]!.trim() && f[3]!.trim())
.map(f => ({ rule: f[0]!.trim(), path: f[1]!.trim(), sha: f[2]!.trim(), reason: f.slice(3).join('\t').trim() }));
}
/** Commits a push publishes: local minus the remote's refs (and its tip for this ref); null remote and no tip → everything reachable. */
@@ -229,7 +229,7 @@ export function resolveVisibility(url: string | null, o: VisibilityOptions = {})
export interface SealedReport {
status: 'no-policy' | 'clear' | 'refused' | 'warned' | 'error';
policyLabels: string[]; commits: number; findings: SealedFinding[]; exempt: ExemptFinding[]; waived: Array<SealedFinding & { reason: string }>;
policyLabels: string[]; commits: number; findings: SealedFinding[]; exempt: ExemptFinding[]; waived: Array<SealedFinding & { reason: string }>; ignoredWaivers: Array<{ rule: string; path: string; sha: string; commit: string }>;
visibility: Visibility; visibilitySource: string; target: string; error?: string;
}
@@ -240,7 +240,7 @@ export interface EvaluateOptions {
/** A report for a failure before any policy is read (an unlisted range, an unreadable push); renders as a fail-closed refusal. */
export function sealedErrorReport(error: string, target: string): SealedReport {
return { status: 'error', error, policyLabels: [], commits: 0, findings: [], exempt: [], waived: [], visibility: 'unknown', visibilitySource: '', target };
return { status: 'error', error, policyLabels: [], commits: 0, findings: [], exempt: [], waived: [], ignoredWaivers: [], visibility: 'unknown', visibilitySource: '', target };
}
/** The whole guard for one push (or range): policies, commits, findings, waivers, visibility, verdict. */
@@ -271,13 +271,20 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport {
const allow = allowEntriesAt(o.repoRoot, o.baseRef);
const waived: SealedReport['waived'] = [];
const findings = found.filter(f => {
const entry = allow.find(a => a.rule === f.rule && a.path === f.file && f.commit.startsWith(a.sha));
const entry = allow.find(a => a.rule === f.rule && a.path === f.file && a.sha === f.commit);
if (entry) waived.push({ ...f, reason: entry.reason });
return !entry;
});
const applied = new Set(waived.map(w => `${w.rule}\t${w.file}\t${w.commit}`));
const ignored = new Map<string, SealedReport['ignoredWaivers'][number]>();
for (const f of findings) for (const a of allow) {
if (a.rule !== f.rule || a.path !== f.file || applied.has(`${a.rule}\t${a.path}\t${a.sha}`)) continue;
ignored.set(`${a.rule}\t${a.path}\t${a.sha}\t${f.commit}`, { rule: a.rule, path: a.path, sha: a.sha, commit: f.commit });
}
const ignoredWaivers = [...ignored.values()];
const vis = policy.visibility ? { visibility: policy.visibility as Visibility, source: `${VISIBILITY_OVERRIDE_KEY} override` } : resolveVisibility(o.remoteUrl, { env: o.env, gh: o.gh });
const status = findings.length === 0 ? 'clear' : vis.visibility === 'private' ? 'warned' : 'refused';
return { status, policyLabels: labels, commits: commits.length, findings, exempt, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target };
return { status, policyLabels: labels, commits: commits.length, findings, exempt, waived, ignoredWaivers, visibility: vis.visibility, visibilitySource: vis.source, target: o.target };
}
const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:${f.line}` : ''}`;
@@ -287,6 +294,10 @@ export function renderSealed(r: SealedReport, context: string): string {
const lines: string[] = [];
for (const e of r.exempt) lines.push(`${context}: exempt ${e.rule} ${where(e)} commit ${e.commit.slice(0, 12)} (sealed.allow_paths ${e.glob})`);
for (const w of r.waived) lines.push(`${context}: waived ${w.rule} ${where(w)} commit ${w.commit.slice(0, 12)} (${SEALED_ALLOW_PATH}: ${w.reason})`);
for (const i of r.ignoredWaivers) {
const why = i.commit.startsWith(i.sha.toLowerCase()) ? `${SEALED_ALLOW_PATH} must name the full lowercase commit sha` : 'it names another commit';
lines.push(`${context}: ignored waiver ${i.rule} ${i.path} sha ${i.sha} (${why}; this finding is in commit ${i.commit}, review it before waiving)`);
}
const vis = `visibility: ${r.visibility}${r.visibility === 'unknown' ? ', counted public' : ''}; ${r.visibilitySource}`;
const body = r.findings.map(f => ` ${f.rule} ${where(f)} commit ${f.commit.slice(0, 12)}`);
if (r.status === 'error') {
@@ -294,7 +305,7 @@ export function renderSealed(r: SealedReport, context: string): string {
lines.push(`Fix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)`);
} else if (r.status === 'refused') {
lines.push(`\n⛔ ${context} BLOCKED the publication of ${r.target} (${vis}) — sealed or eval output in ${r.commits} published commit(s):`, ...body);
lines.push(`Remove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's ${SEALED_CONFIG_PATH}, or waive one reviewed finding with a \`rule<TAB>path<TAB>sha<TAB>reason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`);
lines.push(`Remove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's ${SEALED_CONFIG_PATH}, or waive one reviewed finding with a \`rule<TAB>path<TAB>full-commit-sha<TAB>reason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`);
} else if (r.status === 'warned') {
lines.push(`${context}: warning — sealed or eval output in a push to a private destination (${vis}); it would be refused for a public one:`, ...body);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.337",
"version": "1.91.338",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+110 -6
View File
@@ -41,14 +41,14 @@ function sh(cwd: string, env: Record<string, string>, args: string[]): string {
}
/** A bare origin with main carrying the given base files, and a clone on branch `results`. */
function scratch(baseFiles: Record<string, string | Buffer>): Scratch {
function scratch(baseFiles: Record<string, string | Buffer>, objectFormat: 'sha1' | 'sha256' = 'sha1'): Scratch {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'sealed-guard-')));
const work = path.join(root, 'work'); const origin = path.join(root, 'origin.git'); const state = path.join(root, 'state');
fs.mkdirSync(work); fs.mkdirSync(state);
const env = { HOME: root, GSTACK_HOME: state, GSTACK_STATE_ROOT: state, GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' };
fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@example.com\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n');
spawnSync('git', ['init', '-q', '--bare', origin], { env: { ...process.env, ...env }, timeout: 30_000 });
sh(work, env, ['init', '-q', '-b', 'main']);
fs.writeFileSync(env.GIT_CONFIG_GLOBAL, `[user]\n\tname = t\n\temail = t@example.com\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n`);
spawnSync('git', ['init', '-q', '--bare', `--object-format=${objectFormat}`, origin], { env: { ...process.env, ...env }, timeout: 30_000 });
sh(work, env, ['init', '-q', '-b', 'main', `--object-format=${objectFormat}`]);
write(work, { 'README.md': '# evals\n', ...baseFiles });
sh(work, env, ['add', '-A']); sh(work, env, ['commit', '-q', '-m', 'base']);
sh(work, env, ['remote', 'add', 'origin', origin]);
@@ -145,7 +145,7 @@ describe('policy union, waivers, private destinations and fail-closed parsing',
test('a waiver counts only from the base branch allow file and is listed; the same line committed in the push is ignored', () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
const sha = commit(s, { 'runs/c1/transcript.jsonl': '{}\n' });
const allow = `# rule\tpath\tsha\treason\nsealed.transcript\truns/c1/transcript.jsonl\t${sha.slice(0, 12)}\tsynthetic fixture, no sealed content\n`;
const allow = `# rule\tpath\tsha\treason\nsealed.transcript\truns/c1/transcript.jsonl\t${sha}\tsynthetic fixture, no sealed content\n`;
commit(s, { '.gstack/sealed-allow.tsv': allow });
expect(hook(s).code).toBe(1);
sh(s.work, s.env, ['checkout', '-q', 'main']);
@@ -267,7 +267,7 @@ describe('sealed.allow_paths: base-branch exceptions applied after the deny glob
test('an exception covers sealed.transcript too and is counted before a base sha waiver, never both', () => {
const s = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'], transcripts: ['**/transcript*.jsonl'], allow_paths: ['**/sealed/*/transcript-public.jsonl'] }) });
const sha = commit(s, { 'sealed/s1/transcript-public.jsonl': '{}\n' });
landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.transcript\tsealed/s1/transcript-public.jsonl\t${sha.slice(0, 12)}\tsynthetic fixture\n` });
landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.transcript\tsealed/s1/transcript-public.jsonl\t${sha}\tsynthetic fixture\n` });
const pre = preflight(s);
expect(pre.code).toBe(0);
expect(pre.err).toContain('exempt sealed.transcript sealed/s1/transcript-public.jsonl');
@@ -372,3 +372,107 @@ describe('gstack-pregate sealed is a check, not a push (#3565)', () => {
expect(before).not.toContain('refs/heads/results');
});
});
describe('waivers name the full commit sha (GST-268)', () => {
const TRANSCRIPT = 'runs/c1/transcript.jsonl';
const line = (sha: string) => `sealed.transcript\t${TRANSCRIPT}\t${sha}\tsynthetic fixture\n`;
const PREFIX = 'must name the full lowercase commit sha';
const OTHER = 'it names another commit';
const SHORT: Array<[string, (x: string) => string, string]> = [['a 7-character prefix', x => x.slice(0, 7), PREFIX], ['a 12-character prefix', x => x.slice(0, 12), PREFIX], ['a 39-character prefix', x => x.slice(0, 39), PREFIX], ['an uppercase sha', x => x.toUpperCase(), PREFIX], ['one extra character', x => `${x}a`, OTHER]];
test.each(SHORT)('%s does not waive and is named as ignored with the full commit', (_label, cut, why) => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
const sha = commit(s, { [TRANSCRIPT]: '{}\n' });
landOnMain(s, { '.gstack/sealed-allow.tsv': line(cut(sha)) });
const h = hook(s);
expect(h.code).toBe(1);
expect(h.err).toContain(`ignored waiver sealed.transcript ${TRANSCRIPT} sha ${cut(sha)}`);
expect(h.err).toContain(`${why}; this finding is in commit ${sha}, review it before waiving)`);
const pre = preflight(s);
expect(pre.code).toBe(1);
expect(pre.out).toMatch(/^SEALED: refused findings=1 .* waived=0 /m);
expect(JSON.parse(preflight(s, ['--json']).out).ignoredWaivers).toEqual([{ rule: 'sealed.transcript', path: TRANSCRIPT, sha: cut(sha), commit: sha }]);
});
test('the full 40-character sha waives, trailing whitespace included, with no ignored note', () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
const sha = commit(s, { [TRANSCRIPT]: '{}\n' });
landOnMain(s, { '.gstack/sealed-allow.tsv': line(`${sha} `) });
const r = hook(s);
expect(r.code).toBe(0);
expect(r.err).toContain(`waived sealed.transcript ${TRANSCRIPT}`);
expect(r.err).not.toContain('ignored waiver');
expect(JSON.parse(preflight(s, ['--json']).out).ignoredWaivers).toEqual([]);
});
test("the full sha of a different commit does not waive this commit's file at the same path", () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
const first = commit(s, { [TRANSCRIPT]: '{}\n' });
landOnMain(s, { '.gstack/sealed-allow.tsv': line(first) });
sh(s.work, s.env, ['reset', '-q', '--hard', 'origin/main']);
const second = commit(s, { [TRANSCRIPT]: '{"other":1}\n' });
const r = hook(s);
expect(r.code).toBe(1);
expect(r.err).toContain(`ignored waiver sealed.transcript ${TRANSCRIPT} sha ${first}`);
expect(r.err).toContain(`it names another commit; this finding is in commit ${second}`);
});
test("a short waiver for an older commit is named as another commit, never as this commit's shortened sha", () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
const first = commit(s, { [TRANSCRIPT]: '{}\n' });
landOnMain(s, { '.gstack/sealed-allow.tsv': line(first.slice(0, 7)) });
sh(s.work, s.env, ['reset', '-q', '--hard', 'origin/main']);
const second = commit(s, { [TRANSCRIPT]: '{"other":1}\n' });
expect(second.startsWith(first.slice(0, 7))).toBe(false);
const r = hook(s);
expect(r.code).toBe(1);
expect(r.err).toContain(`ignored waiver sealed.transcript ${TRANSCRIPT} sha ${first.slice(0, 7)} (${OTHER}; this finding is in commit ${second}, review it before waiving)`);
expect(r.err).not.toContain(PREFIX);
});
test('a short waiver line on the base branch prints nothing on a push with no matching finding', () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED), '.gstack/sealed-allow.tsv': line('abc1234') });
commit(s, { 'notes.md': 'ok\n' });
const r = hook(s);
expect(r.code).toBe(0);
expect(r.err).not.toContain('ignored waiver');
});
const SHA256: Array<[string, (x: string) => string, boolean]> = [['the full 64 characters waive', x => x, true], ['a 40-character prefix does not', x => x.slice(0, 40), false], ['a 63-character prefix does not', x => x.slice(0, 63), false], ['65 characters do not', x => `${x}a`, false]];
test.each(SHA256)('a SHA-256 repository: %s', (_label, cut, waives) => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) }, 'sha256');
const sha = commit(s, { [TRANSCRIPT]: '{}\n' });
expect(sha).toMatch(/^[0-9a-f]{64}$/);
landOnMain(s, { '.gstack/sealed-allow.tsv': line(cut(sha)) });
const pre = preflight(s);
expect(pre.code).toBe(waives ? 0 : 1);
expect(pre.err.includes('ignored waiver')).toBe(!waives);
if (!waives) expect(pre.err).toContain(`${cut(sha).length < 64 ? PREFIX : OTHER}; this finding is in commit ${sha}, review it before waiving)`);
});
test('a waiver that applied in one commit is not reported as ignored for a later commit at the same path', () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
const first = commit(s, { 'notes/a.md': 'SEALED ANSWER: b\n' });
const second = commit(s, { 'notes/a.md': 'SEALED ANSWER: b\nSEALED ANSWER: c\n' });
landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.answer\tnotes/a.md\t${first}\tsynthetic fixture\n` });
const j = JSON.parse(preflight(s, ['--json']).out);
expect(j.waived.map((w: { commit: string }) => w.commit)).toEqual([first]);
expect(j.findings.map((f: { commit: string }) => f.commit)).toEqual([second]);
expect(j.ignoredWaivers).toEqual([]);
});
test('a short waiver for a text rule with several matching lines is named once', () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
const sha = commit(s, { 'notes/a.md': 'SEALED ANSWER: b\nSEALED ANSWER: c\n' });
landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.answer\tnotes/a.md\t${sha.slice(0, 7)}\tsynthetic fixture\n` });
const j = JSON.parse(preflight(s, ['--json']).out);
expect(j.findings).toHaveLength(2);
expect(j.ignoredWaivers).toEqual([{ rule: 'sealed.answer', path: 'notes/a.md', sha: sha.slice(0, 7), commit: sha }]);
});
test('an error report carries an empty ignoredWaivers list', () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
commit(s, { 'notes.md': 'ok\n' });
expect(JSON.parse(preflight(s, ['--range', 'nosuch..HEAD', '--json']).out).ignoredWaivers).toEqual([]);
});
});