Skip to content

Commit 4433ece

Browse files
committed
test(lint): pin the dispatch-contract disarm by an undeclared sibling and state it in the docblock
`collectDeclaredContracts` pools global and object-embedded actions into one flat name namespace and counts an action that declares no `execution` as a disagreeing declaration, so one undeclared same-named action anywhere in the stack turns `action-dispatch-contract-mismatch` off for that name. The behaviour is deliberate and unchanged; nothing pinned it (deleting the `'none'` arm left the whole package green) and the docblock did not say it. - Five tests: the lit control, an agreeing-sibling control, and the disarm from the view's own object, from an unrelated object and from the global list, each beside the same stack without the sibling. - The docblock now states the mechanism: absence counts as disagreement, declarations are not scoped by the view's object, and why silence is the intended direction. No logic change. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
1 parent 70a2e75 commit 4433ece

2 files changed

Lines changed: 107 additions & 6 deletions

File tree

‎packages/lint/src/validate-action-dispatch-contract.test.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,3 +207,80 @@ describe('validateActionDispatchContract — every list tier', () => {
207207
expect(findings[0]!.where).toContain('object "task"');
208208
});
209209
});
210+
211+
// ABSENCE COUNTS AS DISAGREEMENT — the disarm `collectDeclaredContracts`
212+
// states in its docblock. Action names are one flat namespace (global and
213+
// object-embedded, pooled), so ONE same-named action that declares no
214+
// `execution`, anywhere in the stack, turns the rule off for that name. That is
215+
// deliberate: zero false positives, and undeclared is not defaulted. It is
216+
// pinned here because nothing else holds it: with the `'none'` arm deleted,
217+
// every other test in this package stays green.
218+
//
219+
// A zero is a reading only when a control fires beside it, so every disarm
220+
// below runs the SAME stack twice: without the sibling (exactly one finding)
221+
// and with it (none). The agreeing-sibling control narrows the cause further:
222+
// a sibling that DECLARES the same contract leaves the finding standing, so it
223+
// is the sibling's silence that disarms the name, not its mere presence.
224+
describe('validateActionDispatchContract — absence counts as disagreement (the disarm)', () => {
225+
type Findings = ReturnType<typeof validateActionDispatchContract>;
226+
227+
const RECALC_AGGREGATE = { name: 'recalc', type: 'api', execution: 'aggregate' };
228+
const RECALC_UNDECLARED = { name: 'recalc', type: 'api' };
229+
230+
/**
231+
* One view on `task`, wiring `recalc` as a bare string (the per-record
232+
* contract). `invoice` is an object the view never resolves through.
233+
*/
234+
const disarmStack = (at: {
235+
global?: Record<string, unknown>[];
236+
task?: Record<string, unknown>[];
237+
invoice?: Record<string, unknown>[];
238+
}) => ({
239+
objects: [
240+
{ name: 'task', actions: at.task ?? [] },
241+
{ name: 'invoice', actions: at.invoice ?? [] },
242+
],
243+
actions: at.global ?? [],
244+
views: [{ name: 'task', object: 'task', list: { bulkActions: ['recalc'] } }],
245+
});
246+
247+
const expectOneMismatch = (findings: Findings) => {
248+
expect(findings).toHaveLength(1);
249+
expect(findings[0]!.rule).toBe(ACTION_DISPATCH_CONTRACT_MISMATCH);
250+
expect(findings[0]!.path).toBe('views[0].list.bulkActions[0]');
251+
};
252+
253+
it('CONTROL: a global `recalc` declared `aggregate`, wired as a bare string, is refused', () => {
254+
expectOneMismatch(validateActionDispatchContract(disarmStack({ global: [RECALC_AGGREGATE] })));
255+
});
256+
257+
it('CONTROL: a same-named sibling that DECLARES the same contract leaves the finding standing', () => {
258+
expectOneMismatch(
259+
validateActionDispatchContract(disarmStack({ global: [RECALC_AGGREGATE], task: [RECALC_AGGREGATE] })),
260+
);
261+
expectOneMismatch(
262+
validateActionDispatchContract(disarmStack({ global: [RECALC_AGGREGATE], invoice: [RECALC_AGGREGATE] })),
263+
);
264+
});
265+
266+
it("an undeclared sibling embedded in the view's own object disarms the name", () => {
267+
expectOneMismatch(validateActionDispatchContract(disarmStack({ global: [RECALC_AGGREGATE] })));
268+
expect(
269+
validateActionDispatchContract(disarmStack({ global: [RECALC_AGGREGATE], task: [RECALC_UNDECLARED] })),
270+
).toEqual([]);
271+
});
272+
273+
it('an undeclared sibling on an UNRELATED object disarms the name too — declarations are not scoped by the view', () => {
274+
expectOneMismatch(validateActionDispatchContract(disarmStack({ global: [RECALC_AGGREGATE] })));
275+
expect(
276+
validateActionDispatchContract(disarmStack({ global: [RECALC_AGGREGATE], invoice: [RECALC_UNDECLARED] })),
277+
).toEqual([]);
278+
});
279+
280+
it('an undeclared GLOBAL sibling disarms an object-embedded declaration', () => {
281+
expectOneMismatch(validateActionDispatchContract(disarmStack({ task: [RECALC_AGGREGATE] })));
282+
expect(
283+
validateActionDispatchContract(disarmStack({ task: [RECALC_AGGREGATE], global: [RECALC_UNDECLARED] })),
284+
).toEqual([]);
285+
});
286+
});

