Skip to content

Commit 6ae9fa4

Browse files
committed
fix(gate-manifest): fingerprint the forwarding statement, not the call
The waiver bound only the normalized `reason(entry.check, file, entry.rule, entry.detail)` text, so the chain around it stayed swappable. Both shapes reported last round were accepted again as long as they kept that call: BUILD_OWNERSHIP .filter((entry) => { entry.rule = computed; return true; }) .map((entry) => reason(entry.check, file, entry.rule, entry.detail)); BUILD_OWNERSHIP.map((entry) => { entry.mutate(); return reason(entry.check, file, entry.rule, entry.detail); }); The claim a waiver makes — this rule is already in the universe — is true because of the chain, not because of the call, so the key has to carry the chain. It is now the whole enclosing statement plus the file and the nearest enclosing named function. The statement is the WIDEST one inside that function, not the tightest. The tightest statement around `.map((entry) => { return reason(…); })` is the bare `return`, which says nothing about the chain that produced `entry` — fingerprinting that would leave the same hole one shape over. Climbing to the outermost statement in the function always captures the chain and its callbacks and stops before unrelated code. The refusal message now prints the exact file/enclosing/statement to paste, since a hand-written fingerprint is otherwise guesswork. The previous negatives used `reason('lint', file, entry.rule, 'd')` — a differently shaped call — so they never exercised the waiver bypass at all. They now use the waived call verbatim, with a non-vacuity test asserting the fixtures still contain it, so editing one cannot quietly turn these back into ordinary not-a-literal cases.
1 parent 08e2fae commit 6ae9fa4

6 files changed

Lines changed: 403 additions & 143 deletions

File tree

