diff --git a/.changeset/7265-data-objectstack-filter-operator-rename.md b/.changeset/7265-data-objectstack-filter-operator-rename.md new file mode 100644 index 0000000000..f301063e53 --- /dev/null +++ b/.changeset/7265-data-objectstack-filter-operator-rename.md @@ -0,0 +1,27 @@ +--- +--- + +Internal only, no release: `@object-ui/data-objectstack` stops declaring a +module-local `normalizeFilterOperator`, the exact name `@objectstack/spec/ui` +exports, and calls it `toAstFilterOperator` instead (objectui#7265, the +`@object-ui/data-objectstack` slice of the DEBT ledger in +`scripts/check-spec-symbol-derivation.mjs`). + +The route is RENAME rather than BIND, and it was decided by measurement against +the RESOLVED 17.4.0 pin rather than by the name. The spec's function folds an +authored spelling to the canonical VIEW vocabulary so `ViewFilterRuleSchema`'s +enum can judge it; this package's translates the same input into the server's +filter-AST symbols, so the spec answers `equals` and `before` where this one +answers `=` and `<`. Same input, different codomain: swapping it in would have +changed what goes on the wire for most of the operators a stored view can carry. + +Two things the measurement overturned, both now pinned. The `?? op` tail is not +where the two differ — both hand an unrecognised string back unchanged, so the +lenient tail is common ground. The tail does diverge on the other arm, and in the +opposite direction: the spec returns a NON-string verbatim where this package +returns `null`, and `objectFilterEntryToAST` reads that `null` into a +`MalformedFilterError`. Binding the spec would therefore have widened what this +adapter accepts onto the wire, not tightened it. + +The declaration was never exported and no behaviour moved with the name, so +nothing published changes and no package is released by this change. diff --git a/packages/data-objectstack/src/filter-operator-ast-parity.test.ts b/packages/data-objectstack/src/filter-operator-ast-parity.test.ts index fd88799766..21f7628bb5 100644 --- a/packages/data-objectstack/src/filter-operator-ast-parity.test.ts +++ b/packages/data-objectstack/src/filter-operator-ast-parity.test.ts @@ -10,11 +10,17 @@ * Adapter operator table → filter-AST parity (#2901, objectstack#3948, #3641). * * `FILTER_OPERATOR_ALIASES` is the last translation a filter passes through - * before it goes on the wire, and `normalizeFilterOperator` ends in `?? op` — + * before it goes on the wire, and `toAstFilterOperator` ends in `?? op` — * an unmapped operator is emitted verbatim. The server then rejects the shape * at `isFilterAST()`, passes the array through unconverted, and driver-sql * skips it entirely: **no WHERE clause, no error, every row returned.** * + * (`toAstFilterOperator` was spelled `normalizeFilterOperator` until + * objectui#7265 renamed it: `@objectstack/spec/ui` exports a DIFFERENT function + * under that name, one that folds to the canonical VIEW vocabulary rather than + * to the AST symbols this table produces. The rename is pinned in + * `scripts/__tests__/spec-symbol-ledger-data-objectstack-7265.test.ts`.) + * * So a missing row in this table is not a validation failure, it is an * unfiltered query. `before`/`after` — canonical members of the spec's * `VIEW_FILTER_OPERATORS` — were missing, which is exactly how a stored @@ -48,7 +54,7 @@ */ import { describe, it, expect } from 'vitest'; import { VALID_AST_OPERATORS } from '@objectstack/spec/data'; -import { VIEW_FILTER_OPERATORS } from '@objectstack/spec/ui'; +import { VIEW_FILTER_OPERATORS, normalizeFilterOperator } from '@objectstack/spec/ui'; import { FILTER_OPERATOR_ALIASES } from './index'; /** @@ -106,7 +112,7 @@ describe('FILTER_OPERATOR_ALIASES lands inside the spec AST vocabulary', () => { }); it('has a mapping row for every canonical view operator the spec defines', () => { - // Resolution mirrors `normalizeFilterOperator` — lowercased spelling first, + // Resolution mirrors `toAstFilterOperator` — lowercased spelling first, // then the operator as written — but stops short of its `?? op` tail. That // tail is production behaviour and must stay there; reproducing it HERE is // what cancelled this assertion (#3641), because the value it falls back to @@ -133,3 +139,64 @@ describe('FILTER_OPERATOR_ALIASES lands inside the spec AST vocabulary', () => { expect(FILTER_OPERATOR_ALIASES.after).toBe('>'); }); }); + +/** + * This table is NOT the spec's `normalizeFilterOperator` (objectui#7265). + * + * The local fold over this table was called `normalizeFilterOperator` until that + * card renamed it `toAstFilterOperator`, because `@objectstack/spec/ui` exports a + * function of that name and most of this monorepo imports it. The two take the + * same input and land in different vocabularies, which is the whole reason the + * route was RENAME and not BIND — so the difference is measured here, against the + * resolved pin, rather than asserted in a comment. The spec-side half of the same + * measurement (its fold, its `?? op` tail, its lenient non-string arm) is in + * `scripts/__tests__/spec-symbol-ledger-data-objectstack-7265.test.ts`. + */ +describe('this table is a different vocabulary from the spec fold that shares its old name', () => { + /** Resolution as `toAstFilterOperator` does it, minus its `?? op` tail. */ + const row = (op: string) => FILTER_OPERATOR_ALIASES[op.toLowerCase()] ?? FILTER_OPERATOR_ALIASES[op]; + + it('answers an AST symbol where the spec answers a canonical view word', () => { + const viewVocabulary = new Set(VIEW_FILTER_OPERATORS); + + // Both spellings of one operator: the spec folds them together onto a view + // word, this table translates them together onto a wire symbol. + for (const spelling of ['eq', 'equals']) { + expect(String(normalizeFilterOperator(spelling))).toBe('equals'); + expect(row(spelling)).toBe('='); + } + // …and the two answers are in different vocabularies, not two spellings of + // one. Read off the spec's own list rather than restated. + expect(viewVocabulary.has('equals')).toBe(true); + expect(viewVocabulary.has('=')).toBe(false); + }); + + it('…and it is most of the vocabulary, not one operator — enumerated, never counted', () => { + // Every canonical view operator this adapter is the bridge for, plus the + // legacy spellings this table carries rows for. The set is DERIVED so it + // cannot go stale, and the assertion is a floor on which members diverge + // rather than a number, per AGENTS.md #9. + const corpus = [ + ...VIEW_FILTER_OPERATORS.filter((op) => !NOT_THIS_ADAPTERS_JOB.has(op)), + ...Object.keys(FILTER_OPERATOR_ALIASES), + ]; + const agree: string[] = []; + const diverge: string[] = []; + for (const op of new Set(corpus)) { + (String(normalizeFilterOperator(op)) === String(row(op)) ? agree : diverge).push(op); + } + + // The divergence is the finding… + expect(diverge).toContain('eq'); + expect(diverge).toContain('greater_than'); + expect(diverge).toContain('before'); + expect(diverge.length).toBeGreaterThan(agree.length); + + // …and the agreement is the lit control that keeps it from being vacuous: a + // comparison where NOTHING matched would mean the probe is broken, not that + // the functions differ. `contains` is an identity row on both sides. + expect(agree).toContain('contains'); + expect(String(normalizeFilterOperator('contains'))).toBe('contains'); + expect(row('contains')).toBe('contains'); + }); +}); diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index 2de35fc82b..b8e2b6c53f 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -146,7 +146,37 @@ export const FILTER_OPERATOR_ALIASES: Record = { after: '>', }; -function normalizeFilterOperator(op: unknown): string | null { +/** + * Resolve an authored filter operator to the AST symbol the wire takes. + * + * Deliberately NOT named `normalizeFilterOperator`. That name belongs to a + * DIFFERENT function, which `@objectstack/spec/ui` exports and which this + * monorepo's view layer imports from there (`viewFilterFold`, + * `filter-converter`, `ListView`, `UserFilters`, the FilterBuilder). The two + * take the same input and agree on nothing else: the spec's folds a legacy + * spelling to the canonical VIEW vocabulary (`VIEW_FILTER_OPERATORS`) so that + * `ViewFilterRuleSchema`'s enum can judge it, leaving a canonical operator + * unchanged — `eq` becomes `equals` and `before` stays `before`. This one + * translates the same input into the server's filter-AST symbols through + * {@link FILTER_OPERATOR_ALIASES} — `eq` AND `equals` both become `=`, and + * `before` becomes `<`. Which spellings the two disagree on is enumerated by + * the pin named below rather than written down here; it is most of them. + * + * Renamed at objectui#7265, off the ledger in + * `scripts/check-spec-symbol-derivation.mjs`, so that a reader who has seen + * `normalizeFilterOperator` anywhere else in this tree cannot read the call + * below as the same fold. Nothing about the behaviour moved with the name. + * + * The `?? op` tail is shared with the spec's, and load-bearing for the same + * reason: an entry already written in AST form (`'='`, `'nin'`) has no row here + * and must pass through. The `null` arm is NOT shared — the spec's hands a + * non-string back verbatim (its body ends `return op as string`), this one + * refuses, and `objectFilterEntryToAST` turns the refusal into a + * `MalformedFilterError` instead of putting a number in the operator slot of a + * tuple it is about to send. Both halves are pinned, in both directions, in + * `scripts/__tests__/spec-symbol-ledger-data-objectstack-7265.test.ts`. + */ +function toAstFilterOperator(op: unknown): string | null { if (typeof op !== 'string') return null; const lower = op.toLowerCase(); return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op; @@ -487,7 +517,7 @@ function objectFilterEntryToAST(entry: any): [string, string, any] | null { // `name`-keyed rule cannot be saved as view metadata in the first place. const field = entry.field; const rawOp = entry.operator ?? entry.op ?? '='; - const op = normalizeFilterOperator(rawOp); + const op = toAstFilterOperator(rawOp); if (!field || !op) return null; return [String(field), op, entry.value]; } diff --git a/packages/data-objectstack/src/spec-symbol-batch6.test.ts b/packages/data-objectstack/src/spec-symbol-batch6.test.ts index bcd8596279..6943d65fd9 100644 --- a/packages/data-objectstack/src/spec-symbol-batch6.test.ts +++ b/packages/data-objectstack/src/spec-symbol-batch6.test.ts @@ -19,6 +19,13 @@ * MetadataSaveOptions → MetadataClientSaveOptions * ValidationError → DataApiValidationError * + * A fourth rename joined the table later, from a different ledger: + * `normalizeFilterOperator` → `toAstFilterOperator` (objectui#7265, rule 1's + * DEBT block in `scripts/check-spec-symbol-derivation.mjs`). It is here because + * the guard a rename needs is this one, whichever ledger sent it; what is + * specific to it -- the site, the block, and the behaviour that refused BIND -- + * is in `scripts/__tests__/spec-symbol-ledger-data-objectstack-7265.test.ts`. + * * The batch's own triage note said "data-objectstack is a direct client of the * spec protocol, so `SecurityPolicy` / `DroppedFieldsEvent` are probably hand * copies — derive them first". Half of that held: `DroppedFieldsEvent` was @@ -143,6 +150,20 @@ const RENAMES: Array<[local: string, formerly: string, specMeaning: string]> = [ 'ValidationError', 'a plain { field, message, code? } entry in a validation report', ], + // objectui#7265's `@object-ui/data-objectstack` slice. Appended here rather + // than given a file of its own: this is the package's spec-symbol parity file + // and the shape it already holds is exactly the one a rename needs. Unlike the + // three above, the renamed symbol is a FUNCTION and a module-local one, so the + // probe above has to see VALUE exports as well as types -- it does, it reads + // the checker's `getExportsOfModule`, and the assertion below would go quiet + // rather than red if it ever stopped. + [ + 'toAstFilterOperator', + 'normalizeFilterOperator', + "folding an authored spelling to the canonical VIEW vocabulary (`eq` -> `equals`), the " + + '`z.preprocess` step on `ViewFilterRuleSchema.operator` -- NOT a translation to the ' + + 'filter-AST symbols this package emits', + ], ]; describe('renamed local concepts do not collide with a spec export', () => { diff --git a/packages/plugin-view/src/config/__tests__/view-operator-builder-parity.test.ts b/packages/plugin-view/src/config/__tests__/view-operator-builder-parity.test.ts index 1539499f54..9ef10a7154 100644 --- a/packages/plugin-view/src/config/__tests__/view-operator-builder-parity.test.ts +++ b/packages/plugin-view/src/config/__tests__/view-operator-builder-parity.test.ts @@ -10,10 +10,12 @@ * View operator → FilterBuilder operator parity (#2901, #2945). * * The third of objectui's spec-operator translation tables. The other two — - * `ListView.mapOperator` and `data-objectstack`'s `normalizeFilterOperator` — - * were pinned to the spec in #2974, which found eight spellings they had missed - * by enumerating instead of deriving. This one maps the same vocabulary onto the - * FilterBuilder's operator ids, and it had missed nine: + * `ListView.mapOperator` and `data-objectstack`'s `toAstFilterOperator` (spelled + * `normalizeFilterOperator` until objectui#7265 renamed it off the spec's own + * export of that name) — were pinned to the spec in #2974, which found eight + * spellings they had missed by enumerating instead of deriving. This one maps + * the same vocabulary onto the FilterBuilder's operator ids, and it had missed + * nine: * * not_equals, greater_than, less_than, greater_than_or_equal, * less_than_or_equal, starts_with, ends_with, is_null, is_not_null diff --git a/scripts/__tests__/spec-symbol-ledger-data-objectstack-7265.test.ts b/scripts/__tests__/spec-symbol-ledger-data-objectstack-7265.test.ts new file mode 100644 index 0000000000..8beab92793 --- /dev/null +++ b/scripts/__tests__/spec-symbol-ledger-data-objectstack-7265.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + normalizeFilterOperator, + VIEW_FILTER_OPERATORS, + VIEW_FILTER_OPERATOR_ALIASES, +} from '@objectstack/spec/ui'; + +// Plain-JS CI helper. Its types are INFERRED from the .mjs source by +// `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here. +import { scanFile } from '../check-spec-symbol-derivation.mjs'; + +/** + * objectui#7265, the `@object-ui/data-objectstack` slice -- the one name this + * package held in rule 1's DEBT block, in one file, and the first of the card's + * slices where that name belonged to a FUNCTION. + * + * Sibling of `spec-symbol-ledger-core-7265.test.ts`, + * `spec-symbol-ledger-app-shell-7265.test.ts`, + * `spec-symbol-ledger-types-7265.test.ts` and + * `spec-symbol-ledger-components-7265.test.ts`, same two-part shape, because a + * ledger needs both halves: + * + * 1. THE SITE. The real scanner, run over the real file. This is the half that + * reds if a local copy comes back -- deleting a name from a ledger is not a + * burn-down unless the declaration went with it. + * 2. THE BLOCK. Shrink-only is the card's own invariant, so it is asserted, + * not just respected. Stated as a ceiling rather than an equality so the + * last slice can shrink it to nothing without touching this file. + * + * ...and a third half this slice had to add, because a FUNCTION is decided by + * what it DOES and a type is decided by what it holds: + * + * 3. THE BEHAVIOUR THAT REFUSED BIND. The spec really does publish a + * `normalizeFilterOperator`, this repo really does import it in several + * other packages, and none of that made it the same function. The probes + * below measure the spec's half of that claim at the RESOLVED pin; the + * local half is measured beside the table it belongs to, in + * `packages/data-objectstack/src/filter-operator-ast-parity.test.ts`. + * + * WHY THE ROUTE WAS RENAME, in one line: the spec's folds an authored spelling + * to the canonical VIEW vocabulary so `ViewFilterRuleSchema`'s enum can judge + * it; this package's translated the same input into the server's filter-AST + * SYMBOLS. Same input, different codomain, so binding would have changed what + * goes on the wire. + * + * WHAT THE MEASUREMENT OVERTURNED, and the reason it is pinned rather than + * narrated: the seeding note expected the `?? op` tail to be the divergence -- + * this one lenient, the spec's refusing. It is the other way round. Both hand an + * unrecognised STRING through unchanged, so the tail is COMMON ground; and on + * the non-string arm it is the SPEC that is lenient (`return op as string`) + * while this package refuses with a `null` its caller turns into a + * `MalformedFilterError`. Binding would therefore have WIDENED what this adapter + * accepts onto the wire. Both directions are probed below. + * + * ⚠️ The scanner is only evidence if it can fail, so the site assertion is + * paired with fixtures of the same kind that it MUST flag. A green scan with an + * empty `specNames` map, or over a path that does not exist, looks exactly like + * a green scan over a burned-down site. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const gateSource = path.join(repoRoot, 'scripts/check-spec-symbol-derivation.mjs'); + +/** The name that was BURNED DOWN by renaming off it. */ +const FORMERLY = 'normalizeFilterOperator'; +/** What it is called now -- the codomain that makes it a different function. */ +const RENAMED = 'toAstFilterOperator'; +/** The subpath the spec owns the old name on. */ +const SUBPATH = '@objectstack/spec/ui'; +/** The single site it lived at. */ +const SITE = 'packages/data-objectstack/src/index.ts'; +/** The ledger key a waiver would have been written under. */ +const ALLOW_KEY = `@object-ui/data-objectstack:${FORMERLY}`; + +/** + * The names rule 1 matches on, built for the one name this slice dealt with + * rather than typed out as a list -- a hand-written name list would keep + * asserting a collision after the spec stopped exporting the symbol. + */ +const specNamesForFormerly = (): Map> => + new Map([[FORMERLY, new Set([SUBPATH])]]); + +/** Reads one `const NAME = { … };` block out of the gate's source text. */ +function ledgerBlock(name: string): string { + const text = fs.readFileSync(gateSource, 'utf8'); + const start = text.indexOf(`const ${name} = {\n`); + expect(start, `${name} block not found in the gate source`).toBeGreaterThan(-1); + const end = text.indexOf('\n};\n', start); + expect(end, `${name} block is not terminated`).toBeGreaterThan(start); + return text.slice(start, end + 3); +} + +const ledgerNames = (block: string) => [...block.matchAll(/^ {4}"([^"]+)",$/gm)].map((m) => m[1]); + +/** A throwaway file, so a control cannot be satisfied by anything in the tree. */ +function withFixture(prefix: string, name: string, body: string, check: (file: string) => void) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + try { + const file = path.join(dir, name); + fs.writeFileSync(file, body); + check(file); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +describe('the behaviour that refused BIND', () => { + it(`the spec really does still export a callable \`${FORMERLY}\``, () => { + // If it ever stops, the rename's reason is spent and the plain name can be + // taken back -- which is what this package's own RENAMES ratchet says out + // loud. Asserted as CALLABLE, not merely present: a type-only shim would + // make every probe below vacuous while leaving them green. + expect(typeof normalizeFilterOperator).toBe('function'); + }); + + it('…and it folds to the canonical VIEW vocabulary, which is not the AST symbol set', () => { + // The whole route, in two reads. Both spellings of the same operator arrive + // at a VIEW word, never at the symbol this package puts on the wire. + const vocabulary = new Set(VIEW_FILTER_OPERATORS); + for (const spelling of ['eq', 'equals']) { + const folded = String(normalizeFilterOperator(spelling)); + expect(vocabulary.has(folded), `${spelling} -> ${folded}`).toBe(true); + expect(folded).toBe('equals'); + } + // Lit control: the fold is only news if it moves something. `eq` is an alias + // the spec's own table carries, read off that table rather than restated, so + // a retirement shows up here rather than in prose. + expect(VIEW_FILTER_OPERATOR_ALIASES.eq).toBe('equals'); + // …and the symbol this package emits for the same input is not a member, so + // the two codomains cannot be confused for one. + expect(vocabulary.has('=')).toBe(false); + }); + + it('every canonical view operator comes back unchanged — the spec folds, it does not translate', () => { + // Collected rather than asserted per entry (same call as the sibling + // ratchet in `filter-operator-ast-parity.test.ts`): a vocabulary change + // lands as a family, and failing on the first would hide the rest. + const moved = VIEW_FILTER_OPERATORS.filter((op) => String(normalizeFilterOperator(op)) !== op); + expect( + moved, + 'the spec\'s `normalizeFilterOperator` now rewrites canonical view operators. It used to ' + + 'return them untouched, which is the property that made it a FOLD to the view ' + + 'vocabulary rather than a translation to anything else — re-read whether ' + + `\`${RENAMED}\` is still a different function before trusting the rename.`, + ).toEqual([]); + }); + + it('the `?? op` tail is COMMON ground, not the divergence', () => { + // The reading the seeding note got backwards. An unrecognised STRING is + // handed through unchanged by the spec's too, so "the spec refuses instead" + // is not a difference that exists. + expect(normalizeFilterOperator('totally_unknown')).toBe('totally_unknown'); + }); + + it('…and on the NON-string arm it is the SPEC that is lenient', () => { + // The half that actually refuses BIND. The spec hands a non-string straight + // back; this package returns `null` there, and `objectFilterEntryToAST` + // turns that into a `MalformedFilterError` rather than sending a number in + // an operator slot. That refusal is pinned end-to-end, through the real + // adapter and both `find()` routes, by the `operator: 42` case in + // `packages/data-objectstack/src/filter-entry-translation.test.ts`; what is + // asserted HERE is the other side of the comparison — that swapping the + // spec's in would have removed it. + expect(normalizeFilterOperator(42 as unknown)).toBe(42); + expect(normalizeFilterOperator(null as unknown)).toBe(null); + }); +}); + +describe('the site: the module-local mirror is gone', () => { + const found = () => + scanFile(path.join(repoRoot, SITE), specNamesForFormerly()).map((f: { name: string }) => f.name); + + it(`rule 1 no longer sees \`${FORMERLY}\` — the declaration is \`${RENAMED}\` now`, () => { + expect(found()).toEqual([]); + }); + + it('the scanner can still see the shape it used to flag — the control', () => { + // Same kind as the subject: the exact declaration this slice renamed. + withFixture( + 'spec-symbol-data-objectstack-7265-', + 'relapse.ts', + [ + `function ${FORMERLY}(op: unknown): string | null {`, + " if (typeof op !== 'string') return null;", + ' return op;', + '}', + '', + `export const used = ${FORMERLY}('eq');`, + '', + ].join('\n'), + (file) => + expect( + scanFile(file, specNamesForFormerly()).map((f: { name: string; kind: string }) => ({ + name: f.name, + kind: f.kind, + })), + ).toEqual([{ name: FORMERLY, kind: 'function' }]), + ); + }); + + it('…and a FUNCTION cannot be derived out of the finding — which is why the route set is small', () => { + // The other half of the control, and the place this slice differs from its + // type-shaped siblings. For a type, wrapping the spec's is a legitimate BIND + // form the gate accepts; for a function there is no such form — rule 1 + // records every function declaration with `derived: false`, so importing the + // spec's and re-declaring a wrapper under the same name is STILL flagged. + // That is what left exactly three exits (delete the declaration and import, + // rename, or waive) before the site was read at all, and it is asserted + // rather than remembered because a later `rendersJsx`-style narrowing that + // exempted functions would make the first assertion in this block unpassable + // and its green meaningless. + withFixture( + 'spec-symbol-data-objectstack-7265-wrapped-', + 'wrapped.ts', + [ + `import { ${FORMERLY} as spec${FORMERLY} } from '${SUBPATH}';`, + '', + `function ${FORMERLY}(op: unknown): string | null {`, + ` return typeof op === 'string' ? String(spec${FORMERLY}(op)) : null;`, + '}', + '', + `export const used = ${FORMERLY}('eq');`, + '', + ].join('\n'), + (file) => + expect(scanFile(file, specNamesForFormerly()).map((f: { name: string }) => f.name)).toEqual([ + FORMERLY, + ]), + ); + }); +}); + +describe('the block: shrink-only, and it shrank by exactly this package', () => { + it(`DEBT no longer lists \`${FORMERLY}\``, () => { + expect(ledgerBlock('DEBT')).not.toContain(`"${FORMERLY}"`); + }); + + it('the whole `@object-ui/data-objectstack` group is gone', () => { + expect(ledgerBlock('DEBT')).not.toContain('"@object-ui/data-objectstack"'); + }); + + it('DEBT has not grown — 1 name is the ceiling this slice left', () => { + // The `@object-ui/components` slice left 2. This one took one of them. + // Any future measurement above this number is the ratchet failing, whatever + // reason is given for it. + expect(ledgerNames(ledgerBlock('DEBT')).length).toBeLessThanOrEqual(1); + }); + + it('CLAIM_DEBT did not grow either — this slice removed no rule 2 claim', () => { + // Measured, not assumed: `--claim-ledger` regenerates that block + // byte-identically across this change, because the declaration that moved + // carried no spec-alignment claim. The `@object-ui/core` slice was forced to + // regenerate both; if a later edit here moves this number, that coupling is + // back. + expect(ledgerNames(ledgerBlock('CLAIM_DEBT')).length).toBeLessThanOrEqual(18); + }); + + it('ALLOW did NOT gain a waiver — the route was RENAME, and that is a fact worth pinning', () => { + // A sibling slice put one of its names in ALLOW instead, so "a name left + // DEBT" does not say which route was taken. This one took none: the + // collision is gone rather than excused. A later hand that waives it instead + // has to delete this assertion, which is the visibility a silent re-fork + // would not have. + expect(fs.readFileSync(gateSource, 'utf8')).not.toContain(`"${ALLOW_KEY}"`); + }); +}); + +describe('the stale figure this slice was authorised to delete stays deleted', () => { + /** The `DEBT_ISSUE` note — from the rule-1 ledger header down to the const. */ + function debtIssueNote(): string { + const text = fs.readFileSync(gateSource, 'utf8'); + const end = text.indexOf('const DEBT_ISSUE ='); + expect(end, 'the DEBT_ISSUE anchor is gone').toBeGreaterThan(-1); + const start = text.lastIndexOf('// ⚠️ `CLAIM_DEBT_ISSUE`', end); + expect(start, 'the CLAIM_DEBT_ISSUE note is gone').toBeGreaterThan(-1); + return text.slice(start, end); + } + + it('carries no entry count at all', () => { + // AGENTS.md #9, and the reason this act was worth a line of the card: the + // sentence used to say how many entries objectui#7265 had burned out of each + // block, which was a figure derived once and re-derived never. It is not + // enough to have deleted it — a later hand "helpfully" re-deriving it to a + // NEW number is the same defect wearing a fresh answer, and that is the move + // this assertion exists to make loud rather than silent. + const note = debtIssueNote(); + const counts = [...note.matchAll(/\b(?:one|two|three|four|five|six|\d+)[- ]entr/gi)].map( + (m) => m[0], + ); + expect( + counts, + 'the `DEBT_ISSUE` note has a ledger size written into it again. Point at the instrument ' + + 'instead: `--ledger` and `--claim-ledger` regenerate the blocks and the run banner ' + + 'prints both counts.', + ).toEqual([]); + }); + + it('…and names the instrument in its place', () => { + // The positive half, so "no count" cannot be satisfied by deleting the + // sentence outright and telling the reader nothing. + const note = debtIssueNote(); + expect(note).toContain('--ledger'); + expect(note).toContain('--claim-ledger'); + }); +}); diff --git a/scripts/check-spec-symbol-derivation.mjs b/scripts/check-spec-symbol-derivation.mjs index 6e76953280..efa43d3f38 100644 --- a/scripts/check-spec-symbol-derivation.mjs +++ b/scripts/check-spec-symbol-derivation.mjs @@ -864,10 +864,16 @@ const ALLOW = { // could not serve either, being the card its own PR closes. objectui#7265 is the // open burn-down card for the population seeded here. // ⚠️ `CLAIM_DEBT_ISSUE` a few screens down has the same defect — objectui#4592 is -// closed while its 18-entry block is live — and is deliberately NOT changed here, +// closed while its block is still live — and is deliberately NOT changed here, // because rule 2's ledger is not what objectui#6291 widened. (Still deliberate at -// objectui#7265, which burned two entries out of the block below and one out of -// that one; only the COUNT above moved, never the dead anchor itself.) +// objectui#7265: its slices have burned names out of BOTH blocks, by BIND, by +// RENAME and by ALLOW as each site allowed, and each one moved a count while +// leaving the dead anchor exactly where it was. How large either block is today +// is deliberately NOT written here — `--ledger` and `--claim-ledger` regenerate +// them from the working tree and the run banner prints both counts, so a figure +// spelled out in this sentence could only be a second, staler answer to a +// question the script already answers, and the sentence it used to end with is +// the one this note now refuses to write again.) const DEBT_ISSUE = 7265; // Re-seeded at objectui#6291, mechanically (`--ledger`), when rule 1 stopped // skipping module-local declarations. ⚠️ The block is SHRINK-ONLY and this is the @@ -948,10 +954,46 @@ const DEBT_ISSUE = 7265; // share-filter-sort-spec-parity.test.ts (appended to, not duplicated); the site // and the block are pinned in // scripts/__tests__/spec-symbol-ledger-components-7265.test.ts. +// +// Then the `@object-ui/data-objectstack` slice at objectui#7265, the first where +// the mirror was a FUNCTION rather than a type -- which narrows the routes before +// anybody reads the site. Rule 1 records a function declaration with +// `derived: false` unconditionally (see `rendersJsx`, whose narrowing exists so +// that it keeps doing so), meaning there is no derive-in-place form for a +// function at all: the only exits are to delete the declaration and import the +// spec's, to rename, or to waive. +// +// BIND was refused on a measurement rather than a preference. The spec's +// `normalizeFilterOperator` (`@objectstack/spec/ui`, re-measured against the +// RESOLVED 17.4.0 pin, because "same behaviour" is a statement about a version +// exactly as "byte-identical" is) folds an authored spelling to the canonical +// VIEW vocabulary so `ViewFilterRuleSchema`'s enum can judge it. This package's +// folded the same input to the server's filter-AST SYMBOLS -- a different +// codomain, not a different spelling of one: the spec answers `equals` and +// `before` where this one answers `=` and `<`. Swapping it in would have changed +// what goes on the wire for most of the operators a stored view can carry. +// +// Two things came out of that measurement which the seeding note had predicted +// the other way round, and they are the part worth carrying forward. The `?? op` +// tail is NOT where the two differ: both hand an unrecognised STRING back +// unchanged, so the lenient tail is COMMON ground, and the guess that the spec's +// version "refuses instead" does not survive being run. The tail does diverge, +// but in the opposite direction and on the other arm -- the spec's returns a +// NON-string verbatim (its body ends `return op as string`), where this one +// returns `null`, and `objectFilterEntryToAST` reads that `null` one line later +// into a `MalformedFilterError`. So binding the spec here would have WIDENED what +// this adapter accepts onto the wire, not tightened it: a number in the operator +// slot of a tuple it is about to send, instead of a 400-shaped refusal. +// +// Renamed, therefore -- to `toAstFilterOperator`, which names the codomain that +// makes it a different function from the one the rest of this monorepo imports +// from the spec under the old name. Nothing about the behaviour moved with it. +// Both directions are pinned: the NAME in this package's own spec-symbol file, +// packages/data-objectstack/src/spec-symbol-batch6.test.ts (appended to its +// RENAMES table, not duplicated), and the site, the block and the measured +// behaviour in +// scripts/__tests__/spec-symbol-ledger-data-objectstack-7265.test.ts. const DEBT = { - "@object-ui/data-objectstack": [ - "normalizeFilterOperator", - ], "@object-ui/plugin-detail": [ "RecordAlertProps", ],