diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 6a42541c60c..c4eb6d23387 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -217,6 +217,27 @@ cannot read, and they are neither in nor out. | object name is an `object: string` parameter | 19 | | object name is some other run-time expression | 57 | +### Subtractions the census could NOT defend — enforced + +A same-named call on something that is not a data engine is subtracted, and the +subtraction is DEFENSIBLE when this census can name why: the receiver is a `node:` +builtin, a value it watched being constructed, a language global, a type THIS +corpus declares and the door rule rejected, or an `UNTYPED_RECEIVERS` row. + +⚠️ Counted below are the subtractions it can name no such fact for — the +receiver carries a declared type the engine type index does not hold, and that +index is built from TRACKED sources only, deliberately. An untracked, generated +or dependency-owned declaration is one this census never saw, and «never saw it» +must not be spelled the same way as «read it, not an engine». + +| what | count | +| :--- | ---: | +| write calls subtracted with no defensible reason | **3** | +| …whose declared type text states an engine door anyway | **2** | + +Every one of them is listed, by receiver and by the type text that could not be +placed, in [`docs/audits/2026-08-tenant-audit-write-call-sites.counts.md`](https://github.com/objectstack-ai/objectstack/blob/main/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md). + The corpus walked is every tracked non-test source under `packages/services/` and `packages/plugins/`; calls to a same-named method on something that is not a data engine were subtracted. Every site is listed in @@ -232,11 +253,11 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-09-16 at `11daf7f69`. +Measured on 2026-09-18 at `02bdeaaf2`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 570 | +| tracked non-test sources scanned | 573 | | engine-shaped types recognised | 63 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 146 | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 69505a01584..c988854b368 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -43,6 +43,30 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 102 | +## Subtractions the census could NOT defend — enforced + +A same-named call on something that is not a data engine is subtracted, and the +subtraction is DEFENSIBLE when this census can name why: the receiver is a `node:` +builtin, a value it watched being constructed, a language global, a type THIS +corpus declares and the door rule rejected, or an `UNTYPED_RECEIVERS` row. + +⚠️ Counted below are the subtractions it can name no such fact for — the +receiver carries a declared type the engine type index does not hold, and that +index is built from TRACKED sources only, deliberately. An untracked, generated +or dependency-owned declaration is one this census never saw, and «never saw it» +must not be spelled the same way as «read it, not an engine». + +| what | count | +| :--- | ---: | +| write calls subtracted with no defensible reason | **3** | +| …whose declared type text states an engine door anyway | **2** | + +| file | receiver | verb | why | declared type | door | n | +|---|---|---|---|---|---|---:| +| `packages/plugins/plugin-auth/src/audience-gate-test-support.ts` | `engine` | `insert` | anonymous-type | `{ insert: (name: string, data: any, options?: any) => Promise } \| null` | ⚠️ yes | 1 | +| `packages/plugins/plugin-auth/src/sso-client-secret.ts` | `e` | `update` | anonymous-type | `{ find(object: string, query: unknown): Promise[]>; update(object: string, data: unknown, options?: unknown): Promise; }` | ⚠️ yes | 1 | +| `packages/plugins/plugin-hono-server/src/adapter.ts` | `this.app` | `delete` | type-not-in-corpus | `Hono` | no | 1 | + ## Corpus scale — present and dated, ⛔ NOT enforced ⛔ These four describe the CORPUS this census walked, not the population it @@ -52,11 +76,11 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-09-16 at `11daf7f69`. +Measured on 2026-09-18 at `02bdeaaf2`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 570 | +| tracked non-test sources scanned | 573 | | engine-shaped types recognised | 63 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 146 | diff --git a/scripts/tenant-audit-census.mjs b/scripts/tenant-audit-census.mjs index e4d9bf884f4..b87aade77de 100644 --- a/scripts/tenant-audit-census.mjs +++ b/scripts/tenant-audit-census.mjs @@ -131,6 +131,32 @@ * {@link measuredAt} dates it, and `check-tenant-audit-census.mjs` carries the * reasoning and the measurement that draws the line where it is drawn. * + * ## ⭐ The index is TRACKED-ONLY, and the subtraction now says so + * + * Every enumeration here is `git ls-files` (see the criterion at + * {@link CORPUS_TYPE_DECL}), so a declaration that is untracked -- a type file a + * developer has not `git add`ed yet, a generated one, a dependency's -- is one the + * engine type index does not hold. ⛔ That is the design: a census whose verdict + * moved with the working tree would be measuring the working tree. + * + * What it cost, until this was written down: a receiver typed with such a + * declaration resolved to `kind: 'other'`, was SUBTRACTED from the certified + * population, and printed nothing. `I read this receiver's type and it is a Set` + * and `I could not find this receiver's type at all` were the same row at the same + * exit code -- so the population could shrink, and the part that shrank was + * invisible. ⭐ The subtraction is not the defect; the two arms printing the same + * thing is. + * + * ⇒ {@link NON_ENGINE_REASONS} is now a closed set of seven, five of which name a + * fact that DEFENDS the subtraction and two of which admit the census could not + * place the receiver's type. The undefended ones are printed per site on every + * run, carried per site in `--json`, and counted under ENFORCEMENT in both + * artefacts -- so a type that leaves the index lands in the diff by name instead + * of removing a site in silence. ⛔ No subtraction is withdrawn on this basis and + * ⛔ no exit code changed: three sites on a clean tree are undefended today, two + * of them with a write door in their own inline type text, and a gate that reds + * on arrival is a gate that gets weakened. + * * ## Refusals, never quiet passes (#4690) * * A corpus of zero sources, an object registry of zero declarations, a source @@ -173,6 +199,77 @@ export function collectSources(root = ROOT, roots = SURFACE_ROOTS) { return out; } +/** + * ⭐ THE CRITERION, written down: this index is built from TRACKED files, and an + * untracked declaration is deliberately NOT repository content. + * + * {@link trackedTs} shells `git ls-files`, so every enumeration in this module -- + * the corpus it censuses, the engine type index receivers resolve against, the + * object registry, and the type-name set below -- sees what the repository + * contains and nothing that exists only in somebody's working tree. That is the + * design and not an oversight: a census whose verdict moved because a file had + * not been `git add`ed yet would make the repository's answer a function of a temp + * file, and a number that changes with the working tree is not a fact about the + * repository. ⛔ So this module never reads an untracked file. + * + * The price of that choice is real and it lands in ONE place: while a declaration + * is untracked, every receiver typed with it is a receiver whose type this module + * cannot find. + * + * ⇒ What the choice therefore OBLIGES is that such a receiver be SAID OUT LOUD + * rather than folded into "not an engine". "I never saw a declaration of that + * type" and "I read that type's declaration and it is not an engine" are + * different facts, and until {@link nonEngineReason} existed the first one was + * spelled exactly like the second: `kind: 'other'`, subtracted from the + * population, exit 0, nothing said. Whether the missing declaration is + * untracked, generated or a dependency's, the honest report is the same + * sentence -- "I could not place this receiver's type" -- and it is produced + * there. + * + * {@link corpusTypeNames} is the other half of that answer, and it is keyed on the + * SAME `trackedTs` call as the index ON PURPOSE. A diagnostic that could see more + * of the tree than the index it reports on would answer "the corpus declares that + * name" for a declaration the index was never able to read -- which is the one + * answer that would make this quieter instead of louder. + */ +export const CORPUS_TYPE_DECL = /\b(?:interface|class|enum)\s+([A-Za-z_$][\w$]*)|\btype\s+([A-Za-z_$][\w$]*)\s*[=<]/g; + +/** + * Every type NAME the tracked corpus declares -- interface, class, enum and alias + * names, and nothing else about them. + * + * Textual on purpose. The only question asked of this set is "does this repository + * declare that name anywhere", it is asked of every non-engine receiver, and a + * full parse of the tracked tree to collect identifiers the declaration keyword + * already states would pay a hundredfold for an answer of the same quality. + * Measured at 6520 files / 103 MB / 8154 names in 0.6 s, against the four sweeps + * this module already makes over the same list. + * + * ⛔ It deliberately records neither WHERE nor WHAT SHAPE. "The door rule read + * this declaration and said no" is what the index already answers; this set only + * separates that from "no declaration of that name exists in the repository at + * all". + * + * Zero names REFUSES (#4690): a sweep that read nothing would make every receiver + * type look unplaceable, which is loud only by accident, and the accident would + * read as a finding about the tree. + */ +export function corpusTypeNames(root = ROOT) { + const names = new Set(); + for (const rel of trackedTs(root, ['packages', 'examples'])) { + const text = readFileSync(join(root, rel), 'utf8'); + for (const m of text.matchAll(CORPUS_TYPE_DECL)) names.add(m[1] ?? m[2]); + } + if (names.size === 0) { + throw new Error( + 'tenant-audit-census: the tracked corpus declares ZERO type names -- refusing to report ' + + 'every non-engine receiver as a type this census never saw. A sweep that read nothing ' + + 'and a corpus that declares nothing are different.', + ); + } + return names; +} + /** * When the corpus-scale numbers were true, and against which tree. * @@ -544,6 +641,128 @@ export function resolveReceiver(recvNode, sf, decls, index, depth = 0) { return { kind: 'unresolved', how: ts.SyntaxKind[r.kind], detail: receiverKey(r, sf) }; } +/** + * Names whose DECLARATION belongs to the language, not to this corpus. + * + * A receiver typed `Map` is not a type this census failed to place: it + * is a language global, and the reason it is not a data engine is that nothing in + * this repository could make it one. Listing them keeps them OUT of the undefended + * pile, and the undefended pile is only useful while it is small enough that a + * real entry is visible in it. + * + * ⛔ This is NOT a list of "types that are fine". It is a list of names this + * corpus cannot declare. A DEPENDENCY's type is deliberately absent: this module + * cannot read `node_modules`, so a dependency's type is one it could not place, + * and saying so is the honest answer rather than an inconvenience to suppress. + */ +export const PLATFORM_TYPES = new Set([ + 'Map', 'Set', 'WeakMap', 'WeakSet', 'Array', 'ReadonlyArray', 'Promise', 'PromiseLike', + 'Date', 'RegExp', 'Error', 'Function', 'Object', 'String', 'Number', 'Boolean', 'Symbol', + 'BigInt', 'Uint8Array', 'Uint16Array', 'Uint32Array', 'Int8Array', 'Float32Array', + 'Float64Array', 'ArrayBuffer', 'SharedArrayBuffer', 'DataView', 'Buffer', 'Blob', 'File', + 'Headers', 'Request', 'Response', 'FormData', 'URL', 'URLSearchParams', 'AbortSignal', + 'Iterable', 'AsyncIterable', 'Iterator', 'IteratorResult', 'Generator', 'AsyncGenerator', + 'Record', 'Partial', 'Required', 'Readonly', 'Pick', 'Omit', 'Exclude', 'Extract', + 'NonNullable', 'Parameters', 'ReturnType', 'Awaited', 'InstanceType', 'ThisType', +]); + +/** Type-syntax words a type text can contain that are not type NAMES. */ +const TYPE_SYNTAX_WORDS = new Set([ + 'string', 'number', 'boolean', 'bigint', 'symbol', 'object', 'any', 'unknown', 'never', + 'void', 'null', 'undefined', 'this', 'readonly', 'keyof', 'typeof', 'infer', 'in', 'is', + 'asserts', 'extends', 'new', 'import', 'true', 'false', 'unique', 'declare', 'abstract', +]); + +/** + * ⭐ WHY a write call was subtracted as non-engine -- a CLOSED set in which exactly + * two arms admit the census could not tell. + * + * The subtraction itself is old and correct: a same-named call on something that is + * not a data engine must not enter a tenancy population, and `.delete()` alone + * answers ~250 sites in this corpus. What was missing is that ONE of these seven + * answers was reaching the count for two incompatible reasons. `kind: 'other'` was + * produced both by "I read the receiver's declared type and it is a `Set`" and by + * "I looked the receiver's declared type up and the index does not hold it" -- and + * an index built from tracked files alone does not hold a type whose declaration is + * untracked, generated, or a dependency's. Same row, same exit code, no diagnostic: + * the population shrank, and the part that shrank was invisible. + * + * ⇒ Five arms name a fact that DEFENDS the subtraction. Two -- `type-not-in-corpus` + * and `anonymous-type` -- say the census could not place the receiver's type, and + * those are reported per site, in both artefacts, by receiver and by the type text + * it could not place. ⛔ A subtraction is never *withdrawn* on this basis: that + * would be the census guessing in the other direction. It is DECLARED. + */ +export const NON_ENGINE_REASONS = Object.freeze({ + 'builtin-import': 'the receiver is an identifier imported from a `node:` builtin', + 'constructed-locally': "the receiver's own initializer is a `new X` this corpus can read", + 'platform-type': 'the declared type is a language global, which this corpus cannot declare', + 'corpus-type': 'the declared type names a type THIS CORPUS DECLARES -- the door rule read that declaration and said no', + 'ledger-row': 'an `UNTYPED_RECEIVERS` row says what the receiver is', + 'type-not-in-corpus': '⚠️ UNDEFENDED -- no declaration of that name exists in the TRACKED corpus (untracked, generated, or a dependency\'s)', + 'anonymous-type': '⚠️ UNDEFENDED -- the declared type is an inline literal, so there is no name for the index to be keyed on', +}); + +/** The two arms that admit the census could not place the receiver's type. */ +export const UNDEFENDED_REASONS = Object.freeze(['type-not-in-corpus', 'anonymous-type']); + +/** + * Does this receiver's type text itself declare an ObjectQL write door? + * + * The same rule as {@link memberIsEngineDoor}, applied to a type that has no name + * -- deliberately the same function rather than a second reading of the same rule, + * because two spellings of "what an engine door looks like" is how the two drift. + * + * ⭐ A `true` here is the sharpest thing this diagnostic can say: the census + * subtracted a write call whose receiver type satisfies its OWN definition of an + * engine, and the only reason it did is that the definition is applied to NAMED + * declarations while this type is spelled inline. It is reported, ⛔ not acted on. + */ +export function typeTextDeclaresEngineDoor(typeText) { + if (typeof typeText !== 'string' || !/\b(insert|update|delete)\b/.test(typeText)) return false; + const sf = parseSourceFile('census-receiver-type.ts', `type CensusReceiver = ${typeText};\n`); + let door = false; + const visit = (node) => { + if (ts.isTypeLiteralNode(node)) { + for (const m of node.members) { + if (!ts.isMethodSignature(m) && !ts.isPropertySignature(m)) continue; + const hit = memberIsEngineDoor(m, sf); + if (hit && hit.isWrite) door = true; + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + return door; +} + +/** + * Which arm of {@link NON_ENGINE_REASONS} this non-engine verdict rests on. + * + * Reads only what {@link resolveReceiver} already returned plus the tracked + * corpus's type-name set, so it cannot reach a fact the classifier itself could + * not reach, and it can never move a verdict -- it explains one. + */ +export function nonEngineReason(res, typeNames) { + const raw = typeof res?.type === 'string' ? res.type : ''; + if (raw.startsWith('node: builtin')) return { reason: 'builtin-import', names: [], doorShaped: false }; + if (/^new\s/.test(raw) || /\/new$/.test(String(res?.how ?? ''))) { + return { reason: 'constructed-locally', names: [], doorShaped: false }; + } + // String literals inside a type (an `import('…')` specifier, a literal union) + // carry no type NAMES, and their words would read as unplaceable identifiers. + const text = raw.replace(/'[^']*'|"[^"]*"|`[^`]*`/g, "''"); + if (text.includes('{')) { + return { reason: 'anonymous-type', names: [], doorShaped: typeTextDeclaresEngineDoor(raw) }; + } + const ids = [...new Set(text.match(/[A-Za-z_$][\w$]*/g) ?? [])] + .filter((id) => !PLATFORM_TYPES.has(id) && !TYPE_SYNTAX_WORDS.has(id)); + if (ids.length === 0) return { reason: 'platform-type', names: [], doorShaped: false }; + const unseen = ids.filter((id) => !typeNames.has(id)); + if (unseen.length === 0) return { reason: 'corpus-type', names: ids, doorShaped: false }; + return { reason: 'type-not-in-corpus', names: unseen, doorShaped: false }; +} + /** * ⭐ WHAT COUNTS AS A DECLARED OBJECT -- the definition, written down here * because the walk that preceded it had none. @@ -949,12 +1168,15 @@ export function resolveObjectNameArg(a0, sf, decls) { /** Run the census. */ export function runCensus({ root = ROOT, roots = SURFACE_ROOTS } = {}) { const index = widenIndexThroughAliases(buildEngineTypeIndex(root), root); + const typeNames = corpusTypeNames(root); const objects = declaredObjects(root); const sources = collectSources(root, roots); const sites = []; const unresolved = []; const usedRows = new Set(); let nonEngineCalls = 0; + const nonEngineReasons = new Map(); + const undefendedSubtractions = []; for (const rel of sources) { let text; @@ -1007,16 +1229,37 @@ export function runCensus({ root = ROOT, roots = SURFACE_ROOTS } = {}) { placedBy = 'object-name-parameter'; } + let byLedger = false; if (kind === 'unresolved') { const row = UNTYPED_RECEIVERS.find((r) => r.file === rel && r.receiver === where.receiver); if (row) { usedRows.add(row); if (row.engine) { kind = 'engine'; engineType = 'untyped receiver, placed by ledger'; placedBy = 'ledger'; } - else kind = 'other'; + else { kind = 'other'; byLedger = true; } } } - if (kind === 'other') { nonEngineCalls += 1; } + if (kind === 'other') { + nonEngineCalls += 1; + // ⭐ The subtraction is unchanged; what is new is that it now SAYS what + // it rests on. Two of the seven arms admit the census could not place + // the receiver's type -- those are the ones that used to be spelled + // exactly like "read it, not an engine", and they are reported per site. + const why = byLedger + ? { reason: 'ledger-row', names: [], doorShaped: false } + : nonEngineReason(res, typeNames); + nonEngineReasons.set(why.reason, (nonEngineReasons.get(why.reason) ?? 0) + 1); + if (UNDEFENDED_REASONS.includes(why.reason)) { + undefendedSubtractions.push({ + ...where, verb, + type: String(res.type ?? '').replace(/\s+/g, ' ').trim(), + how: res.how ?? null, + reason: why.reason, + names: why.names, + doorShaped: why.doorShaped === true, + }); + } + } else if (kind === 'unresolved') { unresolved.push({ ...where, verb, how: res.how, detail: res.detail ?? null, ledgered: false }); } else { @@ -1043,6 +1286,7 @@ export function runCensus({ root = ROOT, roots = SURFACE_ROOTS } = {}) { sites.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); unresolved.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); + undefendedSubtractions.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); const tenancyEnabled = sites.filter((s) => s.tenancy === 'enabled'); return { sites, @@ -1050,6 +1294,14 @@ export function runCensus({ root = ROOT, roots = SURFACE_ROOTS } = {}) { unledgered: unresolved.filter((u) => !u.ledgered), staleLedgerRows: UNTYPED_RECEIVERS.filter((r) => !usedRows.has(r)), nonEngineCalls, + // The subtraction, broken out by the fact each one rests on. Kept OUT of the + // enforced artefacts on purpose: `new Map().delete(k)` moves these counts on + // any commit that adds a Map, which is the ambient churn with no safety + // content that the enforced/unenforced split exists to keep out. The two arms + // that carry safety content are per-site, in `undefendedSubtractions`, and + // those move only when the population's own boundary moves. + nonEngineReasons: Object.fromEntries([...nonEngineReasons].sort((a, b) => a[0].localeCompare(b[0]))), + undefendedSubtractions, totals: { writeCallSites: sites.length, staticallyDecidableObjectName: sites.filter((s) => s.tenancy !== 'undecidable').length, @@ -1135,6 +1387,88 @@ function aggregate(census) { || a.cells[1].localeCompare(b.cells[1])); } +/** + * The undefended subtractions, aggregated the way every other row here is: by a + * key that a pure DISPLACEMENT cannot move. + * + * ⛔ No line numbers, for the same reason the site table carries none -- an + * inserted import above the call must not move an artefact that measures the + * population. `--json` carries `file:line` for anyone navigating to one. + * + * ⭐ This table is ENFORCED, and that is the point of it. A corpus-scale row moves + * on ambient churn (any commit that adds a `Map`), so the split puts those beyond + * comparison; this one moves only when a receiver's type stops being placeable, + * which is exactly when the population's own boundary moves and exactly what used + * to happen in silence. A type that leaves the index -- because its declaration + * was untracked, moved out of `packages/`/`examples/`, or renamed -- now lands + * here BY NAME, in the diff, instead of subtracting a site at exit 0. + */ +export function undefendedRows(census) { + const groups = new Map(); + for (const u of census.undefendedSubtractions ?? []) { + const key = JSON.stringify([u.file, u.receiver, u.verb, u.reason, u.type, u.doorShaped === true]); + groups.set(key, (groups.get(key) ?? 0) + 1); + } + return [...groups.entries()] + .map(([key, count]) => ({ cells: JSON.parse(key), count })) + .sort((a, b) => a.cells[0].localeCompare(b.cells[0]) + || a.cells[1].localeCompare(b.cells[1]) + || a.cells[2].localeCompare(b.cells[2])); +} + +/** A type text in one markdown cell: one line, and no cell-splitting pipe. */ +function cell(text) { + return String(text ?? '').replace(/\s+/g, ' ').trim().replace(/\|/g, '\\|'); +} + +/** + * The undefended-subtraction block, identical in both artefacts apart from + * heading depth. Renders a row per subtraction the census could not defend, and + * says so explicitly when there are none -- an empty section is a measurement, + * while a missing section is indistinguishable from a check that stopped running. + */ +export function renderUndefendedSubtractions(census, heading, { withRows = true } = {}) { + const rows = undefendedRows(census); + const doorShaped = rows.filter((r) => r.cells[5]).reduce((n, r) => n + r.count, 0); + const out = []; + out.push(`${heading} Subtractions the census could NOT defend — enforced`, ''); + out.push('A same-named call on something that is not a data engine is subtracted, and the'); + out.push('subtraction is DEFENSIBLE when this census can name why: the receiver is a `node:`'); + out.push('builtin, a value it watched being constructed, a language global, a type THIS'); + out.push('corpus declares and the door rule rejected, or an `UNTYPED_RECEIVERS` row.'); + out.push(''); + out.push('⚠️ Counted below are the subtractions it can name no such fact for — the'); + out.push('receiver carries a declared type the engine type index does not hold, and that'); + out.push('index is built from TRACKED sources only, deliberately. An untracked, generated'); + out.push('or dependency-owned declaration is one this census never saw, and «never saw it»'); + out.push('must not be spelled the same way as «read it, not an engine».'); + out.push(''); + out.push('| what | count |', '| :--- | ---: |'); + out.push(`| write calls subtracted with no defensible reason | **${rows.reduce((n, r) => n + r.count, 0)}** |`); + out.push(`| …whose declared type text states an engine door anyway | **${doorShaped}** |`); + out.push(''); + if (rows.length === 0) { + out.push('None: every non-engine subtraction in this census rests on a named fact.'); + return out; + } + // ⛔ The ROWS stay off the published page, the same split the site table + // follows: machine output belongs in `docs/audits/`, and a type text carrying + // braces and angle brackets is MDX-hostile besides. The page states the two + // counts and points here. + if (!withRows) { + out.push(`Every one of them is listed, by receiver and by the type text that could not be`); + out.push(`placed, in [\`${COUNTS}\`](https://github.com/objectstack-ai/objectstack/blob/main/${COUNTS}).`); + return out; + } + out.push('| file | receiver | verb | why | declared type | door | n |'); + out.push('|---|---|---|---|---|---|---:|'); + for (const r of rows) { + const [file, receiver, verb, reason, type, door] = r.cells; + out.push(`| \`${file}\` | \`${cell(receiver)}\` | \`${verb}\` | ${reason} | \`${cell(type)}\` | ${door ? '⚠️ yes' : 'no'} | ${r.count} |`); + } + return out; +} + /** * The CORPUS-SCALE numbers: how big the haystack was, not what was found in it. * @@ -1211,6 +1545,8 @@ export function renderGeneratedRegion(census) { out.push(`| object name is an \`object: string\` parameter | ${t.objectNameParameter} |`); out.push(`| object name is some other run-time expression | ${t.objectNameRuntime} |`); out.push(''); + out.push(...renderUndefendedSubtractions(census, '###', { withRows: false })); + out.push(''); out.push(`The corpus walked is every tracked non-test source under \`packages/services/\``); out.push(`and \`packages/plugins/\`; calls to a same-named method on something that is not`); out.push(`a data engine were subtracted. Every site is listed in`); @@ -1280,6 +1616,8 @@ export function renderCountsFile(census) { out.push(`| Threading a decidably non-elevated context | ${t.nonElevatedContext} |`); out.push(`| Threading a context of undecidable elevation | ${t.elevationUndecidable} |`); out.push(''); + out.push(...renderUndefendedSubtractions(census, '##')); + out.push(''); out.push(...renderCorpusScale(census, '##')); out.push(''); out.push('## Every site'); @@ -1489,6 +1827,85 @@ export function selfTest() { + '});\n'), 'sys_user'); + // ── ⭐ THE CRITERION IN BOTH DIRECTIONS: tracked-only, said out loud ─────── + // The index is built from `git ls-files` and nothing else, on purpose (see + // CORPUS_TYPE_DECL above). What that obliges is a DIAGNOSTIC, and a diagnostic + // asserted only in prose is one nothing holds. So both directions are pinned + // here: membership of the tracked corpus is the ONLY difference between the two + // cases in each pair, and the verdicts must differ. + const reasonOf = (type, how, names) => + nonEngineReason({ kind: 'other', type, how }, new Set(names)).reason; + const namesOf = (type, how, names) => + nonEngineReason({ kind: 'other', type, how }, new Set(names)).names.join(','); + + t('⭐ a receiver type the TRACKED corpus declares is one the door rule READ and rejected', + reasonOf('IProbeEngine', 'probe', ['IProbeEngine']), 'corpus-type'); + t('⭐ the SAME receiver type, declared where the tracked enumeration cannot see it, is UNDEFENDED', + reasonOf('IProbeEngine', 'probe', ['SomethingElse']), 'type-not-in-corpus'); + t('⛔ and it is NAMED rather than folded into "not an engine"', + namesOf('IProbeEngine', 'probe', ['SomethingElse']), 'IProbeEngine'); + t('a union naming one unseen type reports that one', + namesOf('IProbeEngine | undefined', 'probe', []), 'IProbeEngine'); + + // The defensible arms, so the diagnostic cannot decay into "everything is + // undefended" -- a pile that flags all 146 subtractions hides the three that + // matter exactly as effectively as flagging none. + t('a `node:` builtin receiver is a NAMED fact, not a type-index miss', + reasonOf('node: builtin crypto', 'node-import', []), 'builtin-import'); + t('a receiver placed by its own `new X` is a named fact', + reasonOf('new Map', 'cache/new', []), 'constructed-locally'); + t('a language global is a named fact -- this corpus cannot declare `Map`', + reasonOf('Map', 'm', []), 'platform-type'); + t('an `import("…").Name` type reads its NAME, not the words in its specifier', + reasonOf("import('./settings.types.js').SecretStore", 'this.secretStore', ['SecretStore']), + 'corpus-type'); + + // An inline type literal has no name for the index to be keyed on, and the + // door rule is keyed on names -- so the census must say "I could not place it" + // rather than "not an engine", and must say when the literal itself declares a + // door. + t('an anonymous type literal is undefended -- there is no name to look up', + reasonOf('{ delete(key: string): void }', 'e/as', []), 'anonymous-type'); + t('⭐ an anonymous literal whose own text declares a write door says so', + String(nonEngineReason({ kind: 'other', type: '{ update(object: string, data: unknown): Promise }', how: 'e/as' }, new Set()).doorShaped), + 'true'); + t('⛔ …and one that declares no door does NOT claim one', + String(nonEngineReason({ kind: 'other', type: '{ delete(key: string): void }', how: 'e/as' }, new Set()).doorShaped), + 'false'); + t('a door named `find` is not a WRITE door', + String(nonEngineReason({ kind: 'other', type: '{ find(object: string): Promise }', how: 'e/as' }, new Set()).doorShaped), + 'false'); + + // ⭐⭐ The whole card in one pair: ONE source text, ONE receiver, and the index + // as the only variable. In the index the site is an ENGINE write; out of the + // index it is a subtraction -- which is what an untracked declaration produces, + // because the index and the corpus name set are the same `git ls-files` + // enumeration. The failure was never that the subtraction happens; it was that + // both arms printed the same thing. + const resolveIn = (indexNames) => { + const src = "declare const e: IProbeEngine;\ne.insert('sys_user', {}, { context: { isSystem: true } });\n"; + const sf = parseSourceFile('selftest.ts', src); + const decls = declaredTypesIn(sf); + const index = new Map(indexNames.map((n) => [n, { decls: ['probe.ts'], verbs: ['insert'] }])); + let out = 'NO-CALL'; + const visit = (node) => { + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) + && WRITE_VERBS.includes(node.expression.name.text)) { + const res = resolveReceiver(node.expression.expression, sf, decls, index); + out = res.kind === 'other' + ? `other/${nonEngineReason(res, new Set()).reason}` + : `${res.kind}/${res.type ?? ''}`; + } + ts.forEachChild(node, visit); + }; + visit(sf); + return out; + }; + t('⭐ a receiver whose type IS in the index is an engine write', + resolveIn(['IProbeEngine']), 'engine/IProbeEngine'); + t('⭐ the same receiver, type NOT in the index, is a subtraction that says WHY', + resolveIn([]), 'other/type-not-in-corpus'); + const failed = cases.filter((c) => !c.ok); for (const c of failed) console.error(` ✗ ${c.name} -- ${c.detail}`); if (failed.length > 0) { @@ -1501,7 +1918,9 @@ export function selfTest() { + 'options argument refusing to answer "carries no context", the ordinary verdicts -- plus ' + 'the declared-object registry in BOTH directions: a file declaring two objects still ' + 'counts two, while `inlineColumns`, validation-rule, action, list-view and index names ' - + 'in the same file count none).', + + 'in the same file count none -- and the TRACKED-ONLY criterion in both directions: one ' + + 'receiver, one source text, and index membership the only variable, reading `engine` in ' + + 'the index and a subtraction that NAMES the unplaceable type out of it).', ); return 0; } @@ -1537,6 +1956,9 @@ function main(argv) { ` threads a context: elevated ${t.elevatedContext} · not elevated ${t.nonElevatedContext} · undecidable ${t.elevationUndecidable}`, ` untyped receivers placed: by object name ${t.placedByObjectName} · by name parameter ${t.placedByObjectNameParameter} · by ledger ${t.placedByLedger}`, ` non-engine calls subtracted ${c.nonEngineCalls} · unresolved receivers ${c.unresolved.length}`, + ` ${Object.entries(c.nonEngineReasons).map(([k, v]) => `${k} ${v}`).join(' · ')}`, + ` ⚠️ subtractions the census could NOT defend ${c.undefendedSubtractions.length}` + + ` · of those, type text states an engine door ${c.undefendedSubtractions.filter((u) => u.doorShaped).length}`, '', ].join('\n')); } @@ -1545,6 +1967,21 @@ function main(argv) { + `receiver type unreadable [${u.how}] and the object name is not a literal declared object. ` + `Add an UNTYPED_RECEIVERS row saying what it is.\n`); } + // ⚠️ A WARNING, deliberately, and the exit code below is deliberately unchanged. + // This class is NOT empty on a clean tree (three sites today, two of them with a + // door signature in their own type text), so refusing here would red `main` for + // findings nobody has ruled on yet -- and a gate that reds on arrival gets + // weakened, which is the opposite of what this card asked for. What the census + // owes is to stop being SILENT: every run now names the receiver and the type it + // could not place, and both artefacts carry the count under enforcement. + for (const u of c.undefendedSubtractions) { + process.stderr.write(`::warning::[receiver-type-not-placed] ${u.file}:${u.line} \`${u.receiver}\`.${u.verb}() -- ` + + `SUBTRACTED from the certified population: its declared type \`${u.type}\` is not in the engine ` + + `type index [${u.reason}${u.names.length > 0 ? `: ${u.names.join(', ')}` : ''}]. The index is built from ` + + `TRACKED sources only -- an untracked, generated or dependency-owned declaration is one this census ` + + `never saw.${u.doorShaped ? ' ⚠️ That type text states an ObjectQL write door, so this subtraction is' + + ' probably WRONG -- the door rule is keyed on named declarations and this type is spelled inline.' : ''}\n`); + } for (const r of c.staleLedgerRows) { process.stderr.write(`::error::[stale-ledger-row] UNTYPED_RECEIVERS names ${r.file} (receiver ` + `\`${r.receiver}\`) but no such write call exists -- delete the row.\n`);