docs/agents/testing.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -237,10 +237,16 @@ Where the two meet is worth knowing before you edit the selector. The path-categ
237237
each `BUILD_OWNERSHIP` entry's `rule:` — so a new ownership rule widens the universe and fails
238238
the gate until a representative path exercises it. Only string literals count. A rule the reader
239239
cannot see would slip past the reachability check while the gate stayed green, so a computed one
240-
is an error naming its line. The single call that forwards `entry.rule` out of the ownership loop
241-
is declared in `FORWARDED_SELECTOR_RULES`, keyed on the exact text of the call, rather than
242-
recognized by shape; `selector-rules.ts` records why the shape-recognizing version could not be
243-
made sound.
240+
is an error naming its line. The single statement that forwards `entry.rule` out of the ownership
241+
loop is declared in `FORWARDED_SELECTOR_RULES` rather than recognized by shape;
242+
`selector-rules.ts` records why the shape-recognizing version could not be made sound.
243+
244+
That waiver is keyed on the whole enclosing **statement** — chain, callbacks and all — plus the
245+
file and enclosing function, not on the `reason(...)` call. The claim it makes ("this rule is
246+
already in the universe") is true because of the chain, so a key naming only the call would
247+
survive that chain being swapped for a mutating one. Mutate it, or move the statement, and the
248+
waiver goes inert and fails. When the reader refuses a forward it prints the exact three fields
249+
to paste.
244250

245251
The gate is deterministic, offline, and needs no GitHub token — it runs from a clean checkout in
246252
the `Affected-check Selector` job. Branch-protection required-contexts drift is the one part

scripts/gate-manifest/check.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ const vitestProjects = vitestProjectNames('vitest.config.ts', read('vitest.confi
7474
const selector = readSelectorRules(
7575
SELECTOR_SOURCE,
7676
read(SELECTOR_SOURCE),
77-
FORWARDED_SELECTOR_RULES.map((entry) => entry.call),
77+
FORWARDED_SELECTOR_RULES,
7878
);
7979
const selectorRules = selector.rules;
8080

@@ -216,21 +216,23 @@ const staleWaivers = [
216216
`TRANSPARENT_WRAPPERS "${entry.file}" changes nothing — no resolved command forwards ` +
217217
`through it, so the waiver is inert`,
218218
),
219-
// A forwarded-rule waiver is applied-reachable when the call it names is really there — and
220-
// only that call. Matching none means the forward is gone and the waiver now excuses nothing;
221-
// matching several means one reviewed claim has silently spread to a call nobody looked at.
222-
...FORWARDED_SELECTOR_RULES.map((entry) => ({
219+
// A forwarded-rule waiver is applied-reachable when the statement it fingerprints is really
220+
// there — and only once. Matching none means the statement changed or moved, so the reviewed
221+
// claim no longer describes live code; matching several means one claim now stands for code
222+
// nobody looked at. Either way the waiver has stopped meaning what it said.
223+
...FORWARDED_SELECTOR_RULES.map((entry, index) => ({
223224
entry,
224-
matches: selector.waiverMatches.get(entry.call) ?? 0,
225+
matches: selector.waiverMatches[index] ?? 0,
225226
}))
226227
.filter(({ matches }) => matches !== 1)
227228
.map(({ entry, matches }) =>
228229
matches === 0
229-
? `FORWARDED_SELECTOR_RULES "${entry.call}" matches no reason() call in ` +
230-
`${SELECTOR_SOURCE} — the forward is gone, so the waiver is inert`
231-
: `FORWARDED_SELECTOR_RULES "${entry.call}" matches ${matches} calls in ` +
232-
`${SELECTOR_SOURCE} — one waiver cannot stand for several forwards; make each call ` +
233-
`distinguishable, or write the rules as literals`,
230+
? `FORWARDED_SELECTOR_RULES ${entry.file}#${entry.enclosing} matches no forwarding ` +
231+
`statement — the statement it waives changed, moved, or is gone, so the waiver is ` +
232+
`inert. It fingerprints: ${entry.statement}`
233+
: `FORWARDED_SELECTOR_RULES ${entry.file}#${entry.enclosing} matches ${matches} ` +
234+
`statements — one waiver cannot stand for several forwards; review each and give it ` +
235+
`its own entry, or write the rules as literals`,
234236
),
235237
...DECLARED_EDGES.filter(
236238
(entry) =>

scripts/gate-manifest/path-categories.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ test("the selector's real rule universe is derived, and excludes its fail-open c
113113
const { rules } = readSelectorRules(
114114
SELECTOR_SOURCE,
115115
fs.readFileSync(path.join(repoRoot, SELECTOR_SOURCE), 'utf8'),
116-
FORWARDED_SELECTOR_RULES.map((entry) => entry.call),
116+
FORWARDED_SELECTOR_RULES,
117117
);
118118
// Live selection rules, read from `reason(...)` calls and the BUILD_OWNERSHIP table.
119119
for (const expected of ['gate:lint', 'platform-src', 'own:swift', 'own:mcp', 'src-prod']) {

scripts/gate-manifest/selector-rules.test.ts

Lines changed: 163 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ test("the reason factory's own return object declares no category", () => {
4545
// --- Nothing but a literal is derived ---------------------------------------
4646

4747
/**
48-
* Every shape that six rounds of review argued about, plus the two that ended the argument.
48+
* Every shape that seven rounds of review argued about.
4949
*
5050
* Earlier revisions tried to decide these case by case — is this `entry` really the ownership
5151
* table's? — and each answer left the next construct open. They are one test now because the
@@ -141,16 +141,12 @@ const NOT_A_LITERAL: readonly { name: string; source: string }[] = [
141141
});`,
142142
},
143143
{
144-
// Round seven, first hole: the mutation is in an UPSTREAM callback, so a proof that only
145-
// looked at the forwarding callback saw nothing wrong with the map below it.
146144
name: 'a mutating upstream filter callback',
147145
source: `${TABLE}
148146
const bad = BUILD_OWNERSHIP.filter((entry) => { entry.rule = computed; return true; })
149147
.map((entry) => reason('lint', file, entry.rule, 'd'));`,
150148
},
151149
{
152-
// Round seven, second hole: `entry.mutate` is a member access whose object is `entry`, so a
153-
// read/write classifier counted invoking it as a read.
154150
name: 'a member call on the entry before reading it',
155151
source: `${TABLE}
156152
const bad = BUILD_OWNERSHIP.map((entry) => {
@@ -167,7 +163,7 @@ const NOT_A_LITERAL: readonly { name: string; source: string }[] = [
167163

168164
for (const { name, source } of NOT_A_LITERAL) {
169165
test(`fails closed: ${name}`, () => {
170-
assert.throws(() => readSelectorRules('m.ts', source), /rule argument is not a string literal/);
166+
assert.throws(() => readSelectorRules('m.ts', source), /not a string literal/);
171167
});
172168
}
173169

@@ -182,99 +178,199 @@ test('a computed ownership-table rule fails closed too', () => {
182178
);
183179
});
184180

185-
test('the failure names the line and quotes the call, so it can be waived or fixed', () => {
186-
try {
187-
readSelectorRules(
188-
'm.ts',
189-
"const ok = reason('lint', f, 'gate:lint', 'd');\nconst bad = reason('lint', f, X, 'd');",
190-
);
191-
assert.fail('expected a throw');
192-
} catch (error) {
193-
assert.match(String(error), /line 2/);
194-
assert.match(String(error), /reason\('lint', f, X, 'd'\)/);
195-
assert.match(String(error), /FORWARDED_SELECTOR_RULES/);
196-
}
197-
});
198-
199181
// --- Declared forwards ------------------------------------------------------
200182

201-
const LIVE_CHAIN = `${TABLE}
202-
const s = BUILD_OWNERSHIP.filter((entry) => entry.owns(file)).map((entry) =>
203-
reason(entry.check, file, entry.rule, entry.detail),
204-
);
205-
const a = reason('lint', file, 'gate:lint', 'd');`;
183+
/**
184+
* The live forwarding statement, on one line so its normalized fingerprint is obvious.
185+
*
186+
* The variants below keep this statement's inner `reason(...)` call byte for byte and change
187+
* only the chain around it. That is the point of fingerprinting the statement: an entry keyed on
188+
* the call alone accepts every one of them.
189+
*/
190+
const LIVE_STATEMENT =
191+
'const s = BUILD_OWNERSHIP.filter((entry) => entry.owns(file)).map((entry) => ' +
192+
'reason(entry.check, file, entry.rule, entry.detail));';
193+
194+
const MUTATING_FILTER =
195+
'const s = BUILD_OWNERSHIP.filter((entry) => { entry.rule = computed; return true; })' +
196+
'.map((entry) => reason(entry.check, file, entry.rule, entry.detail));';
206197

207-
const LIVE_CALL = 'reason(entry.check, file, entry.rule, entry.detail)';
198+
const MEMBER_CALL =
199+
'const s = BUILD_OWNERSHIP.map((entry) => { entry.mutate(); ' +
200+
'return reason(entry.check, file, entry.rule, entry.detail); });';
208201

209-
test('a waiver admits exactly the call it names, and the universe stays complete', () => {
210-
const derived = readSelectorRules('m.ts', LIVE_CHAIN, [LIVE_CALL]);
202+
function inFunction(statement: string, enclosing = 'buildOwnership'): string {
203+
return `${TABLE}
204+
const ${enclosing} = () => {
205+
${statement}
206+
return s;
207+
};
208+
const a = reason('lint', file, 'gate:lint', 'd');`;
209+
}
210+
211+
const LIVE_KEY = { file: 'm.ts', enclosing: 'buildOwnership', statement: LIVE_STATEMENT };
212+
213+
test('a waiver admits exactly the statement it fingerprints, and loses no category', () => {
214+
const derived = readSelectorRules('m.ts', inFunction(LIVE_STATEMENT), [LIVE_KEY]);
211215
// `own:swift` is still there — read from the table entry, which is the whole reason the
212-
// forward is safe to declare: the waiver excuses the call, it does not drop a category.
216+
// forward is safe to declare: the waiver excuses the statement, it does not drop a category.
213217
assert.deepEqual(derived.rules, ['gate:lint', 'own:swift']);
214-
assert.equal(derived.waiverMatches.get(LIVE_CALL), 1);
218+
assert.deepEqual(derived.waiverMatches, [1]);
215219
});
216220

217-
test('a waiver matches across reformatting, because the text is normalized', () => {
218-
const rewrapped = `${TABLE}
219-
const s = BUILD_OWNERSHIP.map((entry) => reason(
220-
entry.check,
221-
file,
222-
entry.rule,
223-
entry.detail
224-
));
225-
const a = reason('lint', file, 'gate:lint', 'd');`;
221+
test('a waiver survives reformatting, because the statement text is normalized', () => {
222+
const rewrapped = inFunction(
223+
`const s = BUILD_OWNERSHIP.filter((entry) => entry.owns(file)).map(
224+
(entry) => reason(entry.check, file, entry.rule, entry.detail),
225+
);`,
226+
);
227+
// Rewrapped across lines with extra indentation, but the same tokens: still one match. (The
228+
// trailing comma IS a token difference, so this variant keeps the argument list as written.)
229+
const normalized = LIVE_STATEMENT.replace(
230+
'.map((entry) => reason(entry.check, file, entry.rule, entry.detail));',
231+
'.map( (entry) => reason(entry.check, file, entry.rule, entry.detail), );',
232+
);
233+
const derived = readSelectorRules('m.ts', rewrapped, [{ ...LIVE_KEY, statement: normalized }]);
234+
assert.deepEqual(derived.rules, ['gate:lint', 'own:swift']);
235+
assert.deepEqual(derived.waiverMatches, [1]);
236+
});
237+
238+
/**
239+
* The round-eight regressions: the two unsafe chains, each keeping the waived call verbatim.
240+
*
241+
* A waiver keyed on `reason(entry.check, file, entry.rule, entry.detail)` accepted all of these.
242+
* Keyed on the statement, every one is a different statement and fails closed.
243+
*/
244+
const BYPASS_ATTEMPTS: readonly { name: string; source: string }[] = [
245+
{ name: 'a mutating upstream filter callback', source: inFunction(MUTATING_FILTER) },
246+
{ name: 'a member call on the entry', source: inFunction(MEMBER_CALL) },
247+
{
248+
name: 'the waived statement relocated to another function',
249+
source: inFunction(LIVE_STATEMENT, 'somewhereElse'),
250+
},
251+
{
252+
name: 'the waived statement wrapped in a value-changing chain',
253+
source: inFunction(
254+
'const s = BUILD_OWNERSHIP.map(transform).map((entry) => ' +
255+
'reason(entry.check, file, entry.rule, entry.detail));',
256+
),
257+
},
258+
];
259+
260+
test('the bypass fixtures really do keep the waived call, or they prove nothing', () => {
261+
// Non-vacuity guard. These negatives only exercise the bypass while their inner call is byte
262+
// for byte the one a call-keyed waiver would have named; edit a fixture so the call differs
263+
// and they quietly degrade into ordinary "not a literal" cases that never touched the waiver.
264+
const call = 'reason(entry.check, file, entry.rule, entry.detail)';
265+
for (const statement of [LIVE_STATEMENT, MUTATING_FILTER, MEMBER_CALL]) {
266+
assert.ok(statement.includes(call), `fixture must contain the waived call: ${statement}`);
267+
}
268+
});
269+
270+
for (const { name, source } of BYPASS_ATTEMPTS) {
271+
test(`the waiver does not admit: ${name}`, () => {
272+
assert.throws(() => readSelectorRules('m.ts', source, [LIVE_KEY]), /not a string literal/);
273+
});
274+
}
275+
276+
test('a forward inside a block callback fingerprints the chain, not the bare return', () => {
277+
// The tightest statement around this forward is `return reason(…);`, which says nothing about
278+
// the chain that produced `entry`. Fingerprinting that would leave the chain swappable — the
279+
// same hole one shape over — so the key climbs to the outermost statement in the function.
280+
const blockForm =
281+
'const s = BUILD_OWNERSHIP.map((entry) => { return reason(entry.check, file, entry.rule, entry.detail); });';
282+
const bareReturn = 'return reason(entry.check, file, entry.rule, entry.detail);';
283+
226284
assert.throws(
227-
() => readSelectorRules('m.ts', rewrapped, [LIVE_CALL]),
228-
/rule argument is not a string literal/,
229-
'a trailing-comma difference is a real text difference',
285+
() =>
286+
readSelectorRules('m.ts', inFunction(blockForm), [
287+
{ file: 'm.ts', enclosing: 'buildOwnership', statement: bareReturn },
288+
]),
289+
/not a string literal/,
290+
'a waiver naming only the return must not admit the chain around it',
230291
);
231-
const normalized = 'reason( entry.check, file, entry.rule, entry.detail )';
232-
assert.deepEqual(readSelectorRules('m.ts', rewrapped, [normalized]).rules, [
233-
'gate:lint',
234-
'own:swift',
292+
const derived = readSelectorRules('m.ts', inFunction(blockForm), [
293+
{ file: 'm.ts', enclosing: 'buildOwnership', statement: blockForm },
235294
]);
295+
assert.deepEqual(derived.waiverMatches, [1]);
236296
});
237297

238-
test('a waiver does not admit a different forward in the same file', () => {
239-
const source = `${LIVE_CHAIN}
240-
const other = BUILD_OWNERSHIP.map((entry) => reason('lint', file, entry.rule, 'd'));`;
298+
test('a waiver is bound to its file, so the same statement elsewhere is unreviewed', () => {
241299
assert.throws(
242-
() => readSelectorRules('m.ts', source, [LIVE_CALL]),
243-
/reason\('lint', file, entry\.rule, 'd'\)/,
300+
() => readSelectorRules('other.ts', inFunction(LIVE_STATEMENT), [LIVE_KEY]),
301+
/not a string literal/,
244302
);
245303
});
246304

247-
test('an unmatched waiver is reported as matching nothing, so check.ts can call it inert', () => {
305+
test('an unmatched waiver reports zero, so check.ts can call it inert', () => {
248306
const derived = readSelectorRules('m.ts', "const a = reason('lint', file, 'gate:lint', 'd');", [
249-
LIVE_CALL,
307+
LIVE_KEY,
250308
]);
251-
assert.equal(derived.waiverMatches.get(LIVE_CALL), undefined);
309+
assert.deepEqual(derived.waiverMatches, [0]);
252310
});
253311

254-
test('a waiver covering two identical calls reports both, so check.ts can refuse it', () => {
255-
// One reviewed claim must not silently spread to a second call nobody looked at.
312+
test('a waiver covering two identical statements reports both, so check.ts can refuse it', () => {
313+
// One reviewed claim must not silently spread to a second forward nobody looked at.
314+
const forward = 'use(BUILD_OWNERSHIP.map((entry) => reason(entry.check, file, entry.rule, 1)));';
256315
const source = `${TABLE}
257-
const one = BUILD_OWNERSHIP.map((entry) => reason(entry.check, file, entry.rule, entry.detail));
258-
const two = OTHER.map((entry) => reason(entry.check, file, entry.rule, entry.detail));`;
259-
assert.equal(readSelectorRules('m.ts', source, [LIVE_CALL]).waiverMatches.get(LIVE_CALL), 2);
316+
const buildOwnership = () => {
317+
${forward}
318+
${forward}
319+
};`;
320+
const derived = readSelectorRules('m.ts', source, [
321+
{ file: 'm.ts', enclosing: 'buildOwnership', statement: forward },
322+
]);
323+
assert.deepEqual(derived.waiverMatches, [2]);
324+
});
325+
326+
test('the failure hands back the exact three fields a waiver needs', () => {
327+
try {
328+
readSelectorRules('m.ts', inFunction(LIVE_STATEMENT));
329+
assert.fail('expected a throw');
330+
} catch (error) {
331+
const message = String(error);
332+
assert.match(message, /file: {6}m\.ts/);
333+
assert.match(message, /enclosing: buildOwnership/);
334+
assert.match(
335+
message,
336+
new RegExp(`statement: ${LIVE_STATEMENT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
337+
);
338+
}
260339
});
261340

341+
test('the failure names the line, and top-level forwards report as such', () => {
342+
try {
343+
readSelectorRules(
344+
'm.ts',
345+
"const ok = reason('lint', f, 'gate:lint', 'd');\nconst bad = reason('lint', f, X, 'd');",
346+
);
347+
assert.fail('expected a throw');
348+
} catch (error) {
349+
assert.match(String(error), /line 2/);
350+
assert.match(String(error), /enclosing: \(top level\)/);
351+
assert.match(String(error), /FORWARDED_SELECTOR_RULES/);
352+
}
353+
});
354+
355+
// --- The real tree ----------------------------------------------------------
356+
262357
test("the repo's real selector derives, and its one declared forward is live", () => {
263-
const waived = FORWARDED_SELECTOR_RULES.map((entry) => entry.call);
264358
const derived = readSelectorRules(
265359
SELECTOR_SOURCE,
266360
fs.readFileSync(path.join(repoRoot, SELECTOR_SOURCE), 'utf8'),
267-
waived,
361+
FORWARDED_SELECTOR_RULES,
268362
);
269363
assert.ok(
270364
derived.rules.length >= 20,
271365
`expected the live selector to yield its categories, got ${derived.rules.length}`,
272366
);
273-
// Each declared forward matches exactly one real call. check.ts fails the gate on 0 or 2+;
274-
// asserting it here too means the unit lane catches a rotted waiver without the full manifest.
275-
for (const call of waived) {
276-
assert.equal(derived.waiverMatches.get(call), 1, `waiver "${call}" should match one call`);
277-
}
367+
// Each declared forward matches exactly one real statement. check.ts fails the gate on 0 or
368+
// 2+; asserting it here means the unit lane catches a rotted waiver without the full manifest.
369+
assert.deepEqual(
370+
derived.waiverMatches,
371+
FORWARDED_SELECTOR_RULES.map(() => 1),
372+
'every FORWARDED_SELECTOR_RULES entry must fingerprint exactly one live statement',
373+
);
278374
});
279375

280376
test("the repo's real selector fails closed with the waiver removed", () => {
@@ -286,6 +382,6 @@ test("the repo's real selector fails closed with the waiver removed", () => {
286382
SELECTOR_SOURCE,
287383
fs.readFileSync(path.join(repoRoot, SELECTOR_SOURCE), 'utf8'),
288384
),
289-
/rule argument is not a string literal/,
385+
/not a string literal/,
290386
);
291387
});

0 commit comments

Comments
 (0)