Skip to content

Commit bf33ab0

Browse files
committed
feat(spec): publish the icontains text-comparand refusal beside FILTER_TEXT_CASES
`@objectstack/spec/data` now exports `isRefusedTextComparand` and `textComparandRefusalReason` — the predicate for the two REJECTION rows `FILTER_TEXT_CASES` declares for the case-insensitive contains operator, and the CONTRACT half of the message they are refused with (no leading capital, no trailing period, no envelope). Ported byte-for-byte from the reference implementation rather than reworded: `mustMention` is what makes the wording load-bearing, and two shipped faces already emit these exact bytes. `operator` is the spelling that ARRIVED, never a canonical substitute. `describeComparand` travels with the reason as a module-internal helper, so the published face grows by exactly two symbols. Pins drive every `FILTER_TEXT_CASES` case through the predicate and assert the reason's `mustMention` tokens for both arriving spellings. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 588475c commit bf33ab0

4 files changed

Lines changed: 428 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`@objectstack/spec/data` publishes the case-insensitive-contains **text-comparand door** — `isRefusedTextComparand(target)` and `textComparandRefusalReason(field, operator, target)` — so every face reads one implementation of a refusal the package already declared as data (#18113, objectui#9048 ruling D).
6+
7+
`FILTER_TEXT_CASES` has carried two REJECTION rows for that operator since #5701 — an empty comparand and a non-string one, both `code: 'INVALID_FILTER'`, both `mustMention: ['$icontains']` — but only as cases a backend is *checked against*. Every face that honoured them wrote its own copy of the discrimination and its own wording, which is how the same authored filter came to be refused in one dialect and lowered onto the wire in another. The rule now lives with the producer of the rule.
8+
9+
- **`isRefusedTextComparand(target)`** answers `true` for exactly those two shapes. It answers `true` for `undefined` as well: a vocabulary with an "absent" the `$` dialect does not have (a stored view rule whose operator takes no comparand) must test for absence **before** this door — that carve-out is the caller's, not a third row.
10+
- **`textComparandRefusalReason(field, operator, target)`** returns the CONTRACT half of the message: **no leading capital, no trailing period, no envelope**, so each face seats it in its own sentence — a matcher that has a row to exclude logs it, a producer that has none throws it. ⛔ No new error code: `INVALID_FILTER` is declared and already in the ADR-0112 ledger.
11+
- **`operator` is the spelling that ARRIVED** (`$icontains` from a `$`-dialect filter, `icontains` from the infix/view vocabulary), never a canonical substitute — telling an author about a key their dialect cannot contain is the misdirection this door exists to end.
12+
- ⚠️ **Consequence for the infix dialect**: `mustMention` is spelled `$icontains` because the published rows' filters are, so for an arriving `icontains` the reason names what arrived and does **not** carry the `$`-dialect token. The face serving that vocabulary names the `$` twin in its own tail. Pinned in both directions in `filter-text-comparand.test.ts`.
13+
- **The message bytes are the contract, not prose.** They are the bytes two shipped faces already emit byte for byte; `mustMention` is what makes a reword a different failure to honour the same row, and a transcription pin catches the reword `mustMention` cannot. ⛔ Change them only by changing the rows they answer.
14+
15+
Additive: no existing export changes, no behaviour moves. `describeComparand` — the guard that keeps a BigInt or a cyclic comparand from making `JSON.stringify` throw *inside* the refusal — travels with the reason as a module-internal helper and is deliberately not published; exporting it is a published-surface decision for the PR that needs it.
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#18113] The text-comparand door, driven against the table it answers.
5+
*
6+
* `filter-text-conformance.test.ts` proves `FILTER_TEXT_CASES` is internally
7+
* honest. This file proves the PREDICATE and the REASON published beside it
8+
* answer that same table — the two halves are only worth publishing together
9+
* if they cannot drift apart, and a predicate written from the rows by hand is
10+
* exactly how they drift.
11+
*
12+
* Both pins are driven from the table rather than from a transcribed list, so a
13+
* row added to `FILTER_TEXT_CASES` arrives here automatically.
14+
*/
15+
16+
import { describe, it, expect } from 'vitest';
17+
import {
18+
FILTER_TEXT_CASES,
19+
FILTER_TEXT_ROWS,
20+
type FilterTextCase,
21+
type FilterTextRejectionCase,
22+
} from './filter-text-conformance';
23+
import { isRefusedTextComparand, textComparandRefusalReason } from './filter-text-comparand';
24+
// Entry reachability, the ruling's actual unblock criterion: the objectui half
25+
// is gated on the INSTALLED `@objectstack/spec` exporting these two names, so
26+
// the barrel hop is pinned here and `check:api-surface` covers the built face.
27+
import * as dataEntry from './index';
28+
29+
/** The operator this door is scoped to, in the two spellings that can ARRIVE. */
30+
const ARRIVING_SPELLINGS = ['$icontains', 'icontains'] as const;
31+
32+
const isRejection = (c: FilterTextCase): c is FilterTextRejectionCase =>
33+
'expectRejection' in c && c.expectRejection === true;
34+
35+
/** Every (field, operator, comparand) triple a case's filter carries. */
36+
function comparands(c: FilterTextCase): Array<{ field: string; operator: string; target: unknown }> {
37+
const out: Array<{ field: string; operator: string; target: unknown }> = [];
38+
for (const [field, ops] of Object.entries(c.filter as Record<string, Record<string, unknown>>)) {
39+
for (const [operator, target] of Object.entries(ops)) out.push({ field, operator, target });
40+
}
41+
return out;
42+
}
43+
44+
/** The case-insensitive contains operator, in whichever dialect the row is written. */
45+
const isTheOperator = (operator: string) => operator === '$icontains' || operator === 'icontains';
46+
47+
// ---------------------------------------------------------------------------
48+
// 1. The predicate answers EXACTLY the two declared REJECTION rows
49+
// ---------------------------------------------------------------------------
50+
51+
describe('#18113 — isRefusedTextComparand, driven through every FILTER_TEXT_CASES case', () => {
52+
it('the table still carries both verdicts, so this suite is not vacuous', () => {
53+
expect(FILTER_TEXT_CASES.filter(isRejection).length).toBeGreaterThan(0);
54+
expect(FILTER_TEXT_CASES.filter((c) => !isRejection(c)).length).toBeGreaterThan(0);
55+
});
56+
57+
for (const c of FILTER_TEXT_CASES) {
58+
const rejection = isRejection(c);
59+
it(`${rejection ? 'REJECTION' : 'rows'}: ${c.name}`, () => {
60+
for (const { operator, target } of comparands(c)) {
61+
// The door is scoped to ONE operator (the module's Scope section). A
62+
// comparand on any other operator is not its question, and answering
63+
// one would be the widening-by-analogy the table reserves to itself.
64+
const expected = rejection && isTheOperator(operator);
65+
expect(
66+
isRefusedTextComparand(target),
67+
`${c.name} — comparand ${JSON.stringify(target) ?? String(target)} on '${operator}'`,
68+
).toBe(expected);
69+
}
70+
});
71+
}
72+
73+
it('answers TRUE for exactly the two rows the table declares refused for this operator', () => {
74+
// The whole-table reading, stated as a set rather than per case: whatever
75+
// rows arrive later, the predicate's TRUE set must stay equal to the
76+
// REJECTION rows written in this operator's dialect.
77+
const trueFor = FILTER_TEXT_CASES
78+
.filter((c) => comparands(c).some(({ target }) => isRefusedTextComparand(target)))
79+
.map((c) => c.name);
80+
const declared = FILTER_TEXT_CASES
81+
.filter((c) => isRejection(c) && comparands(c).some(({ operator }) => isTheOperator(operator)))
82+
.map((c) => c.name);
83+
expect(trueFor).toEqual(declared);
84+
expect(trueFor).toHaveLength(2);
85+
});
86+
87+
it('answers FALSE for the RETIRED-operator rejections — they are a different door', () => {
88+
// Measured, and the reason the set above is not simply "every REJECTION
89+
// case": `$regex` / `$options` rows are refused because the OPERATOR is
90+
// retired (`RETIRED_FILTER_OPERATORS` carries their prescription), and
91+
// their comparands are perfectly ordinary non-empty strings. A predicate
92+
// that answered TRUE for them would be reporting the wrong repair.
93+
const retired = FILTER_TEXT_CASES.filter(
94+
(c) => isRejection(c) && !comparands(c).some(({ operator }) => isTheOperator(operator)),
95+
);
96+
expect(retired.length, 'the table still carries retired-operator rejections').toBeGreaterThan(0);
97+
for (const c of retired) {
98+
for (const { target } of comparands(c)) expect(isRefusedTextComparand(target), c.name).toBe(false);
99+
}
100+
});
101+
102+
it('answers FALSE for every stored NAME in the fixture', () => {
103+
// The card's other half: nothing the fixture stores is a refused comparand,
104+
// so a face cannot pass this door by refusing its own test data. (`score` is
105+
// deliberately not asked — it is a STORED value, never a comparand; the
106+
// rows that aim a text operator at it are `expected: []`, not rejections.)
107+
for (const row of FILTER_TEXT_ROWS) expect(isRefusedTextComparand(row.name), row.name).toBe(false);
108+
});
109+
110+
it('answers TRUE for `undefined` — the carve-out a caller owns, not a third row', () => {
111+
// Pinned because a vocabulary with an "absent" (a view rule whose operator
112+
// takes no comparand) must test absence BEFORE this door, and the module's
113+
// docblock promises exactly this answer to callers that do.
114+
expect(isRefusedTextComparand(undefined)).toBe(true);
115+
expect(FILTER_TEXT_CASES.filter(isRejection)).toHaveLength(5);
116+
});
117+
});
118+
119+
// ---------------------------------------------------------------------------
120+
// 2. The reason names each row's `mustMention` tokens, per ARRIVING spelling
121+
// ---------------------------------------------------------------------------
122+
123+
describe('#18113 — textComparandRefusalReason names what the row requires', () => {
124+
const rows = FILTER_TEXT_CASES.filter(
125+
(c): c is FilterTextRejectionCase =>
126+
isRejection(c) && comparands(c).some(({ operator }) => isTheOperator(operator)),
127+
);
128+
129+
for (const c of rows) {
130+
const { field, target } = comparands(c).find(({ operator }) => isTheOperator(operator))!;
131+
132+
it(`${c.name} — the $-dialect spelling carries every mustMention token`, () => {
133+
const reason = textComparandRefusalReason(field, '$icontains', target);
134+
for (const token of c.mustMention) expect(reason, `${c.name} / ${token}`).toContain(token);
135+
});
136+
137+
it(`${c.name} — the INFIX spelling names what arrived; the $ twin is the envelope's job`, () => {
138+
// ⚠️ Measured, and deliberate. `mustMention` is spelled in the `$` dialect
139+
// because the published rows' filters are. A view rule spells the same
140+
// operator `icontains`, and this function names the spelling that
141+
// ARRIVED — so the `$` token is NOT in the contract half for that
142+
// dialect. The face serving that vocabulary names the `$` twin in its own
143+
// tail (objectui#9152 does exactly this); prescribing it here would send
144+
// a view author looking for a key their metadata cannot contain.
145+
const reason = textComparandRefusalReason(field, 'icontains', target);
146+
for (const token of c.mustMention) {
147+
expect(reason, `${c.name} / ${token} without its dialect sigil`).toContain(token.replace(/^\$/, ''));
148+
expect(reason, `${c.name} / the $ twin is NOT substituted for what arrived`).not.toContain(token);
149+
}
150+
});
151+
152+
for (const operator of ARRIVING_SPELLINGS) {
153+
it(`${c.name} — '${operator}' arrives verbatim, never a canonical substitute`, () => {
154+
const reason = textComparandRefusalReason(field, operator, target);
155+
expect(reason).toContain(`on operator '${operator}'`);
156+
expect(reason).toContain(`the declared comparand for '${operator}'`);
157+
expect(reason).toContain(`field '${field}'`);
158+
});
159+
160+
it(`${c.name} — '${operator}' is seatable: no leading capital, no trailing period`, () => {
161+
// The shape contract each face depends on to seat this in its own
162+
// sentence. Both shipped faces read it mid-sentence.
163+
const reason = textComparandRefusalReason(field, operator, target);
164+
expect(reason[0]).toBe(reason[0]!.toLowerCase());
165+
expect(reason.endsWith('.')).toBe(false);
166+
expect(reason).toContain('INVALID_FILTER');
167+
});
168+
}
169+
}
170+
171+
it('the two rows get DIFFERENT reasons — the empty string is not "not a string"', () => {
172+
const empty = textComparandRefusalReason('name', '$icontains', '');
173+
const nonString = textComparandRefusalReason('name', '$icontains', 42);
174+
expect(empty).not.toEqual(nonString);
175+
expect(empty).toContain('EMPTY STRING');
176+
expect(nonString).toContain('not a string');
177+
});
178+
179+
it('the BYTES, transcribed — a reword is a different failure to honour the same row', () => {
180+
// `mustMention` cannot catch a reword: it only requires `$icontains`. These
181+
// are the bytes two objectui faces have shipped since objectui#8748 /
182+
// objectui#9001, which is what makes them the contract rather than prose.
183+
// ⛔ Change them only by changing the rows they answer.
184+
expect(textComparandRefusalReason('name', '$icontains', '')).toBe(
185+
"filter comparand for field 'name' on operator '$icontains' is the EMPTY STRING. "
186+
+ 'Every value contains the empty substring, so evaluating it is a predicate that '
187+
+ "constrains nothing. @objectstack/spec's FILTER_TEXT_CASES declares this shape "
188+
+ "refused (INVALID_FILTER); the declared comparand for '$icontains' is a NON-EMPTY "
189+
+ 'STRING. Drop the condition instead of sending an empty comparand',
190+
);
191+
expect(textComparandRefusalReason('name', '$icontains', 42)).toBe(
192+
"filter comparand for field 'name' on operator '$icontains' is number (42), not a "
193+
+ "string. Coercing it would answer a query nobody wrote. @objectstack/spec's "
194+
+ 'FILTER_TEXT_CASES declares this shape refused (INVALID_FILTER); the declared '
195+
+ "comparand for '$icontains' is a NON-EMPTY STRING. Write the comparand as a string",
196+
);
197+
});
198+
199+
it('describes a comparand JSON.stringify would THROW on, instead of throwing', () => {
200+
// The guard that travels with the text: on a throwing face a `TypeError`
201+
// raised while BUILDING the message escapes in the refusal's place.
202+
const cyclic: Record<string, unknown> = {};
203+
cyclic.self = cyclic;
204+
expect(() => textComparandRefusalReason('name', '$icontains', cyclic)).not.toThrow();
205+
expect(() => textComparandRefusalReason('name', '$icontains', 10n)).not.toThrow();
206+
expect(textComparandRefusalReason('name', '$icontains', 10n)).toContain('bigint');
207+
// `null` is reported as `null`, not as `object` — the typeof trap.
208+
expect(textComparandRefusalReason('name', '$icontains', null)).toContain('is null (null)');
209+
// `undefined` and symbols stay readable, which `JSON.stringify` alone does not.
210+
expect(textComparandRefusalReason('name', '$icontains', undefined)).toContain('undefined');
211+
});
212+
});
213+
214+
// ---------------------------------------------------------------------------
215+
// 3. Reachability — the ruling's unblock criterion is an EXPORT, not a merge
216+
// ---------------------------------------------------------------------------
217+
218+
describe('#18113 — both names reach the data entry', () => {
219+
it('are re-exported from the barrel a consumer imports', () => {
220+
// objectui#9048 is `pm:blocked` until the INSTALLED `@objectstack/spec`
221+
// exports these. A module nothing re-exports satisfies the card's letter
222+
// and none of its purpose, so the hop is pinned rather than assumed.
223+
expect(typeof (dataEntry as Record<string, unknown>).isRefusedTextComparand).toBe('function');
224+
expect(typeof (dataEntry as Record<string, unknown>).textComparandRefusalReason).toBe('function');
225+
});
226+
227+
it('and `describeComparand` deliberately does NOT — two new symbols, not three', () => {
228+
// ⚠️ Not a ban. #18113 declared TWO new exported symbols (Clause-② carrier),
229+
// so the helper stays internal. If a face needs to describe a comparand in
230+
// its OWN envelope text, export it in the PR that needs it and say so, so
231+
// the addition is a decision rather than a side effect of `export *`.
232+
expect(Object.prototype.hasOwnProperty.call(dataEntry, 'describeComparand')).toBe(false);
233+
});
234+
});

0 commit comments

Comments
 (0)