diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 767ace7801..8b68dfa0b4 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2554,6 +2554,28 @@ jobs: - name: WHERE-matcher conformance gate run: pnpm check:where-matcher + # ObjectQL double `limit` gate (#11525, from #10978). The third member of + # the family above, and the one the other two leave uncovered: a + # `find(object, opts)` double that matches `where` correctly and then hands + # back EVERY matched row. It cannot tell a read bounded at 200 from the same + # read bounded at 1000, or from an unbounded one, so any limit change on + # that read is green by construction and the production symptom is a + # silently truncated result set rather than an error -- #10978's worked + # example truncates an RLS input. PR #11521 converted nine doubles on the + # authorization path and measured the population; #11525 is the observation + # one level up: nothing held those nine right. This lifts each discovered + # double out of its file and asks it a bounded question, encoding all three + # shape rules the nine settled -- presence not truthiness, bound after the + # filter, bound before any row-touching stage. Behavioural rather than + # syntactic on purpose: the bound is applied through inline slices, per-file + # helpers and shared helpers alike, and no pattern-match over source spans + # them. Pre-existing doubles are in a shrink-only measured baseline. Runs + # its own --self-test first: the detector can be broken while every double + # is fine, and a scan that quietly stops matching would report OK while + # reading nothing (#4868's family). + - name: ObjectQL double limit gate + run: pnpm check:objectql-double-limit + # Paired kernel-hook pin gate (#5282, from #5170 / #5257 / #5274). The two # kernels — ObjectKernel (production) and LiteKernel (vitest / serverless / # edge) — run the same plugin code and the same hook vocabulary, but do NOT diff --git a/package.json b/package.json index 7c6b2d7673..1f0a98d4a7 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "check:driver-memory-census": "node scripts/check-driver-memory-census.mjs --self-test && node scripts/check-driver-memory-census.mjs", "check:engine-double-contract": "node scripts/check-engine-double-contract.mjs --self-test && node scripts/check-engine-double-contract.mjs", "check:where-matcher": "node scripts/check-where-matcher-conformance.mjs --self-test && node scripts/check-where-matcher-conformance.mjs", + "check:objectql-double-limit": "node scripts/check-objectql-double-limit.mjs --self-test && node scripts/check-objectql-double-limit.mjs", "check:resume-authority-declared": "node scripts/check-resume-authority-declared.mjs --self-test && node scripts/check-resume-authority-declared.mjs", "check:spec-parsed-alias": "node scripts/check-spec-parsed-alias.mjs --self-test && node scripts/check-spec-parsed-alias.mjs", "check:tenant-chokepoint": "node scripts/check-tenant-chokepoint.mjs --self-test && node scripts/check-tenant-chokepoint.mjs", diff --git a/packages/plugins/plugin-auth/src/audience-posture.test.ts b/packages/plugins/plugin-auth/src/audience-posture.test.ts index 0e4030f0b3..1be0e0a8c4 100644 --- a/packages/plugins/plugin-auth/src/audience-posture.test.ts +++ b/packages/plugins/plugin-auth/src/audience-posture.test.ts @@ -83,7 +83,9 @@ const createMemoryEngine = () => { async find(name: string, q: any = {}) { let out = rows(name).filter((r) => matches(r, q.where)); if (q.offset) out = out.slice(q.offset); - if (q.limit) out = out.slice(0, q.limit); + // Presence, not truthiness: `limit: 0` is a request for NOTHING, and `0` + // is falsy — `if (q.limit)` answers it with every matched row. + if (typeof q.limit === 'number') out = out.slice(0, q.limit); return out.map((r) => project(r, q.fields)); }, async count(name: string, q: any = {}) { diff --git a/scripts/check-objectql-double-limit.mjs b/scripts/check-objectql-double-limit.mjs new file mode 100644 index 0000000000..4748ab513c --- /dev/null +++ b/scripts/check-objectql-double-limit.mjs @@ -0,0 +1,1270 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// check-objectql-double-limit -- an in-memory ObjectQL `find` double inside a +// test file must APPLY the caller's `limit`, or REFUSE it loudly (#11525, from +// #10978; the conversion lane PR #11521). +// +// node scripts/check-objectql-double-limit.mjs +// node scripts/check-objectql-double-limit.mjs --self-test +// node scripts/check-objectql-double-limit.mjs --census # measure, never fail +// +// ## The failure mode this exists for +// +// A hand-written `find(object, opts)` double that matches `opts.where` and +// hands back every matched row cannot tell a read bounded at 200 from the same +// read bounded at 1000, or from one carrying no bound at all. Every limit +// change on such a read is green BY CONSTRUCTION, and the production symptom is +// a SILENTLY TRUNCATED result set rather than an error -- so the suite never +// goes red and the truncation reaches an authorization input. +// +// The worked example is #10978's: `resolveUserAuthzGrants` reads `sys_member` +// twice, `{user_id}` at 200 and `{organization_id}` at 1000. The obvious "same +// object, fold the two reads" cleanup caps the fellow-org peer list +// (`org_user_ids`, an RLS input) at 200 for any organization with more members. +// Under a limit-blind double both bounds return every row and the fold is +// invisible. +// +// #7620 / `check-where-matcher-conformance` is the precedent, and its lesson is +// the reason this is a GATE rather than a second conversion PR: +// +// > the doubles were made right, and nothing held them right +// +// PR #11521 made nine right. Nothing held them right, and nothing stopped the +// rest from being joined by one more. +// +// ## The criterion, in one sentence +// +// > A discovered `find` double must APPLY the caller's `limit` -- by +// > PRESENCE, AFTER the filter, and BEFORE any stage that touches the rows -- +// > or REFUSE it by throwing. What it must never do is silently ignore it. +// +// Refusal counts as conforming on purpose, for the same reason it does next +// door: the defect class is SILENCE, not incompleteness. A double that throws +// the moment a bound arrives cannot make a suite green while answering a +// different query; it makes the suite RED. That is not this gate's invention -- +// `packages/objectql/src/engine-autonumber-*.test.ts` already refuses unknown +// operators with its own recorded reason. +// +// ## The three shape rules, and how each is ENCODED +// +// #11525 pins three details settled by the landed nine. This gate encodes them +// as probes, and the encoding of the third is honest about a limit it cannot +// cross: +// +// 1. PRESENCE, NOT TRUTHINESS -- `typeof opts?.limit === 'number'`, so +// `limit: 0` returns NOTHING rather than the whole table. `0` is falsy, so +// `opts.limit ? ... : rows` answers a request for nothing with everything. +// Measured precedent in-repo: `driver-memory`'s `query.limit !== undefined`. +// ENCODED by the `presence` probe: `limit: 0` must return zero rows. +// +// 2. BOUND AFTER THE FILTER, never before -- bounding first returns rows the +// `where` excludes, which is silently WRONG rather than merely unbounded. +// ENCODED by putting the NON-matching rows FIRST in the probe corpus: a +// double that slices before it filters cannot reach the requested count. +// +// 3. BOUND BEFORE ANY STAGE THAT TOUCHES THE ROWS (the "bound before the +// wrap" rule) -- where a double wraps rows, a row the real read would +// never have returned must not be handed to the wrapper either. +// ENCODED by per-row READ COUNTERS; see the next section, which also +// states exactly where the encoding stops and why stopping there is not a +// hole. +// +// ## Rule 3 is PROVABLE in one direction only -- and that is not a hole +// +// Bounding before or after a PURE wrap produces identical returned values: the +// wrappers built for rows outside the bound are discarded, and nothing outside +// the double can count objects that were never returned. So no black-box probe +// can distinguish the two orders in general. What a probe CAN see is the rows +// the later stage TOUCHED, via accessor properties that count their own reads. +// +// In the bounded probe run (misses at 0..1, matches at 2..8, `limit: 3`): +// +// W = reads of row 2 a match INSIDE the bound +// B = reads of row 8 a match OUTSIDE the bound +// M = reads of row 0 a miss -- read by the filter and nothing else +// +// W > B -> PROVEN: the touching stage ran only for rows the +// bound admitted. Conforming. +// W === B === M -> nothing after the filter touched any row. Either +// there is no transform, or it is LAZY (an observation +// Proxy reads nothing when it is constructed). +// Conforming -- see below. +// W === B > M, and the +// double TRANSFORMS -> rows outside the bound were touched exactly as much +// and does not reorder as rows inside it. The bound is applied after a +// row-touching stage. RED. +// +// The middle row is the one worth being precise about, because it looks like a +// gap and is not. When the later stage touches nothing -- the lazy observation +// Proxy, which is the ONLY wrapper shape in this corpus -- the order is not +// merely unobservable, it is BEHAVIOURALLY IRRELEVANT: a wrapper built for a +// row that is then discarded records nothing, because a lazy wrapper does its +// recording when the CONSUMER reads it and no consumer ever sees it. Rule 3 +// bites exactly when the later stage is EAGER, and that is exactly the case +// these counters can see. +// +// The residual is therefore narrow and named rather than broad and implied: a +// transform with a side effect that does not read the row it transforms (a bare +// counter increment inside the `.map`) is invisible here, as is a double that +// reads its matched rows after filtering WITHOUT reordering them and then wraps +// them lazily. Both are pinned as self-test fixtures so they stay KNOWN limits, +// and every run PRINTS how many doubles landed in each wrap-order state. +// +// ## How a candidate is discovered -- and why the control probe IS the filter +// +// Discovery is structural: a function bound to a property named `find` (a method +// in an object literal or class, or `find: (…) => …`) in `packages/**/*.test.ts`, +// taking at least one parameter. That over-matches enormously on its own -- +// `find` is also a Map lookup, a registry read, an array search -- so, exactly +// as next door, every candidate must then pass a CONTROL probe before it is +// graded at all: +// +// await f(OBJ, { where: { F: 'yes' } }) -> only the F:'yes' rows, >= 1 +// await f(OBJ, { where: { F: 'no' } }) -> only the F:'no' rows, >= 1 +// +// A candidate that cannot do that is not a query-honouring row-selecting `find` +// and is dropped OUT OF SCOPE rather than reported. Membership is decided by +// BEHAVIOUR, never by a name -- the failure `check-engine-double-contract` +// documents, where `o` is the object name in twelve doubles and the options bag +// in a thirteenth. It also means a double cannot pass vacuously by returning +// everything: the control catches that before the battery runs. +// +// The seat is granted on a POSITIVE reading, never on the absence of a negative +// one. A double may answer both control probes with rows of its OWN -- constant +// stubs (`async find(o, q) { return [{ id: 'r1' }]; }`), or schema-signature +// fixtures that exist only to satisfy a `parse()`. Those rows carry none of the +// probe's fields, so the control reads nothing from them and obtains no evidence +// of filtering whatever. Treating "could not read" as "did not fail" seats them, +// and they then grade BLIND -- debt with NO POSSIBLE REMEDY, because there is no +// corpus to bound. Measured on this corpus: 19 of 294 seated candidates. They are +// routed by structure instead, by the same fallthrough every unseated candidate +// takes: OUT OF SCOPE when the body never filters, UNJUDGED -- declared, never +// skipped -- when it does but this lift cannot drive it. +// +// ## Driving a double whose rows live in a closure +// +// Unlike a `matches(row, where)` matcher, a `find` double reads its rows from an +// ENCLOSING binding -- `makeQl(tables)`, `makeQl(rows)`, a module-scope fixture +// array. So the lift binds them: the double's source is transpiled together with +// the same-file declarations it references, and any name still free at call time +// is bound to a ROW STUB -- one value that answers as an array (`rows.filter`), +// as a table map (`tables[object] ?? []`), and as a callable (`store.get(o)`), +// so a single binding covers every row-source spelling in the corpus without +// this gate guessing which one it is looking at. +// +// Two binding strategies are tried, in order, and the control probe decides: +// +// 1. lift same-file declarations, stub what is still free. The common shape -- +// the rows come from a factory PARAMETER no standalone lift can supply. +// 2. additionally stub every same-file declaration whose initializer is an +// array or object literal. The module-scope FIXTURE shape, where lifting +// the declaration succeeds but binds the file's own rows, which carry none +// of the probe's fields -- a double that would then read as "returns +// nothing" and be dropped as out of scope. +// +// The object NAME is searched the same way: many doubles answer `[]` for every +// object but one (`if (object !== 'sys_api_key') return []`). The probe tries a +// synthetic name first, then each string literal in the body, and keeps the +// first that passes the control probe. Nothing here asserts what the right name +// is; the control probe reports it. +// +// Only the double and the declarations it names are executed -- never the test +// file, never a suite. Anything that cannot be driven this way is UNJUDGED and +// must be declared; it is never treated as passing. +// +// ## Why this gate hand-copies its scope walk instead of importing one +// +// `check-where-matcher-conformance` carries a near-identical `visibleDeclarations` +// walk. Importing it was rejected for that gate's own recorded reason, which +// applies unchanged here: "a gate that imports its own substrate from another +// gate's file couples two tripwires that must be able to fail independently." +// The two gates must be able to go red separately and be edited separately. +// +// ## Invariants +// +// DISCOVERED the scan seated `find` doubles at all. Zero is not "a clean +// repo", it is a broken scan: every other invariant iterates the +// seated set, so a discovery that silently stopped matching would +// print OK while checking nothing. +// BOUNDED every seated double applies the caller's `limit`, or refuses it +// loudly -- or its file carries a measured baseline entry. +// SHAPED every double that DOES apply the bound applies it by presence, +// after the filter, and before any row-touching stage. The measured +// population DOES contain shape breakers (32 of them, nearly all +// reading the bound by truthiness), so they are grandfathered like +// the blind ones -- but under their OWN `wrong` count, never folded +// into `blind`. The two have different remedies, and a folded count +// cannot tell a repair from a regression. +// JUDGED every seated double was actually drivable. "Could not run" is a +// failure, not a skip (AGENTS.md, "Absence must be loud"), and is +// declared per file in the baseline's `unjudged` count. +// RECONCILED in both directions. An entry whose count is now LOWER, or whose +// file is now clean or gone, is an error -- ratchet it down in the +// same PR. A ratchet that can only accrete rots into a list nobody +// reads. +// MONOTONIC the baseline key set only ever SHRINKS, measured against the +// merge base with origin/main. Counts alone cannot see the last +// move: a newly-added file matching its own count would sail +// through, turning the ledger into a general-purpose mute button +// (the SLOT_LOOKUP_UNSWEPT precedent, #4251). +// +// The baseline records the STANDING DEBT this gate was filed over and never +// grows. There is deliberately no `--update` / `--fix` flag, for the reason +// `check-engine-double-contract` gives: a generator would let a new limit-blind +// double be admitted by "just run the update command", which is precisely how a +// gate stops meaning anything. Ratcheting down is editing one number by hand -- +// the failure output prints exactly what was measured. + +import { readdirSync, statSync, readFileSync, existsSync } from 'node:fs'; +import { join, relative, resolve, dirname } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { requireDefaultExport } from './import-prerequisite.mjs'; +const ts = await requireDefaultExport('typescript', () => import('typescript'), import.meta.url); +import { parseSourceFile, transpileChecked } from './ts-parse.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '..'); +const BASELINE_PATH = 'scripts/objectql-double-limit.baseline.json'; +const SCAN_ROOT = 'packages'; + +// --------------------------------------------------------------------------- +// The probe vocabulary. Field names are deliberately synthetic so no double can +// special-case them (many branch on `organization_id`, `user_id`, `id`). +// --------------------------------------------------------------------------- +const F = '__os_bound_field'; +const G = '__os_bound_other'; +const SYNTHETIC_OBJECT = '__os_bound_object'; + +/** Row plan: MISSES FIRST, so a double that bounds before it filters is short. */ +const MISS_COUNT = 2; +const MATCH_COUNT = 7; +const ROW_COUNT = MISS_COUNT + MATCH_COUNT; +const NARROW_LIMIT = 3; // < MATCH_COUNT, and > MISS_COUNT so slicing first shows +const WIDE_LIMIT = 5; + +const MATCH_WHERE = () => ({ [F]: 'yes' }); +const MISS_WHERE = () => ({ [F]: 'no' }); + +/** + * Probe rows whose fields COUNT THEIR OWN READS. The counters are what make + * rule 3 (bound before any row-touching stage) observable at all: the filter + * reads every row once, so a later stage shows up as reads a MISS row never + * gets. Enumerable and configurable so a spread / clone / `JSON.stringify` + * transform reads them exactly as it would read a plain row. + */ +function makeProbeRows(counters) { + const rows = []; + for (let i = 0; i < ROW_COUNT; i++) { + const matched = i >= MISS_COUNT; + const row = {}; + const define = (key, value) => { + Object.defineProperty(row, key, { + get() { counters[i] += 1; return value; }, + enumerable: true, + configurable: true, + }); + }; + define(F, matched ? 'yes' : 'no'); + define(G, `g${i}`); + rows.push(row); + } + return rows; +} + +/** + * The ROW STUB -- one value standing in for every row-source spelling in the + * corpus, so the lift never has to guess which one a double uses: + * + * rows.filter(...) array methods, indices, length, iteration + * tables[object] ?? [] any unknown property answers with the stub again + * store.get(object) and the stub is callable, answering with the rows + * + * A Proxy over a FUNCTION target, because only a callable target can carry an + * `apply` trap; array behaviour is forwarded to the real rows array. + */ +function makeRowStub(rows) { + // The target is an ARROW, not `function () {}`: a normal function carries a + // non-configurable own `prototype`, and the proxy invariant then rejects an + // `ownKeys` trap that does not report it ("trap result did not include + // 'prototype'"), which surfaces as an unjudged double rather than as anything + // legible. An arrow has no `prototype`, so the array's keys are the whole + // answer -- and it is not a constructor, which nothing here needs it to be. + const stub = new Proxy(() => {}, { + get(_t, prop, receiver) { + if (prop === Symbol.toStringTag) return 'Array'; + // `then` must answer UNDEFINED, never the stub. A double that returns the + // stub itself is then `await`ed as a thenable, the stub's `apply` trap + // answers instead of calling `resolve`, and the probe hangs FOREVER -- + // measured, and it presents as "unsettled top-level await" with no clue + // which double did it. + if (prop === 'then' || prop === 'catch' || prop === 'finally') { + return Reflect.has(rows, prop) ? Reflect.get(rows, prop).bind(rows) : undefined; + } + if (Reflect.has(rows, prop)) { + const value = Reflect.get(rows, prop, receiver === stub ? rows : receiver); + return typeof value === 'function' ? value.bind(rows) : value; + } + return stub; + }, + has(_t, prop) { return Reflect.has(rows, prop); }, + ownKeys() { return Reflect.ownKeys(rows); }, + getOwnPropertyDescriptor(_t, prop) { + const d = Reflect.getOwnPropertyDescriptor(rows, prop); + return d ? { ...d, configurable: true } : undefined; + }, + // Answers with the STUB, never with the bare rows array. `seen.get(t)` + // and `storeFor(object)` are the same shape as `rows`, and a call that + // handed back the raw array made the NEXT hop fail (`cols.add is not a + // function`) -- filing a perfectly drivable double as unjudged. The stub is + // array-like, so `Array.from(x.values())` and `x.filter(...)` both still work. + apply() { return stub; }, + }); + return stub; +} + +// --------------------------------------------------------------------------- +// Discovery +// --------------------------------------------------------------------------- +const isFnExpr = (n) => ts.isFunctionExpression(n) || ts.isArrowFunction(n); +const isAsync = (n) => + (n.modifiers ?? []).some((m) => m.kind === ts.SyntaxKind.AsyncKeyword); + +/** Same-file declarations visible from `node`, innermost scope first. */ +function visibleDeclarations(node, sf) { + const decls = new Map(); + for (let cur = node.parent; cur; cur = cur.parent) { + const stmts = ts.isSourceFile(cur) + ? cur.statements + : (cur.statements ?? (cur.body && cur.body.statements)); + if (!stmts) continue; + for (const s of stmts) { + if (ts.isVariableStatement(s)) { + for (const d of s.declarationList.declarations) { + if (ts.isIdentifier(d.name) && d.initializer && !decls.has(d.name.text)) { + const literal = + ts.isArrayLiteralExpression(d.initializer) || + ts.isObjectLiteralExpression(d.initializer); + const fnLike = + ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer); + decls.set(d.name.text, { + text: `let ${d.name.text} = ${d.initializer.getText(sf)};`, + literal, + fnLike, + }); + } + } + } else if (ts.isFunctionDeclaration(s) && s.name && !decls.has(s.name.text)) { + decls.set(s.name.text, { text: s.getText(sf), literal: false, fnLike: true }); + } + } + } + return decls; +} + +const identifiersIn = (src) => { + const out = new Set(); + for (const m of src.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\b/g)) out.add(m[1]); + return out; +}; + +/** String literals in the body -- the object names a double may branch on. */ +function stringLiteralsIn(fnNode, sf) { + const out = []; + const seen = new Set(); + const visit = (n) => { + if (ts.isStringLiteral(n) && n.text && !seen.has(n.text)) { + seen.add(n.text); + out.push(n.text); + } + ts.forEachChild(n, visit); + }; + visit(fnNode.body ?? fnNode); + return out.slice(0, 10); +} + +/** + * Does this body READ LIKE an ObjectQL query double? This is NOT the admission + * criterion -- the control probe is, and it is behavioural. This decides only + * what an UNSEATED candidate MEANS: `find` is also a Map lookup, a registry read + * and an array search, and a scan that filed every one of those as "could not + * judge" would bury the population that matters under hundreds of entries of + * manufactured debt. So a candidate that never seats is DEBT when it reads like + * a query double and OUT OF SCOPE when it does not. + */ +const looksQueryShaped = (body) => + /\bwhere\b|\$filter|\bfilters\b/.test(body) && /\.filter\(|\.every\(|\.some\(|\bfor\s*\(/.test(body); + +/** The lifted source of a `find`, as a standalone EXPRESSION. */ +function expressionSourceOf(node, sf) { + if (ts.isMethodDeclaration(node)) { + const params = node.parameters.map((p) => p.getText(sf)).join(', '); + return `${isAsync(node) ? 'async ' : ''}function (${params}) ${node.body.getText(sf)}`; + } + // A function expression or an arrow is already a valid expression; a named + // function expression keeps its name, which is harmless inside the lift. + return node.getText(sf); +} + +/** + * Structural candidates in one source text. Behavioural admission (the control + * probe) happens later, in `judge` -- this stage only proposes. + */ +export function discoverInSource(text, label) { + const out = []; + if (!/\bfind\b/.test(text)) return out; + const sf = parseSourceFile(label, text, ts.ScriptKind.TS); + const visit = (node) => { + let fn = null; + if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === 'find') { + fn = node; + } else if ( + ts.isPropertyAssignment(node) && + ts.isIdentifier(node.name) && + node.name.text === 'find' && + isFnExpr(node.initializer) + ) { + fn = node.initializer; + } + if (fn && fn.body && fn.parameters.length >= 1) { + const { line } = sf.getLineAndCharacterOfPosition(fn.getStart(sf)); + out.push({ + file: label, + line: line + 1, + queryShaped: looksQueryShaped(fn.body.getText(sf)), + source: expressionSourceOf(fn, sf), + declarations: visibleDeclarations(fn, sf), + objectNames: [SYNTHETIC_OBJECT, ...stringLiteralsIn(fn, sf)], + }); + } + ts.forEachChild(node, visit); + }; + visit(sf); + return out; +} + +// --------------------------------------------------------------------------- +// Lift +// --------------------------------------------------------------------------- +/** + * `stubClass` widens what the lift REPLACES with the row stub, one class at a + * time, because the row source is not always a parameter: + * + * 0 lift every same-file declaration; stub only what is still free at call + * time. The `makeQl(tables)` shape -- the rows come from a factory + * parameter no standalone lift can supply. + * 1 + array / object literals. The module-scope FIXTURE shape: lifting + * succeeds and binds the file's OWN rows, which carry none of the probe's + * fields, so the double reads as "returns nothing" and would be dropped. + * 2 + every non-function initializer. The STORE shape: `new Map()`, reached + * directly or through a same-file `storeFor(object)` helper. Measured: this + * class alone is the difference between judging and not judging a large + * fraction of the corpus, because a Map-backed store is the most common + * spelling in this repo and an empty lifted Map answers every probe with + * an empty array. + * + * Function-like declarations are never stubbed at any class: they are the + * double's own `matches(row, where)` helpers, and replacing one would remove + * the very filtering the control probe is there to observe. + */ +function buildCallable(candidate, stubbed, stubClass) { + const self = '__os_find'; + const skip = new Set(stubbed); + const included = new Map(); + let frontier = identifiersIn(candidate.source); + for (let depth = 0; depth < 6; depth++) { + const next = new Set(); + for (const id of frontier) { + if (included.has(id) || id === self || skip.has(id)) continue; + const decl = candidate.declarations.get(id); + if (!decl) continue; + if (stubClass >= 1 && decl.literal) { skip.add(id); continue; } + if (stubClass >= 2 && !decl.fnLike) { skip.add(id); continue; } + included.set(id, decl.text); + for (const x of identifiersIn(decl.text)) next.add(x); + } + if (next.size === 0) break; + frontier = next; + } + // The stub bindings come FIRST. A lifted declaration may itself name a stubbed + // root (`let seeded = seed.map(...)`), and with the stubs emitted last that read + // lands in the temporal dead zone -- which surfaces as `Cannot access 'seed' + // before initialization` and files a perfectly drivable double as unjudged. + const stubs = [...skip].map((id) => `let ${id} = __os_stub;`).join('\n'); + const code = + `${stubs}\n${[...included.values()].join('\n')}\n` + + `const ${self} = ${candidate.source};\nreturn ${self};`; + // `transpileChecked` rather than the raw call: the raw one reports NOTHING + // without `reportDiagnostics` and still returns an `outputText`, so a dropped + // operand comes back as code `new Function` throws on, and that reads as + // "could not judge" standing in for "could not read". + const js = transpileChecked(`${candidate.file}#L${candidate.line}.lifted.ts`, code, { + compilerOptions: { target: ts.ScriptTarget.ES2022, isolatedModules: true }, + }).outputText; + return { fn: new Function('__os_stub', js), stubbedNames: skip }; +} + +const REFERENCE_ERROR = /(?:^|\W)([A-Za-z_$][A-Za-z0-9_$]*) is not defined/; + +/** + * Describe a thrown value without ever throwing again. A double handed the row + * stub can throw the stub itself, and `String(stub)` raises "Cannot convert + * object to primitive value" -- an error INSIDE the error path, which took the + * whole scan down on the first corpus run rather than filing one candidate as + * unjudged. + */ +function describeThrown(e) { + try { + if (e instanceof Error) return `${e.name}: ${e.message}`; + return String(e); + } catch { + return 'a value that cannot be described'; + } +} + +/** One probe call: fresh rows, fresh counters, snapshot before we inspect. */ +async function callOnce(factory, objectName, where, limit) { + const counters = new Array(ROW_COUNT).fill(0); + const rows = makeProbeRows(counters); + const fn = factory(makeRowStub(rows)); + const opts = limit === undefined ? { where } : { where, limit }; + const value = await fn(objectName, opts); + return { value, counters: [...counters], rows }; +} + +const asArray = (v) => (Array.isArray(v) ? v : null); + +/** Does every returned row carry `F === want`? `null` when unreadable. */ +function allCarry(list, want) { + let seen = 0; + for (const r of list) { + if (r == null || typeof r !== 'object') return false; + let v; + try { v = r[F]; } catch { return false; } + if (v === undefined) continue; + seen += 1; + if (v !== want) return false; + } + return seen === 0 ? null : true; +} + +// --------------------------------------------------------------------------- +// Judgment +// --------------------------------------------------------------------------- +/** + * Lift the double out and drive it. Returns one of: + * { verdict: 'OUT_OF_SCOPE' } -- not a query-honouring row-selecting find + * { verdict: 'CONFORMING', … } -- applies the bound, or refuses it loudly + * { verdict: 'BLIND' } -- ignores the bound + * { verdict: 'WRONG', shapes } -- applies it, but breaks a shape rule + * { verdict: 'UNJUDGED', why } -- could not be lifted or driven + */ +export async function judge(candidate) { + let seated = null; + let lastError = null; + let notAList = false; + for (const stubClass of [0, 1, 2]) { + const stubbed = new Set(); + let built = null; + for (let attempt = 0; attempt < 10 && !seated; attempt++) { + try { + built = buildCallable(candidate, stubbed, stubClass); + } catch (e) { + lastError = `could not lift: ${describeThrown(e).slice(0, 110)}`; + break; + } + const factory = (stub) => built.fn(stub); + let retryName = null; + for (const objectName of candidate.objectNames) { + let hit, skip; + try { + hit = await callOnce(factory, objectName, MATCH_WHERE(), undefined); + skip = await callOnce(factory, objectName, MISS_WHERE(), undefined); + } catch (e) { + const missing = REFERENCE_ERROR.exec(describeThrown(e))?.[1]; + if (missing && !stubbed.has(missing)) { retryName = missing; break; } + lastError = `probe threw: ${describeThrown(e).slice(0, 110)}`; + continue; + } + const hits = asArray(hit.value); + const skips = asArray(skip.value); + // An ObjectQL `find` answers with a LIST. A `find` that answers with a + // record, a boolean or nothing is a different verb, not a double this + // gate could not drive -- out of scope rather than debt. + if (!hits || !skips) { notAList = true; continue; } + if (hits.length === 0 || skips.length === 0) continue; + // Seating demands a POSITIVE answer, never merely the absence of a + // negative one. `allCarry` answers `null` when NO returned row carried + // the probe's field -- the double handed back rows of its own, so the + // control obtained no evidence that it filtered on the probe's `where` + // at all. Reading `null` as "not disproven" seats a double the control + // could not read (measured: 19 of 294, among them constant-returning + // stubs like `async find(o, q) { return [{ id: 'r1' }]; }` and schema- + // signature fixtures that exist only to satisfy a `parse()`), and then + // grades it BLIND -- debt with no possible remedy, since there is no + // corpus to bound. Unseated candidates fall through below and are + // routed by structure: OUT_OF_SCOPE when the body never filters, + // UNJUDGED -- declared, never skipped -- when it does. + if (allCarry(hits, 'yes') !== true || allCarry(skips, 'no') !== true) continue; + if (hits.length === ROW_COUNT || skips.length === ROW_COUNT) continue; // no filtering + seated = { factory, objectName, unbounded: hit }; + break; + } + if (retryName) { stubbed.add(retryName); continue; } + break; + } + if (seated) break; + } + if (!seated) { + // A candidate the control probe never seated is either not a + // query-honouring `find` at all (the common case -- `find` is also a Map + // lookup and an array search) or one this lift could not drive. Only the + // second is debt, and only a candidate that ERRORED can be told apart. + if (!candidate.queryShaped || notAList) return { verdict: 'OUT_OF_SCOPE' }; + return { verdict: 'UNJUDGED', why: lastError ?? 'the control probe never seated it' }; + } + + const { factory, objectName, unbounded } = seated; + const matched = asArray(unbounded.value).length; + const run = async (limit) => callOnce(factory, objectName, MATCH_WHERE(), limit); + + let narrow, wide, zero; + let refused = false; + try { + narrow = await run(NARROW_LIMIT); + } catch { + refused = true; // threw on a bound it does not implement -- loud, not silent + } + if (refused) return { verdict: 'CONFORMING', refused: true, matched, wrapOrder: 'n/a' }; + try { + wide = await run(WIDE_LIMIT); + zero = await run(0); + } catch (e) { + return { verdict: 'UNJUDGED', why: `bounded probe threw: ${describeThrown(e).slice(0, 110)}` }; + } + + const len = (p) => asArray(p.value)?.length ?? -1; + const probes = { + matched, + unbounded: matched, + narrow: len(narrow), + wide: len(wide), + zero: len(zero), + }; + if (probes.narrow < 0 || probes.wide < 0 || probes.zero < 0) { + return { verdict: 'UNJUDGED', why: 'a bounded probe did not return an array' }; + } + + // Blind: the bound changes nothing at all. + if (probes.narrow === matched && probes.zero === matched) { + return { verdict: 'BLIND', probes }; + } + + const shapes = []; + if (probes.zero !== 0) { + shapes.push('truthiness, not presence (`limit: 0` returns rows -- `0` is falsy)'); + } + if (probes.narrow !== NARROW_LIMIT || probes.wide !== WIDE_LIMIT) { + // The misses sit FIRST, so a double that slices before it filters cannot + // reach the requested count. Reported with both readings so the message + // still means something if a double is short for another reason. + shapes.push( + `bound applied BEFORE the filter (asked for ${NARROW_LIMIT}/${WIDE_LIMIT} of ` + + `${matched} matches, got ${probes.narrow}/${probes.wide})`, + ); + } + + // --- rule 3: the bound must precede any stage that touches the rows ------- + const returned = asArray(narrow.value) ?? []; + const supplied = new Set(narrow.rows); + const transforms = returned.length > 0 && returned.every((r) => !supplied.has(r)); + const c = narrow.counters; + const within = c[MISS_COUNT]; // a match INSIDE the bound + const beyond = c[ROW_COUNT - 1]; // a match OUTSIDE the bound + const miss = c[0]; // read by the filter and nothing else + const ordered = returned.every((r, i) => { + try { return r?.[G] === `g${MISS_COUNT + i}`; } catch { return false; } + }); + let wrapOrder; + if (!transforms) wrapOrder = 'no-transform'; + else if (within > beyond) wrapOrder = 'proven-after-bound'; + else if (beyond > miss && ordered) wrapOrder = 'before-bound'; + else wrapOrder = 'lazy-transform'; + if (wrapOrder === 'before-bound') { + shapes.push( + 'a row-touching stage runs BEFORE the bound (rows outside the bound were ' + + `read as often as rows inside it: ${within}/${beyond} vs ${miss} for a filtered-out row)`, + ); + } + + if (shapes.length > 0) return { verdict: 'WRONG', probes, shapes, wrapOrder }; + return { verdict: 'CONFORMING', refused: false, matched, probes, wrapOrder }; +} + +// --------------------------------------------------------------------------- +// Corpus walk +// --------------------------------------------------------------------------- +function testFilesUnder(dir, acc = []) { + for (const entry of readdirSync(dir)) { + if (entry === 'node_modules' || entry === 'dist' || entry === '.git' || entry === '.cache') continue; + const p = join(dir, entry); + if (statSync(p).isDirectory()) testFilesUnder(p, acc); + else if (/\.test\.ts$/.test(entry)) acc.push(p); + } + return acc; +} + +/** Measure the whole corpus: file -> { blind, unjudged, details[] }. */ +export async function measure() { + const measured = new Map(); + const census = { + proposed: 0, graded: 0, dropped: 0, + conforming: 0, refusing: 0, blind: 0, wrong: 0, unjudged: 0, + wrapOrder: { 'no-transform': 0, 'proven-after-bound': 0, 'lazy-transform': 0, 'before-bound': 0, 'n/a': 0 }, + }; + for (const abs of testFilesUnder(join(repoRoot, SCAN_ROOT))) { + const rel = relative(repoRoot, abs).replace(/\\/g, '/'); + for (const candidate of discoverInSource(readFileSync(abs, 'utf8'), rel)) { + census.proposed += 1; + const result = await judge(candidate); + if (result.verdict === 'OUT_OF_SCOPE') { census.dropped += 1; continue; } + if (result.verdict !== 'UNJUDGED') census.graded += 1; + if (result.wrapOrder) census.wrapOrder[result.wrapOrder] += 1; + if (result.verdict === 'CONFORMING') { + census.conforming += 1; + if (result.refused) census.refusing += 1; + continue; + } + if (!measured.has(rel)) measured.set(rel, { blind: 0, wrong: 0, unjudged: 0, details: [] }); + const bucket = measured.get(rel); + if (result.verdict === 'BLIND') { bucket.blind += 1; census.blind += 1; } + else if (result.verdict === 'WRONG') { bucket.wrong += 1; census.wrong += 1; } + else { bucket.unjudged += 1; census.unjudged += 1; } + bucket.details.push({ line: candidate.line, result }); + } + } + return { measured, census }; +} + +const KINDS = ['blind', 'wrong', 'unjudged']; +const countsOf = (v) => ({ blind: v.blind ?? 0, wrong: v.wrong ?? 0, unjudged: v.unjudged ?? 0 }); + +const describeDetail = (d) => + `line ${d.line}: ${d.result.verdict}` + + (d.result.shapes ? ` -- ${d.result.shapes.join('; ')}` : '') + + (d.result.why ? ` -- ${d.result.why}` : ''); + +export function reconcile(measured, baselineFiles) { + const errors = []; + for (const [file, v] of measured) { + const now = countsOf(v); + const allowed = baselineFiles[file] ? countsOf(baselineFiles[file]) : null; + if (!allowed) { + errors.push( + `${file}: NEW ObjectQL \`find\` double that does not hold the caller's bound ` + + `(${now.blind} blind, ${now.wrong} breaking a shape rule, ${now.unjudged} unjudged).\n` + + ` ${v.details.map(describeDetail).join('\n ')}\n` + + ` Apply the caller's bound AFTER the filter, by PRESENCE:\n` + + ` const page = typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows;\n` + + ` or make the double THROW when it is handed a bound it does not implement.\n` + + ` The baseline never grows.`, + ); + continue; + } + for (const kind of KINDS) { + if (now[kind] > allowed[kind]) { + errors.push( + `${file}: ${kind} double count grew ${allowed[kind]} -> ${now[kind]}. The file is ` + + `grandfathered for its EXISTING doubles only.\n` + + ` ${v.details.map(describeDetail).join('\n ')}`, + ); + } else if (now[kind] < allowed[kind]) { + errors.push( + `${file}: ${kind} count fell ${allowed[kind]} -> ${now[kind]} -- ratchet DOWN: set it ` + + `to ${now[kind]} in ${BASELINE_PATH} (delete the entry when every count reaches 0).`, + ); + } + } + } + for (const file of Object.keys(baselineFiles)) { + if (!measured.has(file)) { + errors.push( + `${file}: baselined file is clean or gone -- ratchet DOWN: delete its entry from ` + + `${BASELINE_PATH}.`, + ); + } + } + return errors; +} + +function monotonicity(baselineFiles) { + try { + const git = (...args) => + execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + let base; + for (const ref of ['origin/main', 'main']) { + try { base = git('merge-base', 'HEAD', ref); break; } catch { /* try the next ref */ } + } + if (!base) return null; + const previous = JSON.parse(git('show', `${base}:${BASELINE_PATH}`)).files ?? {}; + return { base: base.slice(0, 7), added: Object.keys(baselineFiles).filter((f) => !(f in previous)) }; + } catch { + // No git, a shallow clone, or the baseline is new on this branch. Reported + // rather than passed over -- a check that could not run must not read as a + // check that passed. + return null; + } +} + +// --------------------------------------------------------------------------- +// Self-test -- the detector can be broken while every double is fine +// +// Every fixture below is a whole test-file source, driven through the same +// `discoverInSource` + `judge` path the corpus walk uses. They cover the four +// verdicts, all three shape rules IN BOTH DIRECTIONS, both binding strategies, +// both wrap-order proofs, and the two residuals this gate declines to grade. +// --------------------------------------------------------------------------- +const MATCHER = `Object.entries(where).every(([k, v]) => (r as any)[k] === v)`; + +/** The correct shape: presence, after the filter, inline. */ +const FIXTURE_CORRECT = ` +function makeQl(rows: any[]) { + return { + async find(object: string, opts: any) { + const where = opts?.where ?? {}; + const matched = rows.filter((r: any) => ${MATCHER}); + return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; + }, + }; +}`; + +/** The defect class: the bound is read by nobody. */ +const FIXTURE_BLIND = ` +function makeQl(rows: any[]) { + return { + async find(object: string, opts: any) { + const where = opts?.where ?? {}; + return rows.filter((r: any) => ${MATCHER}); + }, + }; +}`; + +/** Shape rule 1, violated: `0` is falsy, so a request for NOTHING gets everything. */ +const FIXTURE_TRUTHINESS = ` +function makeQl(rows: any[]) { + return { + async find(object: string, opts: any) { + const where = opts?.where ?? {}; + const matched = rows.filter((r: any) => ${MATCHER}); + return opts?.limit ? matched.slice(0, opts.limit) : matched; + }, + }; +}`; + +/** Shape rule 2, violated: bounding first returns rows the `where` excludes. */ +const FIXTURE_BEFORE_FILTER = ` +function makeQl(rows: any[]) { + return { + async find(object: string, opts: any) { + const where = opts?.where ?? {}; + const page = typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + return page.filter((r: any) => ${MATCHER}); + }, + }; +}`; + +/** Shape rule 3, honoured, and PROVABLY so: the eager copy runs after the bound. */ +const FIXTURE_EAGER_AFTER_BOUND = ` +function makeQl(rows: any[]) { + return { + async find(object: string, opts: any) { + const where = opts?.where ?? {}; + const matched = rows.filter((r: any) => ${MATCHER}); + const page = typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; + return page.map((r: any) => ({ ...r })); + }, + }; +}`; + +/** Shape rule 3, violated: rows outside the bound are copied anyway. */ +const FIXTURE_EAGER_BEFORE_BOUND = ` +function makeQl(rows: any[]) { + return { + async find(object: string, opts: any) { + const where = opts?.where ?? {}; + const matched = rows.filter((r: any) => ${MATCHER}).map((r: any) => ({ ...r })); + return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; + }, + }; +}`; + +/** + * A LAZY wrapper placed before the bound. Not a finding, and the reason is the + * point: a Proxy built for a row that is then discarded reads nothing and + * records nothing, because a lazy wrapper does its work when the CONSUMER reads + * it and no consumer ever sees it. Pinned so the "unprovable" state stays a + * declared, GREEN state rather than drifting into a red one. + */ +const FIXTURE_LAZY_WRAP_BEFORE_BOUND = ` +function makeQl(rows: any[]) { + return { + async find(object: string, opts: any) { + const where = opts?.where ?? {}; + const matched = rows + .filter((r: any) => ${MATCHER}) + .map((r: any) => new Proxy(r, { get: (t, p, rc) => Reflect.get(t, p, rc) })); + return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; + }, + }; +}`; + +/** + * RESIDUAL, pinned: a transform whose side effect never reads the row it + * transforms. The counter over-counts under this ordering and no black-box probe + * can see it. A KNOWN limit, not an accidental one. + */ +const FIXTURE_ROW_BLIND_SIDE_EFFECT = ` +function makeQl(rows: any[]) { + let wrapped = 0; + return { + async find(object: string, opts: any) { + const where = opts?.where ?? {}; + const matched = rows + .filter((r: any) => ${MATCHER}) + .map((r: any) => { wrapped += 1; return new Proxy(r, {}); }); + return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; + }, + }; +}`; + +/** Refusal conforms: the suite goes RED the moment a bound arrives. */ +const FIXTURE_REFUSES = ` +function makeQl(rows: any[]) { + return { + async find(object: string, opts: any) { + if (opts?.limit !== undefined) throw new Error('fake driver: limit is not implemented'); + const where = opts?.where ?? {}; + return rows.filter((r: any) => ${MATCHER}); + }, + }; +}`; + +/** Structurally a candidate, behaviourally not a query double. Dropped. */ +const FIXTURE_NOT_A_QUERY = ` +const registry = { + find(name: string) { + return name.toUpperCase(); + }, +};`; + +/** A `find` that returns every row whatever the `where` says. Dropped. */ +const FIXTURE_NO_FILTERING = ` +function makeQl(rows: any[]) { + return { + async find(object: string, opts: any) { + const where = opts?.where ?? {}; + void where; + return rows; + }, + }; +}`; + +/** + * A `find` that answers with rows OF ITS OWN -- constants that carry none of the + * probe's fields -- and never consults `where` at all. The control probe reads + * nothing from the returned rows, so it obtains NO evidence of filtering; the + * seat therefore has to be refused. Graded, it would read as limit-blind debt + * with no possible remedy: there is no corpus to bound. This is the commonest + * shape in the corpus (`async find(o, q) { return [{ id: 'r1' }]; }`). + */ +const FIXTURE_CONSTANT_ROWS = ` +function makeQl(rows: any[]) { + return { + async find(object: string, opts: any) { + void opts; + return [{ id: 'r1' }]; + }, + }; +}`; + +/** + * The same refusal, one step harder: the body IS query-shaped -- it names + * `where` and it filters -- but the rows it answers with are its own, so the + * control probe still reads nothing. Structure alone cannot dismiss this one, + * so it must be DECLARED (`UNJUDGED`) rather than dropped or graded. Pinning + * both fixtures pins the seating rule in both directions: a control that reads + * "not disproven" as "proven" grades both of these BLIND. + */ +const FIXTURE_FOREIGN_ROWS = ` +function makeQl(rows: any[]) { + return { + async find(object: string, opts: any) { + const where = opts?.where ?? {}; + const roster = [{ user_id: 'u1' }, { user_id: 'u2' }]; + return roster.filter((r: any) => where.user_id == null || r.user_id === where.user_id); + }, + }; +}`; + +/** + * The shared-helper spelling. The gate is deliberately blind to WHICH spelling a + * double uses -- it asks a question, it never hands out an implementation -- so + * a repo-shared `bounded()` and a per-file copy grade identically. Pinned so the + * answer to #11525's shared-vs-per-file question cannot be smuggled into the + * gate as a preference. + */ +const FIXTURE_SHARED_HELPER = ` +const bounded = (rows: T[], opts: any): T[] => + (typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows); +function makeQl(table: any[]) { + return { + async find(object: string, opts: any) { + const where = opts?.where ?? {}; + return bounded(table.filter((r: any) => ${MATCHER}), opts); + }, + }; +}`; + +/** Rows behind a table map keyed by the object name, and an object-name guard. */ +const FIXTURE_TABLE_MAP = ` +function makeQl(tables: Record) { + return { + async find(object: string, opts: any) { + if (object !== 'sys_member') return []; + const rows = tables[object] ?? []; + const where = opts?.where ?? {}; + return rows.filter((r: any) => ${MATCHER}); + }, + }; +}`; + +/** Binding strategy 2: rows come from a module-scope fixture, not a parameter. */ +const FIXTURE_MODULE_FIXTURE = ` +const seedRows = [{ id: 'a' }, { id: 'b' }]; +const ql = { + async find(object: string, opts: any) { + const where = opts?.where ?? {}; + return seedRows.filter((r: any) => ${MATCHER}); + }, +};`; + +async function judgeFixture(src) { + const found = discoverInSource(src, 'fixture.test.ts'); + const results = []; + for (const c of found) results.push(await judge(c)); + return { found, results }; +} + +async function selfTest() { + const failures = []; + const expect = (label, cond) => { if (!cond) failures.push(label); }; + const one = async (src) => { + const { found, results } = await judgeFixture(src); + return { found, r: results[0], n: found.length }; + }; + + const correct = await one(FIXTURE_CORRECT); + expect('the correct fixture is discovered', correct.n === 1); + expect('the correct fixture is CONFORMING', correct.r?.verdict === 'CONFORMING'); + expect('the correct fixture bounds, and reports what it measured', + correct.r?.probes?.narrow === NARROW_LIMIT && correct.r?.probes?.zero === 0); + + const blind = await one(FIXTURE_BLIND); + expect('the limit-blind fixture is discovered', blind.n === 1); + expect('the limit-blind fixture is BLIND', blind.r?.verdict === 'BLIND'); + expect('the limit-blind fixture returns every match under a bound', + blind.r?.probes?.narrow === MATCH_COUNT && blind.r?.probes?.zero === MATCH_COUNT); + + const truthy = await one(FIXTURE_TRUTHINESS); + expect('shape rule 1 red: the truthiness fixture is WRONG', truthy.r?.verdict === 'WRONG'); + expect('shape rule 1 is attributed to presence, not to anything else', + truthy.r?.shapes?.length === 1 && truthy.r.shapes[0].startsWith('truthiness')); + expect('shape rule 1 red is driven by `limit: 0`, and the narrow bound still held', + truthy.r?.probes?.zero === MATCH_COUNT && truthy.r?.probes?.narrow === NARROW_LIMIT); + + const early = await one(FIXTURE_BEFORE_FILTER); + expect('shape rule 2 red: bounding before the filter is WRONG', early.r?.verdict === 'WRONG'); + expect('shape rule 2 is attributed to the filter order', + early.r?.shapes?.some((s) => s.startsWith('bound applied BEFORE the filter')) === true); + expect('shape rule 2 red is a SHORT read, not a long one', + early.r?.probes?.narrow < NARROW_LIMIT && early.r?.probes?.wide < WIDE_LIMIT); + + const eagerOk = await one(FIXTURE_EAGER_AFTER_BOUND); + expect('shape rule 3 green: an eager copy after the bound is CONFORMING', + eagerOk.r?.verdict === 'CONFORMING'); + expect('shape rule 3 green is PROVEN, not assumed', + eagerOk.r?.wrapOrder === 'proven-after-bound'); + + const eagerBad = await one(FIXTURE_EAGER_BEFORE_BOUND); + expect('shape rule 3 red: an eager copy before the bound is WRONG', + eagerBad.r?.verdict === 'WRONG'); + expect('shape rule 3 is attributed to the row-touching stage', + eagerBad.r?.shapes?.some((s) => s.startsWith('a row-touching stage runs BEFORE')) === true); + expect('shape rule 3 red records the wrap-order state', eagerBad.r?.wrapOrder === 'before-bound'); + + const lazy = await one(FIXTURE_LAZY_WRAP_BEFORE_BOUND); + expect('a LAZY wrapper before the bound is CONFORMING -- the discarded wrappers do nothing', + lazy.r?.verdict === 'CONFORMING'); + expect('and it is recorded as the lazy state rather than as a proof', + lazy.r?.wrapOrder === 'lazy-transform'); + + const sideEffect = await one(FIXTURE_ROW_BLIND_SIDE_EFFECT); + expect('RESIDUAL: a row-blind side effect before the bound is not graded (declared limit)', + sideEffect.r?.verdict === 'CONFORMING' && sideEffect.r?.wrapOrder === 'lazy-transform'); + + const refuses = await one(FIXTURE_REFUSES); + expect('the refusing fixture is CONFORMING', refuses.r?.verdict === 'CONFORMING'); + expect('the refusing fixture is recorded as refusing', refuses.r?.refused === true); + + const notQuery = await one(FIXTURE_NOT_A_QUERY); + expect('a non-query `find` is a structural candidate', notQuery.n === 1); + expect('the control probe drops a non-query `find`', notQuery.r?.verdict === 'OUT_OF_SCOPE'); + + const noFilter = await one(FIXTURE_NO_FILTERING); + expect('a `find` that never filters cannot pass vacuously', + noFilter.r?.verdict === 'OUT_OF_SCOPE'); + + const constant = await one(FIXTURE_CONSTANT_ROWS); + expect('a constant-returning `find` is a structural candidate', constant.n === 1); + expect('the control probe refuses a seat it could not read -- constants are OUT OF SCOPE, never BLIND', + constant.r?.verdict === 'OUT_OF_SCOPE'); + + const foreign = await one(FIXTURE_FOREIGN_ROWS); + expect('a query-shaped double answering with rows of its own is not graded BLIND', + foreign.r?.verdict !== 'BLIND'); + expect('it is DECLARED unjudged rather than dropped -- absence must be loud', + foreign.r?.verdict === 'UNJUDGED'); + + const shared = await one(FIXTURE_SHARED_HELPER); + expect('a double bounded through a SHARED helper is discovered', shared.n === 1); + expect('a double bounded through a SHARED helper is CONFORMING -- the gate reads behaviour, not spelling', + shared.r?.verdict === 'CONFORMING'); + + const tableMap = await one(FIXTURE_TABLE_MAP); + expect('rows behind a table map keyed by the object name are reachable', tableMap.n === 1); + expect('and the object-name guard is searched, not guessed', tableMap.r?.verdict === 'BLIND'); + + const moduleFixture = await one(FIXTURE_MODULE_FIXTURE); + expect('binding strategy 2 reaches a module-scope fixture array', moduleFixture.n === 1); + expect('a module-scope-fixture double is judged, not dropped', + moduleFixture.r?.verdict === 'BLIND'); + + // Reconciliation, both directions. + const fake = new Map([['a.test.ts', { blind: 1, wrong: 0, unjudged: 0, details: [{ line: 1, result: { verdict: 'BLIND' } }] }]]); + expect('an unbaselined blind double is an error', reconcile(fake, {}).length === 1); + expect('a matching baseline entry passes', reconcile(fake, { 'a.test.ts': { blind: 1 } }).length === 0); + expect('a grown count is an error', reconcile(fake, { 'a.test.ts': { blind: 0 } }).length === 1); + expect('a fallen count is an error (ratchet down)', reconcile(fake, { 'a.test.ts': { blind: 2 } }).length === 1); + expect('a stale entry is an error', reconcile(new Map(), { 'gone.test.ts': { blind: 1 } }).length === 1); + const fakeWrong = new Map([['b.test.ts', { blind: 0, wrong: 1, unjudged: 0, details: [{ line: 2, result: { verdict: 'WRONG', shapes: ['truthiness'] } }] }]]); + expect('a shape-breaking double is ledgered in its own kind, not folded into blind', + reconcile(fakeWrong, { 'b.test.ts': { blind: 0, wrong: 1 } }).length === 0 + && reconcile(fakeWrong, { 'b.test.ts': { blind: 1 } }).length === 2); + + if (failures.length > 0) { + console.error(`x check-objectql-double-limit --self-test (${failures.length} failure(s)):\n`); + for (const f of failures) console.error(` - ${f}`); + console.error(''); + process.exit(1); + } + console.log( + 'OK self-test: separates bounding, limit-blind, truthiness, bound-before-filter and\n' + + ' refusing doubles on synthetic fixtures; all three shape rules are pinned in BOTH\n' + + ' directions, with the wrap-order proof separated from the two states it cannot\n' + + ' prove; the control probe drops a non-query `find`, one that never filters, and\n' + + ' one answering with rows of its own that it could read no evidence from;\n' + + ' a SHARED helper and a per-file copy grade identically; rows reached through a\n' + + ' table map and through a module-scope fixture are both driven; the ledger\n' + + ' reconciles in both directions.', + ); +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- +function reportCensus(census, prefix = ' ') { + const w = census.wrapOrder; + console.log( + `${prefix}${census.proposed} structural candidate(s): ${census.graded} graded, ` + + `${census.unjudged} unjudged, ${census.dropped} dropped as out of scope.`, + ); + console.log( + `${prefix}of the graded: ${census.conforming} apply the bound or refuse it loudly ` + + `(${census.refusing} refuse), ${census.blind} are limit-blind, ` + + `${census.wrong} break a shape rule.`, + ); + console.log( + `${prefix}wrap order: ${w['no-transform']} return their rows untouched, ` + + `${w['proven-after-bound']} PROVE the bound precedes a row-touching stage, ` + + `${w['lazy-transform']} transform without reading (order is behaviourally moot), ` + + `${w['before-bound']} touch rows outside the bound.`, + ); +} + +if (!isEntrypoint(import.meta.url)) { + // Imported (another gate's self-test, or a measurement helper). Running the + // corpus scan as an import side effect would make this file impossible to + // reuse without also failing someone else's process. +} else if (process.argv.includes('--self-test')) { + await selfTest(); +} else if (process.argv.includes('--census')) { + const { measured, census } = await measure(); + reportCensus(census, ''); + for (const [file, v] of [...measured].sort()) { + console.log(` ${file}: blind=${v.blind} wrong=${v.wrong} unjudged=${v.unjudged}`); + if (process.argv.includes('--why')) { + for (const d of v.details) console.log(` L${d.line} ${describeDetail(d)}`); + } + } +} else { + if (!existsSync(resolve(repoRoot, BASELINE_PATH))) { + console.error(`check-objectql-double-limit: missing ${BASELINE_PATH}`); + process.exit(2); + } + const baseline = JSON.parse(readFileSync(resolve(repoRoot, BASELINE_PATH), 'utf8')); + const baselineFiles = baseline.files ?? {}; + const { measured, census } = await measure(); + + const errors = []; + if (census.graded === 0) { + errors.push( + 'DISCOVERED: the control probe graded no ObjectQL `find` doubles at all. That is a ' + + 'broken scan, not a clean repo -- every other invariant iterates the graded set.', + ); + } + errors.push(...reconcile(measured, baselineFiles)); + + const mono = monotonicity(baselineFiles); + for (const file of mono?.added ?? []) { + errors.push( + `${file}: ADDED to the baseline (not present at ${mono.base}). The grandfather list is ` + + `not a mute button -- it only ever shrinks.`, + ); + } + + if (errors.length > 0) { + console.error(`x ObjectQL double \`limit\` conformance (${errors.length} problem(s)):\n`); + for (const e of errors) console.error(` - ${e}`); + console.error(''); + reportCensus(census, ' '); + process.exit(1); + } + + console.log( + `OK ObjectQL double \`limit\` conformance holds: ${census.graded} double(s) graded, ` + + `${census.conforming} apply the caller's bound or refuse it loudly.`, + ); + reportCensus(census, ' '); + console.log( + ` ${census.blind} limit-blind, ${census.wrong} shape-breaking and ${census.unjudged} ` + + `unjudged double(s) in ${measured.size} grandfathered file(s); none new.`, + ); + console.log( + mono + ? ` baseline key set verified against ${mono.base}: no files added.` + : ` NOT verified: could not read the baseline at the merge base with main, so ` + + `"no files added" is unchecked this run.`, + ); +} diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json new file mode 100644 index 0000000000..6675a77405 --- /dev/null +++ b/scripts/objectql-double-limit.baseline.json @@ -0,0 +1,844 @@ +{ + "$comment": [ + "Measured baseline for scripts/check-objectql-double-limit.mjs (#11525, from #10978).", + "", + "WHAT AN ENTRY IS. One test file holding in-memory ObjectQL `find` double(s) that do not", + "hold the caller's `limit`. An entry is DEBT, never an exemption: the remedy is the double,", + "and the entry is deleted by the PR that repairs it. The per-file counts are MEASURED by the", + "gate, never asserted by hand -- run the gate and it prints what it measured for every file", + "it disagrees with.", + "", + "`blind` the double ignores the bound outright: it matches `where` and hands back every", + " matched row. It cannot tell a read bounded at 200 from the same read bounded at", + " 1000, or from one carrying no bound at all, so every limit change on that read is", + " green BY CONSTRUCTION and the production symptom is a silently truncated result", + " set rather than an error.", + "", + "`wrong` the double DOES apply the bound but breaks one of the three shape rules -- most", + " of this population reads it by truthiness (`opts.limit ? ... : rows`, or", + " `limit > 0`), so a request for NOTHING is answered with the WHOLE table. Kept in", + " its own count rather than folded into `blind`, because the two have different", + " remedies and a folded count cannot tell a repair from a regression.", + "", + "`unjudged` the double could not be lifted out of its file and driven, so the gate has no", + " verdict on it. Declared rather than skipped: \"could not run\" is a failure, not a", + " pass (AGENTS.md, \"Absence must be loud\"). Shrinking this count is a real repair --", + " it usually means widening the lift, not editing the double.", + "", + "ABSENCE FROM THIS FILE MEANS GRADED AND CONFORMING -- never \"unscanned\". Membership is", + "decided behaviourally: a candidate the control probe cannot seat as a query-honouring,", + "row-selecting `find` is dropped OUT OF SCOPE (`find` is also a Map lookup, a registry read", + "and an array search), and a `find` that answers with a record rather than a list is a", + "different verb. The gate PRINTS the whole census on every run -- candidates proposed,", + "graded, unjudged, dropped -- so those populations are visible rather than implied.", + "", + "PROVENANCE, and the number this ledger does NOT carry. #11525 was filed against a census", + "recorded on PR #11521: 49 limit-blind doubles across 43 files and 10 packages, 9 of them", + "converted there, 40 left. Re-derived behaviourally at the merge base of this gate's own PR,", + "the population is MUCH larger -- 167 blind, 32 breaking a shape rule and 54 unjudged, across", + "the files below. The two measurements are not the same instrument: the earlier one was", + "scoped by hand, this one seats every candidate by driving it. Seeding this ledger from 40", + "would have pinned the ratchet to a number the corpus does not have, and it would have read", + "as green forever. The nine doubles PR #11521 converted are the positive control on the", + "re-derivation: all nine grade CONFORMING here, so they are absent below.", + "", + "WHAT IS NOT IN HERE, and why this count is lower than a first measurement of the same corpus.", + "A candidate is seated only on a POSITIVE reading of the control probe -- the rows it answers with", + "must actually carry the probe's field. 19 candidates answered BOTH control probes with rows of", + "their OWN (constant stubs like `async find(o, q) { return [{ id: 'r1' }]; }`, and schema-signature", + "fixtures that exist only to satisfy a `parse()`), so the control read nothing from them and", + "obtained no evidence of filtering at all. Seating them anyway graded them limit-blind -- debt with", + "no possible remedy, because there is no corpus to bound -- and a ledger carrying unfixable entries", + "can never burn down. They are routed by structure instead: 15 dropped OUT OF SCOPE (the body never", + "filters), 4 DECLARED unjudged (it does, but this lift cannot drive it). Both are pinned by", + "--self-test fixtures, so reading \"not disproven\" as \"proven\" again fails the gate's own test.", + "", + "TWO ways to clear an entry, and the cheap one is usually right:", + " 1. apply the bound AFTER the filter, by PRESENCE --", + " const page = typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows;", + " 2. make the double REFUSE a bound it does not implement (`throw` when `limit` arrives).", + "Option 2 is one line and is already the recorded practice next door for unknown operators", + "in `packages/objectql/src/engine-autonumber-*.test.ts` -- \"silently ignoring an unknown", + "operator would let a bad query pass as a good one\". A double whose reads never carry a", + "bound does not need a bound implementation; it needs to stop answering as if it had one.", + "", + "SHRINK-ONLY and hand-edited under review. The gate fails in BOTH directions: a count that", + "went down must be lowered here in the same PR, and a file with nothing left must lose its", + "entry entirely. The key set is additionally checked against the merge base with origin/main,", + "because counts alone cannot see a newly-added file matching its own count -- that would turn", + "this ledger into a general-purpose mute button (the SLOT_LOOKUP_UNSWEPT precedent, #4251).", + "There is deliberately no --update flag: a generator would let a new limit-blind double in by", + "\"just run the update command\".", + "", + "DO NOT DELETE THIS FILE WHEN IT EMPTIES. With the file absent the gate prints", + "\"check-objectql-double-limit: missing\" followed by this path and exits 2 BEFORE it scans", + "anything -- a hard refusal, never a clean pass, because an absent ledger cannot tell settled", + "debt from a new defect. Deleting and later re-adding it also costs the monotonicity check for", + "that run. `files: {}` is the SUCCESS state, and the loader reads `baseline.files ?? {}`, so", + "an empty ledger is a passing run rather than a half-written one.", + "", + "Key is the repo-relative test file path; its value is the measured counts for that file.", + "Run the gate to see exactly what it measured." + ], + "files": { + "packages/core/src/utils/migration-journal.test.ts": { + "unjudged": 1 + }, + "packages/metadata-protocol/src/migrations/recorded-by-sentinel.test.ts": { + "unjudged": 1 + }, + "packages/metadata-protocol/src/migrations/seed-tenancy-backfill.test.ts": { + "unjudged": 1 + }, + "packages/metadata-protocol/src/protocol-publish-drafts-advisories.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol-publish-drafts-closure.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol-publish-drafts-endpoint-gate.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol-publish-drafts-package-scope.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.audit-field-governance.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.batch-verb-driver-code.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.batch-verb-driver-text.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts": { + "unjudged": 1 + }, + "packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.destructive-gate-reachable-types.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.diff-canonical-type-and-history-outage.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.diff-credential-redaction.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.diff-dead-history-read.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts": { + "unjudged": 1 + }, + "packages/metadata-protocol/src/protocol.hydrate-overlay-canonical-type.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.i18n-bundle-list-merge.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.injected-system-columns.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.lifecycle-audit-rows.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.meta-list-runtime-baseline-dedup.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.metadata-redaction.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.migrate-stored-noncanonical-type.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.object-registry-write-through-spelling.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts": { + "unjudged": 1 + }, + "packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.package-closure-gate.test.ts": { + "unjudged": 1 + }, + "packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.platform-schedule-org-gate.test.ts": { + "unjudged": 1 + }, + "packages/metadata-protocol/src/protocol.publish-commit-capture-read-failure.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.publish-item-draft-org-scope.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.publish-item-rebind-announce.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.publish-side-effects-canonical-type.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.publish-stored-type-canonical.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.read-decorations.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.record-package-commit-durability.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.revert-stored-type-canonical.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts": { + "unjudged": 1 + }, + "packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts": { + "unjudged": 1 + }, + "packages/metadata-protocol/src/protocol.stored-conversions.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.stored-migration.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.stored-residue-resave.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts": { + "unjudged": 1 + }, + "packages/metadata-protocol/src/sys-metadata-repository.draft-drain.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/sys-metadata-repository.history-counters.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/sys-metadata-repository.recorded-by.test.ts": { + "blind": 1 + }, + "packages/metadata-protocol/src/view-container-runtime-expansion.test.ts": { + "blind": 1 + }, + "packages/objectql/src/adr0104-attestation-evidence.test.ts": { + "blind": 1 + }, + "packages/objectql/src/adr0104-lax-deviation-marker.test.ts": { + "blind": 1 + }, + "packages/objectql/src/bulk-write-per-row-hooks.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-audit-anchor-write.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-author-state-query.test.ts": { + "wrong": 1 + }, + "packages/objectql/src/engine-autonumber-runtime-owned.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-cascade-delete-atomic.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-cascade-delete-multivalue-probe.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-cascade-delete-probe-failure.test.ts": { + "unjudged": 1 + }, + "packages/objectql/src/engine-cascade-delete.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-cascade-registry-read-failure.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-comparand-type-door.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-data-events.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-delete-prior-read-scope.test.ts": { + "unjudged": 1 + }, + "packages/objectql/src/engine-delete-restricted-locale.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-filter-alias.test.ts": { + "wrong": 1 + }, + "packages/objectql/src/engine-filter-array-lowering.test.ts": { + "wrong": 1 + }, + "packages/objectql/src/engine-findone-contract.test.ts": { + "wrong": 1 + }, + "packages/objectql/src/engine-formula-scale.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-lookup-referential-integrity.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-privileged-read-ambient-transaction.test.ts": { + "unjudged": 1 + }, + "packages/objectql/src/engine-readonly-strict-writes.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-readonly-strip-caller-values.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-readonly-strip-signal.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-readonly-when-derived-writes.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-readonly-when-parent.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-required-when-parent.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-seed-required-deferral.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-summary-index-registry-read-failure.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-summary-recompute-context.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-summary-retry.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-temporal-comparand-door.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-unknown-option.test.ts": { + "wrong": 1 + }, + "packages/objectql/src/engine-unscoped-multi-write-dispatch.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-update-prior-read-scope.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-wire-alias-reject.test.ts": { + "wrong": 1 + }, + "packages/objectql/src/engine-write-formula-hydration.test.ts": { + "blind": 1 + }, + "packages/objectql/src/engine-write-not-found-gate.test.ts": { + "blind": 1 + }, + "packages/objectql/src/hook-condition-bulk-previous.test.ts": { + "blind": 1 + }, + "packages/objectql/src/hook-condition-fail-loud.test.ts": { + "blind": 1 + }, + "packages/objectql/src/hook-condition-merged-record.test.ts": { + "blind": 1 + }, + "packages/objectql/src/hook-condition-previous-scope.test.ts": { + "blind": 1 + }, + "packages/objectql/src/hook-input-shape-contract.test.ts": { + "blind": 1 + }, + "packages/objectql/src/internal-fields.test.ts": { + "blind": 1 + }, + "packages/objectql/src/layered-overlay-integration.test.ts": { + "unjudged": 1 + }, + "packages/objectql/src/lifecycle/lifecycle-service.test.ts": { + "unjudged": 1 + }, + "packages/objectql/src/meta-object-primary-designation-roundtrip.test.ts": { + "blind": 1 + }, + "packages/objectql/src/meta-object-search-companion-roundtrip.test.ts": { + "blind": 1 + }, + "packages/objectql/src/meta-object-tenant-index-roundtrip.test.ts": { + "blind": 1 + }, + "packages/objectql/src/package-disable-enforcement.test.ts": { + "blind": 1 + }, + "packages/objectql/src/plugin.authoring-channel.test.ts": { + "blind": 1 + }, + "packages/objectql/src/protocol-boot-hydration-scoped.test.ts": { + "blind": 1 + }, + "packages/objectql/src/protocol-boot-object-package-binding.test.ts": { + "blind": 1 + }, + "packages/objectql/src/protocol-clone-real-engine.test.ts": { + "blind": 1 + }, + "packages/objectql/src/protocol-commit-history.test.ts": { + "blind": 1 + }, + "packages/objectql/src/protocol-delete-object-registry-heal.test.ts": { + "blind": 1 + }, + "packages/objectql/src/protocol-meta-effective-schema.test.ts": { + "blind": 1 + }, + "packages/objectql/src/protocol-object-overlay-layer.test.ts": { + "blind": 1 + }, + "packages/objectql/src/protocol-packaged-object-base.test.ts": { + "blind": 1 + }, + "packages/objectql/src/protocol-publish-canonical-fold.test.ts": { + "blind": 1 + }, + "packages/objectql/src/protocol-publish-rollback.test.ts": { + "unjudged": 1 + }, + "packages/objectql/src/protocol-recorded-by-null.test.ts": { + "blind": 1 + }, + "packages/objectql/src/protocol-registry-shadow.test.ts": { + "blind": 1 + }, + "packages/objectql/src/protocol-revert-org-scope.test.ts": { + "blind": 1 + }, + "packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts": { + "blind": 1 + }, + "packages/objectql/src/protocol-save-meta-repo-path.test.ts": { + "unjudged": 1 + }, + "packages/objectql/src/protocol-unregistered-object.test.ts": { + "blind": 1 + }, + "packages/objectql/src/protocol-view-identity-overlay.test.ts": { + "unjudged": 1 + }, + "packages/objectql/src/protocol-writepath-object-ownership.test.ts": { + "blind": 1 + }, + "packages/objectql/src/publish-meta-response-conformance.test.ts": { + "blind": 1 + }, + "packages/objectql/src/publish-package-drafts-response-conformance.test.ts": { + "blind": 1 + }, + "packages/objectql/src/save-meta-response-conformance.test.ts": { + "blind": 1 + }, + "packages/objectql/src/secret-fields.test.ts": { + "blind": 1 + }, + "packages/objectql/src/seed-loader-authoring-feedback.test.ts": { + "blind": 1 + }, + "packages/objectql/src/summary-rollup.test.ts": { + "blind": 1 + }, + "packages/objectql/src/sys-metadata-repository.test.ts": { + "unjudged": 1 + }, + "packages/platform-objects/src/plugin.test.ts": { + "unjudged": 1 + }, + "packages/platform-objects/src/system/migration-flag.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-approvals/src/approval-node.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-approvals/src/approval-restart-resume.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-approvals/src/approval-revise.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-approvals/src/approver-cross-org.integration.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-approvals/src/team-approver-org-screen.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-approvals/src/team-member-org-screen.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-audit/src/activity-type-vocabulary-enforcement.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-audit/src/audit-bound-previous.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-audit/src/audit-hook-object-scope.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-audit/src/audit-milestone-summary.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-audit/src/audit-option-label-summary.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-audit/src/auth-event-audit.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-audit/src/capability-gate-update-verb.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-audit/src/comment-access-hooks.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-audit/src/objects/sys-audit-log-record-views-columns.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-audit/src/read-audit.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-auth/src/accept-invitation-adopt-membership.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-auth/src/account-issuer-parity.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-auth/src/admin-revoke-user-session-match-guard.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-auth/src/auth-manager.jwt-eddsa-fallback.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-auth/src/auth-manager.optional-plugin-isolation.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-auth/src/organization-add-member.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-auth/src/remove-member-permission-guard.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-auth/src/remove-user-atomicity.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-auth/src/revoke-session-match-guard.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-auth/src/session-of-record.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-auth/src/session-tombstone.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-email/src/attachment-reclaim.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-email/src/email-headers-internal.integration.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-email/src/email-plugin.attachment-storage.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-email/src/email-plugin.outbox-sweep.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-email/src/email-plugin.queue-delivery.test.ts": { + "wrong": 1 + }, + "packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-pinyin-search/src/companion-projection.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-security/src/audience-anchors.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-security/src/explain-engine.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-security/src/normalize-managed-by.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-security/src/packaged-permission-set-restore-leg.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-security/src/permission-denied-user-copy.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-security/src/permission-set-active.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-security/src/permission-set-drift.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-security/src/permission-set-overlay-discard.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-security/src/permission-set-projection.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-security/src/resolve-permission-sets-for-context.pin.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-security/src/security-denial-user-copy.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-security/src/share-link-tenant-wall.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-sharing/src/business-unit-graph.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-sharing/src/federated-phantom-owner-scoping.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-sharing/src/manager-org-screen.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-sharing/src/position-graph.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-sharing/src/share-link-enforcement-context.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-sharing/src/share-link-service.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts": { + "unjudged": 1 + }, + "packages/plugins/plugin-webhooks/src/webhook-headers-gate.test.ts": { + "blind": 1 + }, + "packages/plugins/plugin-webhooks/src/webhook-secret-at-rest.test.ts": { + "blind": 1 + }, + "packages/rest/src/meta-compound-save-force-parity.test.ts": { + "blind": 1 + }, + "packages/rest/src/meta-compound-save-mode-parity.test.ts": { + "blind": 1 + }, + "packages/rest/src/meta-object-extension-property-classes.test.ts": { + "blind": 1 + }, + "packages/rest/src/meta-object-overlay-extension-fold.test.ts": { + "blind": 1 + }, + "packages/rest/src/meta-object-search-companion-agreement.test.ts": { + "blind": 1 + }, + "packages/rest/src/meta-published-overlay.test.ts": { + "blind": 1 + }, + "packages/rest/src/remote-tables-twin.equivalence.test.ts": { + "unjudged": 1 + }, + "packages/rest/src/rest-exec-ctx-principal-kind.test.ts": { + "unjudged": 1 + }, + "packages/rest/src/rest-server-meta-read-org-scope.test.ts": { + "blind": 1 + }, + "packages/rest/src/rest-server-timing.test.ts": { + "unjudged": 1 + }, + "packages/rest/src/rest-update-path-id-not-a-dropped-field.test.ts": { + "blind": 1 + }, + "packages/rest/src/rest-write-response-formula.test.ts": { + "blind": 1 + }, + "packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts": { + "blind": 1 + }, + "packages/runtime/src/action-execution-calldata-batch-retired.test.ts": { + "unjudged": 1 + }, + "packages/runtime/src/domains/meta-published-runtime-publish.test.ts": { + "blind": 1 + }, + "packages/runtime/src/domains/meta-save-destructive-remedy.test.ts": { + "blind": 1 + }, + "packages/runtime/src/http-dispatcher.keys.test.ts": { + "blind": 2 + }, + "packages/runtime/src/http-dispatcher.test.ts": { + "blind": 1 + }, + "packages/runtime/src/meta-compound-arity-mint-door.test.ts": { + "blind": 1 + }, + "packages/runtime/src/meta-field-overlay-lock.test.ts": { + "blind": 1 + }, + "packages/runtime/src/meta-write-org-scope.test.ts": { + "blind": 1 + }, + "packages/runtime/src/migration-recovery-plugin.test.ts": { + "unjudged": 1 + }, + "packages/runtime/src/sandbox/nested-write.canary.test.ts": { + "blind": 1 + }, + "packages/runtime/src/sandbox/nested-write.integration.test.ts": { + "blind": 1 + }, + "packages/services/service-automation/src/builtin/crud-bulk-intent.test.ts": { + "blind": 1 + }, + "packages/services/service-automation/src/builtin/wait-node-degraded-run.test.ts": { + "wrong": 1 + }, + "packages/services/service-automation/src/plugin-suspended-run-wiring.test.ts": { + "unjudged": 1 + }, + "packages/services/service-automation/src/runas-grant-resolution.integration.test.ts": { + "blind": 1 + }, + "packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts": { + "blind": 1 + }, + "packages/services/service-job/src/db-job-adapter.degraded-outcome.test.ts": { + "wrong": 1 + }, + "packages/services/service-job/src/db-job-adapter.test.ts": { + "wrong": 1 + }, + "packages/services/service-job/src/db-job-adapter.timeout.test.ts": { + "wrong": 1 + }, + "packages/services/service-job/src/job-service-plugin.test.ts": { + "blind": 1 + }, + "packages/services/service-messaging/src/preference-resolver.test.ts": { + "unjudged": 1 + }, + "packages/services/service-queue/src/db-queue-adapter.test.ts": { + "wrong": 1 + }, + "packages/services/service-queue/src/job-queue-retention.test.ts": { + "wrong": 1 + }, + "packages/services/service-settings/src/config-change-audit.test.ts": { + "blind": 1 + }, + "packages/services/service-settings/src/settings-crypto-fail-closed.test.ts": { + "blind": 1 + }, + "packages/services/service-settings/src/settings-engine-bind-window.test.ts": { + "blind": 1 + }, + "packages/services/service-settings/src/settings-prebind-read-warning.test.ts": { + "blind": 1 + }, + "packages/services/service-settings/src/settings-secret-rotation.test.ts": { + "blind": 1 + }, + "packages/services/service-settings/src/sys-secret-orphan-report.test.ts": { + "blind": 1 + }, + "packages/services/service-storage/src/attachment-access-hooks.test.ts": { + "blind": 1 + }, + "packages/services/service-storage/src/attachment-lifecycle.test.ts": { + "blind": 1 + }, + "packages/services/service-storage/src/files-to-references-migration.test.ts": { + "unjudged": 1 + }, + "packages/services/service-storage/src/lax-deviation-reclamation-gate.test.ts": { + "blind": 2 + }, + "packages/services/service-storage/src/tombstone-hydration-download-agreement.test.ts": { + "unjudged": 1 + }, + "packages/triggers/trigger-record-change/src/bulk-write-per-row-context.test.ts": { + "blind": 1 + }, + "packages/triggers/trigger-record-change/src/formula-context.test.ts": { + "blind": 1 + }, + "packages/triggers/trigger-record-change/src/multilookup-context.test.ts": { + "blind": 1 + } + } +} diff --git a/scripts/pm/bare-root-worklist.mjs b/scripts/pm/bare-root-worklist.mjs index 09b8d6a3d7..35b33200ab 100644 --- a/scripts/pm/bare-root-worklist.mjs +++ b/scripts/pm/bare-root-worklist.mjs @@ -206,6 +206,21 @@ const TRIAGE = new Map([ verdict: 'REFUSE-UNSPELLABLE', why: 'test files only, 2510 of 4903 (51%)', }], + ['check:objectql-double-limit SCAN_ROOT packages', { + verdict: 'REFUSE-UNSPELLABLE', + why: 'test files only — the walk admits `*.test.ts` and nothing else, 2696 of 5161 (52%), the ' + + 'same file-KIND filter as its check:where-matcher and check:examples-live-imports ' + + 'neighbours above and refused alike. Measured rather than assumed, in both directions: the ' + + 'only spellable claim, `packages/**`, covers all 2696 test files AND all 2465 non-test ' + + 'files under the root, so it would name this gate for 2465 files it never opens — the ' + + 'costlier error, since a find double can only land in a test file. Nothing narrower is ' + + 'spellable: every glob form of the real population collapses to a malformed ' + + 'double-separator prefix (`packages/**/*.test.ts` -> `packages//.test.ts`) that hintCovers ' + + 'matches against 0 of 2696, so a narrow declaration would not be a precise hint but a live ' + + 'hint covering nothing. No narrower SUBTREE exists either — the corpus is spread over 28 ' + + 'second-level directories, and 274 of the 2696 sit outside any `src` segment, so even the ' + + 'check:runner-env-posture `src`-segment shape would be false here as well as uncollapsible', + }], ['check:i18n-coverage EXAMPLES_DIR examples', { verdict: 'REFUSE-UNSPELLABLE', why: 'one named config file per child directory — 3 of 238 (1.3%)',