Skip to content

Commit 66abef3

Browse files
os-warrenclaude
andauthored
feat(spec): publish the icontains text-comparand refusal (predicate + reason) beside FILTER_TEXT_CASES (#18438)
Fixes #18113 `@objectstack/spec/data` now publishes the case-insensitive-contains **text-comparand door** — the predicate for the two REJECTION rows `FILTER_TEXT_CASES` has declared since #5701, and the reason text they are refused with — so every face reads one implementation instead of writing its own copy of both halves. Executes objectui#9048 batch #133 item 1, ruling **D**. ## What landed | path | what | |---|---| | `packages/spec/src/data/filter-text-comparand.ts` | new — `isRefusedTextComparand`, `textComparandRefusalReason`, and the module-internal `describeComparand` | | `packages/spec/src/data/index.ts` | re-export (the entry-reachability hop the ruling names) | | `packages/spec/src/data/filter-text-comparand.test.ts` | new — both acceptance pins, driven from the table | | `packages/spec/api-surface/data.json`, `packages/spec/export-origins/data.json` | regenerated: **2 added, 0 removed** | | `.changeset/18113-icontains-text-comparand-refusal.md` | `minor` on `@objectstack/spec` | Measured file face: **6 paths**, all under `packages/spec` plus the changeset — disjoint from the sibling cards named at dispatch. ## The port is byte-exact, and that is measured rather than asserted The reference is objectui `packages/core/src/utils/text-comparand.ts` at objectui `origin/main` @ `55f39ee9`. That blob is `c29782139426fc1af51dac86f0a4b8d0b5d2827e` at `55f39ee9`, at objectui `HEAD` (`ff1d5ea8`) and in the read-only checkout — one file, not three readings. - **Source text**: the three function bodies extracted from both files and compared — `describeComparand` 159 bytes, `isRefusedTextComparand` 115, `textComparandRefusalReason` 928, **all three identical** (only the `export` modifier normalised, which is the publication decision, not the behaviour). - **Behaviour**: both implementations driven over 17 comparand shapes x 2 arriving spellings x 2 field names — **85 readings, 0 mismatches**, including the two shapes `describeComparand` exists for (a BigInt and a cyclic object, on which `JSON.stringify` throws). Both are one-off scripts, not committed test files. ## The acceptance pins `pnpm --filter @objectstack/spec exec vitest run src/data/filter-text-comparand.test.ts` — **46 passed**. 1. **Every** `FILTER_TEXT_CASES` case is driven through `isRefusedTextComparand` (24 cases, one `it` each), plus a whole-table set assertion that its TRUE set equals the rows the table declares refused for this operator, plus every `FILTER_TEXT_ROWS` stored `name`. 2. The reason for `''` and for a non-string comparand carries each row's `mustMention` tokens, **per arriving spelling**, plus a transcription pin on the exact bytes — `mustMention` only requires `$icontains`, so it cannot catch a reword, and a reword is what the fence forbids. **Ablation** (mutate, prove it hit disk, run, restore, prove the restore — both legs restored with `git hash-object` matching the HEAD blob and `git diff HEAD` empty): | leg | on-disk proof | result | |---|---|---| | reword one word of the reason | removed-text count 1 to 0, injected 1 | **1 failed** / 45 passed, exit 1 | | drop the `=== ''` arm from the predicate | removed-text count 1 to 0, injected 1 | **2 failed** / 44 passed, exit 1 | ## Two readings the card asked for **⚠️ The second acceptance clause is falsified for one spelling, and the fence outranks it.** The card asks that the reason contain each case's `mustMention` tokens "for BOTH arriving spellings". `mustMention` is `['$icontains']`; for an arriving `icontains` the reason names what arrived and therefore does **not** contain the `$`-dialect token. Rewording it to would break the byte-for-byte fence and diverge from two shipped faces. This is also what objectui#9152 already does about it — it names the `$` twin in its own tail, with a comment saying why the substitution would misdirect a view author. So the pin asserts the measured truth in both directions: the token for the `$` spelling, the token minus its sigil for the infix one, and `not.toContain` on the `$` form there. Flagged for the seat rather than settled by me. **The fold measurement — measured, not folded.** The note names `@objectstack/formula` and the engine. Neither is a literal copy of this predicate: - `packages/formula/src/matches-filter.ts:249` fuses the De Morgan complement into its positive arm (`typeof v === 'string' && v !== ''`) and **answers `false`** rather than refusing — its docblock says the totality is deliberate. - `packages/objectql/src/having-filter.ts:388` **refuses**, so it is not one of the faces that answer 0 of 9 rows. So: report the reading and leave it. What the sweep did turn up is in the acceptance notes below. ## Verification - `pnpm --filter @objectstack/spec test` — **483 files, 13734 tests, all passed**. - `pnpm --filter @objectstack/spec typecheck` — exit 0. Coverage proven rather than assumed: the main `tsconfig.json` excludes `**/*.test.ts`, and `--listFiles` on `tsconfig.test.json` (what `check:test-typecheck` runs) shows **both** new files in the program. - `pnpm --filter @objectstack/spec check:generated` — proved exactly `api-surface/` and `export-origins/` stale, `--fix` regenerated only those two. The gate's own diff line is the published-face evidence: `+ isRefusedTextComparand (function)`, `+ textComparandRefusalReason (function)` on `./data`, nothing else, and no `describeComparand`. - **Derived gate families**: `scripts/pm/dispatch-gates.mjs` derived **85** from the change set; **83 run, 2 NOT MEASURED, 0 unrun**, reconciled through `--ran` with a recorded exit code per family. - **Lint, narrowed and declared** (at `812a5bf565`): eslint's own config lints `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` repo-wide; `--format json` reports **3 files linted, 0 errors, 0 warnings**; and type-aware linting is **not** enabled for these files (`parserOptions.project`, `projectService` and `EXPERIMENTAL_useProjectService` all null under `--print-config`), so this diff cannot move the verdict on any file it does not touch. The repo-wide sweep is CI's. - **Changeset publishes** — measured, with both controls. `@objectstack/spec` is public at `17.4.0` and its `files[]` is `dist`, `json-schema`, `liveness`, `prompts`, `llms.txt`, `README.md`, `src/**/*.zod.ts`, `CHANGELOG.md`, `api-surface`, `spec-changes.json`. **Positive control**: `isRefusedTextComparand` appears in **6** shipped files under `dist/`, `dist/data/index.js` / `.mjs` / `.d.mts` and the browser build among them. **Negative control**: `ARRIVING_SPELLINGS`, which exists only in the new test file (2 occurrences in `src/`), appears in **0** shipped files — so the grep discriminates and the test layer does not ship. Note `src/**/*.zod.ts` IS published, but the new module is not a `.zod.ts`, so it reaches consumers only as built output. A published symbol moved, so this is `minor` — ⛔ not `skip-changeset`. **NOT MEASURED (2 of 85), both `exit 3 PREREQUISITE NOT MET`** — `check:dual-build-cjs-loads` and `check:type-check-debt` both refuse until the repo-wide build exists, and 54 of 69 buildable packages have no `dist/` in this worktree. A repo-wide build is CI's run (`Build Core`, `TypeScript Type Check`), not this card's; declaring the narrowing rather than reporting a green. ## Acceptance notes **A gate whose verdict depends on whether you built — `check:cross-package-test-inputs`, measured red here and green on `main`.** It names `packages/cli/test/init-created-files-summary.e2e.test.ts` descending into `packages/spec/dist/`; it names none of my files. Ablated to be sure: same tree, same sources, `dist/` parked aside then restored — **exit 1 with `packages/spec/dist/` present, exit 0 without it** (115 entries, restored). A pristine `origin/main` worktree also exits 0, because nothing there is built. So the finding is real and CI is structurally blind to it: the lint job does not build spec, and following AGENTS.md's mandatory "regenerate spec artifacts before pushing" is exactly what makes it visible. Filed as a finding for the seat, not fixed here. **Five faces carry the same discrimination inline, and none is in the note's named set.** `driver-sql:4047`, `driver-mongodb:296`, `driver-memory/filter-refusal.ts:911`, `objectql/having-filter.ts:388` and `driver-turso/remote-transport.ts:2989` each spell `typeof X !== 'string' || X === ''` and each throws its own `icontainsComparandError`. Folding them is a five-package diff with a message-text question inside it, well outside this card's measured face and outside what the note authorises. Noted, not filed; the natural carrier is whichever card next consolidates that refusal family. **`Clause-②: yes`** — two new exported symbols on a published entry. The carrier is the seat's on both the card and this PR; not hung, not stripped, not waited on here. --- _Generated by [Claude Code](https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b6fe311 commit 66abef3

6 files changed

Lines changed: 432 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.

‎packages/spec/api-surface/data.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,7 @@
767767
"isOrganizationUnique (function)",
768768
"isPlainRecord (function)",
769769
"isPublicSharingEnabled (function)",
770+
"isRefusedTextComparand (function)",
770771
"isRuntimeDefaultToken (function)",
771772
"isTenancyDisabled (function)",
772773
"isTextFilterOperator (function)",
@@ -826,6 +827,7 @@
826827
"stripLegacyApiMethods (function)",
827828
"suggestDefaultValueToken (function)",
828829
"suggestFieldTypeForSqlType (function)",
830+
"textComparandRefusalReason (function)",
829831
"textOperatorDoorVerdict (function)",
830832
"unprovisionedInjectedColumns (function)",
831833
"urlCredentialQueryParams (function)",

‎packages/spec/export-origins/data.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,7 @@
754754
"isOrganizationUnique": "src/data/field.zod.ts#isOrganizationUnique (function)",
755755
"isPlainRecord": "src/data/authoring-key-lint.ts#isPlainRecord (function)",
756756
"isPublicSharingEnabled": "src/data/object.zod.ts#isPublicSharingEnabled (function)",
757+
"isRefusedTextComparand": "src/data/filter-text-comparand.ts#isRefusedTextComparand (function)",
757758
"isRuntimeDefaultToken": "src/data/default-value-tokens.ts#isRuntimeDefaultToken (function)",
758759
"isTenancyDisabled": "src/data/object.zod.ts#isTenancyDisabled (function)",
759760
"isTextFilterOperator": "src/data/filter-text-operator-declared-type.ts#isTextFilterOperator (function)",
@@ -813,6 +814,7 @@
813814
"stripLegacyApiMethods": "src/data/object.zod.ts#stripLegacyApiMethods (function)",
814815
"suggestDefaultValueToken": "src/data/default-value-shape.ts#suggestDefaultValueToken (function)",
815816
"suggestFieldTypeForSqlType": "src/data/type-compat.ts#suggestFieldTypeForSqlType (function)",
817+
"textComparandRefusalReason": "src/data/filter-text-comparand.ts#textComparandRefusalReason (function)",
816818
"textOperatorDoorVerdict": "src/data/filter-text-operator-declared-type.ts#textOperatorDoorVerdict (function)",
817819
"unprovisionedInjectedColumns": "src/data/injected-system-column-provenance.ts#unprovisionedInjectedColumns (function)",
818820
"urlCredentialQueryParams": "src/data/driver/common.zod.ts#urlCredentialQueryParams (function)",
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)