‎packages/lint/src/validate-action-dispatch-contract.ts‎

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,36 @@ const MISFIRE: Readonly<Record<ActionDispatchContract, string>> = {
130130
* Every action name in the stack that DECLARES a dispatch contract, mapped to
131131
* the contract it declares.
132132
*
133-
* A name declared more than once (a global action and an object-embedded one,
134-
* or two objects) contributes only when every declaration agrees: two
135-
* declarations that disagree are a defect in the DECLARATIONS, not in any
136-
* wiring, and charging a view for it would name the wrong file. Undeclared
137-
* names are absent from the map, which is what makes "undeclared is not
138-
* defaulted" structural here rather than a branch someone can drop.
133+
* Action names are ONE flat namespace here, the same one
134+
* `validate-action-name-refs` resolves against: the global `actions` and every
135+
* object's embedded `actions`, pooled. Each name collects the set of contracts
136+
* its declarations carry, and an action that declares NO `execution` does not
137+
* drop out of that set: it enters it as `'none'`. A name contributes only when
138+
* its set holds exactly one member and that member is a real contract.
139+
*
140+
* ⇒ ABSENCE COUNTS AS DISAGREEMENT. One same-named action that declares no
141+
* `execution`, anywhere in the flat namespace, turns this rule OFF for that
142+
* name, for every view that wires it. "Anywhere" is literal: the view's own
143+
* object, the global list, or an object the view never resolves through.
144+
* Declarations are not scoped by the view's object; the map is built once for
145+
* the whole stack, before any view is read.
146+
*
147+
* That is deliberate, and it is the zero-false-positive posture (silence is the
148+
* safe direction) applied to the name rather than to the action:
149+
*
150+
* - two declarations that disagree are a defect in the DECLARATIONS, not in
151+
* any wiring, and charging a view for it would name the wrong file;
152+
* - an action that declares nothing is not unanimous with one that declares
153+
* a contract, because undeclared is not defaulted to either contract.
154+
* Counting it as agreement would be exactly that silent default, arriving
155+
* through a sibling.
156+
*
157+
* Undeclared names are absent from the map, which is what makes "undeclared is
158+
* not defaulted" structural here rather than a branch someone can drop; the
159+
* `'none'` member is what carries the same rule to a name that is declared in
160+
* one place and undeclared in another. The test file pins the disarm from the
161+
* view's own object, from an unrelated object and from the global list, each
162+
* beside a control that fires on the same stack without the sibling.
139163
*/
140164
function collectDeclaredContracts(stack: AnyRec): Map<string, ActionDispatchContract> {
141165
const seen = new Map<string, Set<ActionDispatchContract | 'none'>>();

0 commit comments

Comments
 (0)