From 871877bfcdd99c3f20cd58081e661bdad0d4880f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 10:54:04 +0000 Subject: [PATCH 1/2] fix(tooling): dispatcher error-vocabulary scan reports lowercase codes and the stamp positions it could not see (#9460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate's published bound said "only SCREAMING_SNAKE literals" and handed lowercase to check:error-code-casing. Half that delegation was real and half was a hole — and the hole is where plugin-security's live 403 `owd_widening_forbidden` sat through two ADR-0112 sweeps, read by both gates and reported by neither. Lowercase is now reported except in the two positions where check:error-code-casing reads the identical characters (`code: 'x'`, `.code = 'x'`), where it carries the D6/D6b/D6c discrimination this gate does not have. Measured first: reporting every lowercase stamp took the scan from 12 sites to 94, and all 82 new findings were D6/D6b/D6c or Zod's own issue codes. Three unseen stamp positions, all widenings: `codehelper` (a factory whose parameter carries the code, so the literal lives at the call site with no `code` token beside it — the parameter INDEX is derived, not assumed zero), `assignconst` (the assign position's constant sibling, the gap #9223 closed for object literals), and `assign` with a cast on the left. 12 -> 18 classified sites; 0 -> 2 awaiting a ledger entry. No allowlist, no narrowed pattern, no raised ceiling. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_012WKSnqAaoqtW3QX7SSf1Vk --- ...-vocabulary-lowercase-and-helper-shapes.md | 56 +++ .../src/dispatcher-error-vocabulary.ts | 96 ++++ scripts/check-dispatcher-error-vocabulary.mjs | 438 +++++++++++++++++- 3 files changed, 573 insertions(+), 17 deletions(-) create mode 100644 .changeset/dispatcher-error-vocabulary-lowercase-and-helper-shapes.md diff --git a/.changeset/dispatcher-error-vocabulary-lowercase-and-helper-shapes.md b/.changeset/dispatcher-error-vocabulary-lowercase-and-helper-shapes.md new file mode 100644 index 0000000000..644b5b6f66 --- /dev/null +++ b/.changeset/dispatcher-error-vocabulary-lowercase-and-helper-shapes.md @@ -0,0 +1,56 @@ +--- +"@objectstack/runtime": patch +--- + +fix(tooling): `check:dispatcher-error-vocabulary` reports lowercase codes and the two stamp positions it could not see — a live 403 had been invisible to both vocabulary gates (#9460) + +The gate's published bound said it scanned "only SCREAMING_SNAKE literals", and +handed lowercase to `check:error-code-casing`. Half of that delegation was real +and half was a hole, and the hole is where `plugin-security`'s live 403 +`owd_widening_forbidden` sat through two ADR-0112 sweeps: both gates read the +file, both reported nothing, each leaving it to the other. + +**The card's premise was that the scan's patterns are case-sensitive. They are +not** — the literal shapes already matched `[A-Za-z]`. Two explicit filters +dropped the value after the match, and the producer was invisible for a +different reason entirely, so the prescribed one-line widening would not have +found it. Measured before changing anything: reporting every lowercase stamp +took the scan from 12 sites to 94, and **all 82 new findings were D6/D6b/D6c +neighbours or Zod's own issue codes** — it would have called +`ctx.addIssue({ code: 'custom' })` an unregistered ObjectStack error code. + +So lowercase is now reported **except** in the two positions where +`check:error-code-casing` reads the identical characters (`code: 'x'`, +`.code = 'x'`). There the delegation is genuine: that gate carries the +D6/D6b/D6c discrimination this one does not have. Everywhere else — a constant, +a template, a helper parameter — there is no quoted literal at the stamp site +for a `code`-anchored pattern to match, that gate is structurally blind, and +dropping the value reported it to nobody. What is measured is now "outside the +vocabulary **and** unowned by the gate we delegate lowercase to", never "is it +SCREAMING_SNAKE". + +Three stamp positions the scan could not see, all of them widenings: + +- **`codehelper`** — a file declares one factory and throws through it + everywhere (`postureError(code, message)`, `makeError(status, code, message)`, + a `constructor(code, message)`). The stamp `(err as any).code = code` knows + the token `code` but not the value; the call site knows the value and never + writes the token. Every pattern in **both** gates anchors on that token. The + join is the parameter, so its **index** names the argument to read — derived, + never assumed to be zero, because two live helpers put `code` second and a + first-argument rule reads a number and an English sentence as error codes. +- **`assignconst`** — `err.code = DENY_CODE`, the assign position's constant + sibling. #9223 closed exactly this gap for object literals; the assign + position kept it. +- **`assign`** with a cast on the left. The old anchor demanded a bare + identifier where `(err as any).code = 'X'` puts a `)`. + +The scan goes from 12 classified sites to 18, and from 0 to 2 codes awaiting a +ledger entry — the ratchet moving in the direction it exists to move. +`FLOW_CONVERSION_CONFLICT` (a live 409 from the metadata write path) and +`owd_widening_forbidden` are recorded as `pending-registration`; four +`MigrationJournalRefusal` codes are `boot-refusal` (their only consumers are two +CLI commands, no HTTP boundary). ⛔ No allowlist entry, no narrowed pattern, no +raised ceiling: **registering or renaming a code stays the `packages/spec` +lane's call**, and these rows record the measurement rather than prescribing the +remedy. diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 77126457de..07bd446e35 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -334,7 +334,51 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ why: 'Same driver errno, same migration sample, carried in the registry index.', }, + // ── pending registration [#9460]: found by the widened scan ──────────── + // Both rows are the deliverable of #9460, not a regression: the scan could + // not SEE either site before it learned the two stamp positions below, so + // "no finding" meant "not looked at", which is the failure this gate + // exists to prevent. + { + code: 'FLOW_CONVERSION_CONFLICT', + file: 'packages/metadata-protocol/src/protocol.ts', + shape: 'assign', + door: 'rest', + verdict: 'pending-registration', + why: + 'A live 409 from the metadata write path: the conversion pass refuses a body whose token is a ' + + "live name in the environment, and stamps `(err as any).code = 'FLOW_CONVERSION_CONFLICT'` " + + 'beside `status = 409` before throwing. [#9460] The `assign` shape demanded a BARE identifier ' + + 'to the left of `.code`, and this site writes a cast — `(err as any)` puts a `)` exactly where ' + + 'the anchor wanted a word character — so the single most common way this repo stamps a code ' + + 'onto a constructed error was invisible in the shape named for it. Registering it is the ' + + "`packages/spec` lane's call (#8846's batch); this row records the measurement.", + }, + { + code: 'owd_widening_forbidden', + file: 'packages/plugins/plugin-security/src/object-posture-gate.ts', + shape: 'codehelper', + door: 'rest', + verdict: 'pending-registration', + why: + 'The ADR-0090 D7 / ADR-0086 D1 refusal: an environment overlay may only TIGHTEN a packaged ' + + "object's OWD. It reaches the wire verbatim — `packages/rest/src/meta-object-owd-gate.test.ts` " + + 'drives `PUT /api/v1/meta/object/:name` and reads a 403 body whose `code` is this string. ' + + '[#9460] Invisible to BOTH vocabulary gates until now, and not for its casing: the file throws ' + + 'through a code-carrying helper (`postureError(code, message)`), so the stamp `(err as any).code ' + + '= code` knows the token `code` but not the value, while the call site knows the value and never ' + + 'writes the token. Every pattern in this gate and in `check:error-code-casing` anchors on that ' + + 'token, so both read the file and both reported nothing. ⚠️ The spelling is LOWERCASE, so ' + + 'ADR-0112 D1 forbids registering it as spelled — the rename-or-keep-the-#9106-demote call is ' + + "the `packages/spec` lane's, tracked as #9460 half (2) and NOT decided here. The row records " + + 'that a live wire code is outside the vocabulary; it does not prescribe the remedy.', + }, + // ── boot refusals: no HTTP boundary exists yet ───────────────────────── + // [#9460] The four `MigrationJournalRefusal` codes below arrive through the + // same code-carrying-helper shape as `owd_widening_forbidden` — a class + // constructor `(code, message)` whose `this.code = code` names the token but + // not the value — and land on the OTHER side of the reachability question. { code: 'MONGODB_MULTI_TENANT_UNSUPPORTED', file: 'packages/drivers/driver-mongodb/src/mongodb-tenancy-guard.ts', @@ -356,6 +400,58 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ 'The driver-memory twin of the row above — same guard shape, same MULTI_TENANT_UNSUPPORTED_CODE ' + 'constant name, same pre-HTTP abort. Ruled by the same #8035 reasoning.', }, + { + code: 'NO_SUCH_RUN', + file: 'packages/core/src/utils/migration-journal.ts', + shape: 'codehelper', + door: 'none', + verdict: 'boot-refusal', + why: + 'Raised by `MigrationJournalRefusal` when no journal rows exist for the requested run id. ' + + 'Its only consumers are `packages/cli/src/commands/migrate/resume.ts` and `recorded-by.ts`, ' + + 'which catch it with `instanceof` and print a message — no HTTP boundary exists on that path, ' + + 'and grep finds no other consumer in `packages/`. Same class as the two rows above and ruled ' + + 'by the same #8035 reasoning: a runner refusal the CLI rethrows is not wire vocabulary.', + }, + { + code: 'NOT_COMPENSABLE', + file: 'packages/core/src/utils/migration-journal.ts', + shape: 'codehelper', + door: 'none', + verdict: 'boot-refusal', + why: + 'Raised by `MigrationJournalRefusal` when a chunk cannot be compensated, so the runner refuses to resume. ' + + 'Its only consumers are `packages/cli/src/commands/migrate/resume.ts` and `recorded-by.ts`, ' + + 'which catch it with `instanceof` and print a message — no HTTP boundary exists on that path, ' + + 'and grep finds no other consumer in `packages/`. Same class as the two rows above and ruled ' + + 'by the same #8035 reasoning: a runner refusal the CLI rethrows is not wire vocabulary.', + }, + { + code: 'PLAN_CHANGED', + file: 'packages/core/src/utils/migration-journal.ts', + shape: 'codehelper', + door: 'none', + verdict: 'boot-refusal', + why: + 'Raised by `MigrationJournalRefusal` when the plan hash moved under a recorded run. ' + + 'Its only consumers are `packages/cli/src/commands/migrate/resume.ts` and `recorded-by.ts`, ' + + 'which catch it with `instanceof` and print a message — no HTTP boundary exists on that path, ' + + 'and grep finds no other consumer in `packages/`. Same class as the two rows above and ruled ' + + 'by the same #8035 reasoning: a runner refusal the CLI rethrows is not wire vocabulary.', + }, + { + code: 'PREFLIGHT_FAILED', + file: 'packages/core/src/utils/migration-journal.ts', + shape: 'codehelper', + door: 'none', + verdict: 'boot-refusal', + why: + 'Raised by `MigrationJournalRefusal` when the pre-resume checks refuse to start. ' + + 'Its only consumers are `packages/cli/src/commands/migrate/resume.ts` and `recorded-by.ts`, ' + + 'which catch it with `instanceof` and print a message — no HTTP boundary exists on that path, ' + + 'and grep finds no other consumer in `packages/`. Same class as the two rows above and ruled ' + + 'by the same #8035 reasoning: a runner refusal the CLI rethrows is not wire vocabulary.', + }, ]; /** diff --git a/scripts/check-dispatcher-error-vocabulary.mjs b/scripts/check-dispatcher-error-vocabulary.mjs index abfdd9ff2f..fd2057cd05 100644 --- a/scripts/check-dispatcher-error-vocabulary.mjs +++ b/scripts/check-dispatcher-error-vocabulary.mjs @@ -58,7 +58,7 @@ * string literal in one of a handful of syntactic positions, and the positions * are more varied than an AST shape for "the value of an error's code property" * would cover (class field, constructor-side object literal, post-hoc - * assignment, a constant one module over). The six shapes below are published + * assignment, a constant one module over). The eight shapes below are published * rather than left inside the implementation, and `--self-test` pins each one — * because the price of a source scan is that it sees only the spellings it * knows, and an unrecognised one produces no finding, SILENTLY. Reaching for a @@ -82,8 +82,40 @@ * (the ledger's own rule, and #4984's phantom-check family). * - Reported: only codes the registered vocabulary does NOT contain. A * registered code is by construction parseable, whatever door it reaches. - * - Only SCREAMING_SNAKE literals. Lowercase code literals are a different - * defect with its own gate (`check:error-code-casing`, ADR-0112 D6/D6b/D6c). + * - [#9460] Lowercase and mixed-case codes are REPORTED, except in the two + * positions where a better-equipped gate already sees the identical text. + * ADR-0112 D1 rules the value space `^[A-Z][A-Z0-9_]*$`, so a lowercase + * code is outside the vocabulary by definition and the only real question + * is WHICH gate reports it. `check:error-code-casing` owns the lowercase + * sweep, and every pattern it has needs a QUOTED lowercase literal beside + * the token `code` — `code: 'x'`, `.code = 'x'`, `code === 'x'`, + * `code?: 'x' | 'y'`. In those positions (`objlit`, `assign`) the + * delegation is real: that gate reads the same characters and carries the + * D6/D6b/D6c discrimination — field-addressed catalogs, persisted audit + * columns, diagnostics payloads, Zod's own issue codes — that decides which + * lowercase literals are legitimate. Reporting them here would duplicate a + * gate that answers better and would call Zod's `code: 'custom'` an + * unregistered ObjectStack error code, which is false. Measured, not + * assumed: reporting every lowercase stamp took this gate from 12 sites to + * 94, and 82 of the 82 new findings were D6/D6b/D6c or Zod. + * + * Everywhere else the delegation was a HOLE, not a hand-off. A code + * arriving through a constant, a template, or a helper parameter has no + * quoted literal at the stamp site, so `check:error-code-casing` is + * structurally blind to it — and this gate dropping it for its casing meant + * NOBODY reported it. Two gates, each assuming the other. `plugin-security` + * threw a live 403 `owd_widening_forbidden` through both for two vocabulary + * batches. Those shapes carry `lowercase: 'here'` and are reported. + * + * So what is measured is "outside the vocabulary AND unowned by the gate we + * delegate lowercase to", never "is it SCREAMING_SNAKE". + * - [#9460] A code assembled from RUNTIME values in a local variable + * (`const code = readFrom(x); err.code = code`) is out of reach for the + * same reason an interpolated template is, and — unlike a template, which + * at least has a stable family identity — has nothing to report under. It + * contributes no site. `packages/metadata-protocol/src/sys-metadata-repository.ts` + * is the live example worth closing next: `const code = intent === 'x' ? 'A' : 'B'` + * is a local TERNARY OF LITERALS, which a resolver could reduce. * - A constant this gate cannot resolve is REPORTED as unresolved, never * dropped: a deriver that goes quietly blind is the same failure one layer * down. [#9223] A constant imported from a WORKSPACE package is resolved @@ -156,27 +188,55 @@ export function parseStandardCodes(source) { */ export const SHAPES = [ // `err.code = 'X'` — stamped onto a value about to be thrown. - { name: 'assign', re: /(?:^|[^\w.$])[\w$]+\.code\s*=\s*'([A-Za-z][A-Za-z0-9_]*)'/g, resolve: 'literal' }, + // [#9460] The left-hand side is no longer required to be a BARE identifier. + // The old anchor (`[^\w.$][\w$]+\.code`) demanded a word character where + // `(err as any).code = 'X'` puts a `)`, so the single most common way this + // repo stamps a code onto a caught-or-constructed value — through a cast — + // was invisible in the very shape named for it. + { name: 'assign', re: /\.code\s*=\s*'([A-Za-z][A-Za-z0-9_]*)'/g, resolve: 'literal', lowercase: 'casing-gate' }, // `readonly code = 'X'` / `readonly code: T = 'X'` — an error class's identity. - { name: 'classfield', re: /\breadonly\s+code\s*(?::[^=;\n]+)?=\s*'([A-Za-z][A-Za-z0-9_]*)'/g, resolve: 'literal' }, + { name: 'classfield', re: /\breadonly\s+code\s*(?::[^=;\n]+)?=\s*'([A-Za-z][A-Za-z0-9_]*)'/g, resolve: 'literal', lowercase: 'here' }, // `readonly code = CONST` — the same, one indirection away. - { name: 'classconst', re: /\breadonly\s+code\s*(?::[^=;\n]+)?=\s*([A-Z][A-Z0-9_]*)\s*[;,\n]/g, resolve: 'constant' }, + { name: 'classconst', re: /\breadonly\s+code\s*(?::[^=;\n]+)?=\s*([A-Z][A-Z0-9_]*)\s*[;,\n]/g, resolve: 'constant', lowercase: 'here' }, // `code: 'X'` in an object literal (constructor options, Object.assign, a // returned envelope). The broadest shape, and the reason verdicts exist: // plenty of these are not wire codes at all. - { name: 'objlit', re: /\bcode:\s*'([A-Za-z][A-Za-z0-9_]*)'/g, resolve: 'literal' }, + { name: 'objlit', re: /\bcode:\s*'([A-Za-z][A-Za-z0-9_]*)'/g, resolve: 'literal', lowercase: 'casing-gate' }, // [#9223] `code: CONST` in an object literal — the SAME indirection // `classconst` already follows, in the shape that stamps most of this repo's // codes. Left out of the original four, it was the gate's own blind spot: // `objlit` required a quoted literal, so a constant in an object literal // matched nothing at all and was never even reported as unresolved. - { name: 'objlitconst', re: /\bcode:\s*([A-Z][A-Z0-9_]*)\s*[,;}\n]/g, resolve: 'constant' }, + { name: 'objlitconst', re: /\bcode:\s*([A-Z][A-Z0-9_]*)\s*[,;}\n]/g, resolve: 'constant', lowercase: 'here' }, // [#9223] A template-literal `code:`. Evaluating one needs the RUNTIME values // of its interpolations, which a source scan does not have — so it is // reported as unresolved, which is what the header's bound requires of any // value this gate cannot reduce to a literal. A template with no // interpolation is just a literal wearing backticks and is treated as one. - { name: 'objlittemplate', re: /\bcode:\s*`([^`]*)`/g, resolve: 'template' }, + { name: 'objlittemplate', re: /\bcode:\s*`([^`]*)`/g, resolve: 'template', lowercase: 'here' }, + // [#9460] `x.code = ident` — the assign position's INDIRECT sibling, and the + // last of the four stamp positions to get one. `objlitconst` (#9223) closed + // this exact gap for object literals; the assign position kept it, so + // `err.code = DENY_CODE` and `err.code = code` both matched nothing at all. + // + // Two different indirections arrive through one regex, so it emits two shape + // names rather than pretending they are one thing: + // - `assignconst` — the identifier is a module constant `resolveConstant` + // can reduce to a literal, exactly as in the object-literal position. + // - `codehelper` — the identifier is a PARAMETER of the enclosing function + // or constructor, which makes that function a code-carrying helper + // (`postureError(code, message)`, `makeError(status, code, message)`). + // The literal then lives at the CALL SITES, at that parameter's index, + // and nowhere near a `code` token — so no `code`-anchored pattern in + // either this gate or `check:error-code-casing` can see it. See + // `helperCodesFor`. + { name: 'assignconst', re: /\.code\s*=\s*([A-Z][A-Z0-9_]*)\s*[;,\n)]/g, resolve: 'constant', lowercase: 'here' }, + // [#9460] `x.code = param` inside a CODE-CARRYING HELPER — the one stamp + // position whose literal is nowhere near the token `code`. Structural, not + // textual: it fires only when the assigned identifier is a PARAMETER of the + // enclosing function or constructor, which is what makes that declaration a + // helper and its call sites the place the codes live. See `helperCodesFor`. + { name: 'codehelper', re: /\.code\s*=\s*([A-Za-z_$][\w$]*)\s*[;,\n)]/g, resolve: 'helper', lowercase: 'here' }, ]; const isTestFile = (rel) => @@ -332,6 +392,166 @@ function resolveFromWorkspacePackage(name, spec, { scanned, packageDirs } = {}) return values.size === 1 ? [...values][0] : null; } +/** + * [#9460] The code-carrying-helper resolver. + * + * ## The shape, and why no `code`-anchored pattern can see it + * + * A file declares one small factory and throws through it everywhere: + * + * function postureError(code: string, message: string): Error { + * const err = new Error(`[${code}] ${message}`); + * (err as any).code = code; // ← the stamp + * return err; + * } + * throw postureError('owd_widening_forbidden', '…'); // ← the value + * + * The stamp knows the token `code` but not the value; the call site knows the + * value but never writes the token `code`. Every pattern in this gate AND every + * pattern in `check:error-code-casing` anchors on the token — so both gates + * read this file and both report nothing, each leaving it to the other. That is + * how a live 403 refusal carrying a lowercase code sat unswept through two + * vocabulary batches: not a missing table row, and not a case-sensitive regex, + * but a stamp position with no `code` token next to its literal. + * + * The join is the PARAMETER: the assigned identifier is one of the enclosing + * declaration's parameters, so its INDEX names the argument to read at each + * call site. The index is derived, never assumed to be zero — + * `makeError(status, code, message)` and `exposureError(message, code, status)` + * both put it second, and a first-argument rule would have read a number and an + * English sentence as error codes. + * + * Bounds, stated because a resolver that goes quietly blind is this gate's own + * failure mode one layer down: + * - IN-FILE call sites only. These factories are file-local by construction + * (none is exported); an exported one resolves to no literals and is + * REPORTED as unresolved rather than passed. + * - An argument is read as a quoted literal, or as a CONSTANT put through the + * same `resolveConstant` the other indirect shapes use — the MCP bridge + * throws `exposureError(msg, OBJECT_API_DISABLED, 404)`, so a literals-only + * rule would have found the helper, seen no literal, and reported an + * unresolvable it could in fact resolve. An argument that reduces to + * neither contributes no site, and a helper that yields NOTHING at all is + * reported, never dropped. + */ + +/** The substring inside the parentheses opening at `open`, brackets balanced. */ +export function sliceBalanced(src, open) { + let depth = 0; + for (let i = open; i < src.length; i += 1) { + const c = src[i]; + if (c === '(' || c === '[' || c === '{') depth += 1; + else if (c === ')' || c === ']' || c === '}') { + depth -= 1; + if (depth === 0) return src.slice(open + 1, i); + } else if (c === "'" || c === '"' || c === '`') { + const quote = c; + i += 1; + while (i < src.length && src[i] !== quote) i += src[i] === '\\' ? 2 : 1; + } + } + return null; +} + +/** Split on TOP-LEVEL commas — a nested call, generic or template keeps its own. */ +export function splitTopLevel(args) { + const out = []; + let depth = 0; + let start = 0; + for (let i = 0; i < args.length; i += 1) { + const c = args[i]; + if (c === '(' || c === '[' || c === '{' || c === '<') depth += 1; + else if (c === ')' || c === ']' || c === '}' || c === '>') depth -= 1; + else if (c === "'" || c === '"' || c === '`') { + const quote = c; + i += 1; + while (i < args.length && args[i] !== quote) i += args[i] === '\\' ? 2 : 1; + } else if (c === ',' && depth === 0) { + out.push(args.slice(start, i)); + start = i + 1; + } + } + out.push(args.slice(start)); + return out; +} + +/** Parameter NAMES, in order: `readonly a: T = x` → `a`. */ +export function parseParamNames(params) { + if (!params.trim()) return []; + return splitTopLevel(params).map((raw) => { + const cleaned = raw.replace(/^\s*(?:readonly|public|private|protected|\.\.\.)\s+/g, '').trim(); + const m = /^([A-Za-z_$][\w$]*)/.exec(cleaned.replace(/^\.\.\./, '')); + return m ? m[1] : ''; + }); +} + +/** Declaration headers that own a parameter list, with the offset of their `(`. */ +const DECL_HEADER_RE = + /\bfunction\s+([A-Za-z_$][\w$]*)\s*\(|\bconstructor\s*\(|\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=\s*(?:async\s+)?(?:function\s*[A-Za-z_$][\w$]*\s*)?\(/g; + +/** + * The declaration enclosing `offset`: `{ name, params, isConstructor }`, or + * `null`. Nearest-preceding-header, which is what a textual scan can honestly + * offer — a helper is a handful of lines and its stamp sits inside it. + */ +export function enclosingDeclaration(src, offset) { + DECL_HEADER_RE.lastIndex = 0; + let best = null; + for (const m of src.matchAll(DECL_HEADER_RE)) { + if (m.index >= offset) break; + const open = m.index + m[0].length - 1; + const params = sliceBalanced(src, open); + if (params === null) continue; + const isConstructor = /\bconstructor\s*\($/.test(m[0]); + let name = m[1] ?? m[2] ?? null; + if (isConstructor) { + const before = src.slice(0, m.index); + // The LAST class declared before this constructor, not the first — a + // greedy `[\s\S]*$` anchor answers with the file's first class and + // silently mis-names every helper in a multi-class file. + let cls = null; + for (const c of before.matchAll(/\bclass\s+([A-Za-z_$][\w$]*)/g)) cls = c[1]; + name = cls; + } + if (!name) continue; + best = { name, params, isConstructor, end: open + params.length + 2 }; + } + return best; +} + +/** + * Literal codes a code-carrying helper is called with, or `null` when the + * assigned identifier is not one of the enclosing declaration's parameters + * (so the site is an ordinary unresolvable constant, reported as such). + */ +export function helperCodesFor(ident, offset, src, resolveIdent = () => null) { + const decl = enclosingDeclaration(src, offset); + if (!decl) return null; + const index = parseParamNames(decl.params).indexOf(ident); + if (index < 0) return null; + + const callRe = decl.isConstructor + ? new RegExp(`\\bnew\\s+${decl.name}\\s*\\(`, 'g') + : new RegExp(`(?:^|[^\\w.$])${decl.name}\\s*\\(`, 'g'); + const codes = new Set(); + for (const call of src.matchAll(callRe)) { + const open = call.index + call[0].length - 1; + // The declaration's own header is not a call to itself. + if (/\b(?:function|const|let|var)\s*$/.test(src.slice(Math.max(0, call.index - 12), call.index + call[0].length - decl.name.length - 1))) continue; + const args = sliceBalanced(src, open); + if (args === null) continue; + const parts = splitTopLevel(args); + const arg = (parts[index] ?? '').trim(); + const lit = /^'([A-Za-z][A-Za-z0-9_]*)'$/.exec(arg); + if (lit) { codes.add(lit[1]); continue; } + if (/^[A-Za-z_$][\w$]*$/.test(arg)) { + const viaConst = resolveIdent(arg); + if (viaConst) codes.add(viaConst); + } + } + return [...codes]; +} + /** * One entry per (file, shape, value). The same constant stamped six times in * one file is one unresolved value to go and fix, not six lines of noise — @@ -354,11 +574,51 @@ export function deriveSites({ registered, files, readFile, packageDirs = new Map // comment naming a constant must not resolve one either. const scanned = files.map(({ rel, source }) => ({ rel, stripped: maskComments(source) })); const ctx = { scanned, packageDirs }; + + /** + * [#9460] Is this value this gate's to report? + * + * A SCREAMING_SNAKE code always is. A lowercase or mixed-case one is outside + * the ADR-0112 value space (D1) by definition, so the only question is WHICH + * gate reports it — and the answer is the one that can actually see it. + * + * `check:error-code-casing` owns the lowercase sweep, and every pattern it + * has requires a QUOTED lowercase literal sitting next to the token `code`: + * `code: 'x'`, `.code = 'x'`, `code === 'x'`, `code?: 'x' | 'y'`. Where this + * gate finds a lowercase code in one of those same positions — `objlit`, + * `assign` — the delegation is real: that gate sees the identical text, and + * it carries the D6/D6b/D6c discrimination (field-addressed catalogs, + * persisted audit columns, diagnostics payloads, Zod's own issue codes) that + * decides which lowercase literals are legitimate. Reporting those here would + * duplicate a gate that answers better, and would call Zod's `code: 'custom'` + * an unregistered ObjectStack error code, which is simply false. + * + * But a code that arrives through a CONSTANT, a TEMPLATE, or a HELPER + * PARAMETER has no quoted literal at the stamp site for a `code`-anchored + * pattern to match, so that gate is structurally blind to it — and this gate + * dropping it for its casing meant NOBODY reported it. That is not a + * delegation; it is a hole between two gates, each assuming the other. Those + * shapes are marked `lowercase: 'here'` and are reported. + * + * So the measured question is "is this code outside the vocabulary and unowned + * by the gate we hand lowercase to", never "is it SCREAMING_SNAKE". + */ + const keep = (value, shapeName, isFamily = false) => { + const screaming = isFamily ? /^[A-Z*][A-Z0-9_*]*$/ : /^[A-Z][A-Z0-9_]*$/; + if (!screaming.test(value)) { + const shape = SHAPES.find((sh) => sh.name === shapeName); + const owner = shapeName === 'codehelper' ? 'here' : shape?.lowercase ?? 'here'; + if (owner === 'casing-gate') return false; + } + return !registered.has(value); + }; + for (const { rel, stripped } of scanned) { for (const shape of SHAPES) { shape.re.lastIndex = 0; for (const m of stripped.matchAll(shape.re)) { let code = m[1]; + let emitAs = shape.name; if (shape.resolve === 'constant') { const value = resolveConstant(code, stripped, rel, readFile, ctx); if (value === null) { @@ -366,21 +626,44 @@ export function deriveSites({ registered, files, readFile, packageDirs = new Map continue; } code = value; + } else if (shape.resolve === 'helper') { + // [#9460] Structural: the identifier must be a PARAMETER of the + // enclosing declaration. Anything else — a module constant, a local + // holding a runtime value — is not this shape, and `null` here means + // "not a helper", not "a code I dropped": the constant case is + // `assignconst`'s, and the runtime case is a declared bound above. + const helperCodes = helperCodesFor(code, m.index, stripped, (arg) => + resolveConstant(arg, stripped, rel, readFile, ctx), + ); + if (helperCodes === null) continue; + if (helperCodes.length === 0) { + // A helper this scan CAN see but whose callers all pass variables. + // Reported, never dropped — the bound this gate states for every + // value it cannot reduce to a literal. + addUnresolved(unresolved, { file: rel, shape: shape.name, value: code, reason: 'helper' }); + continue; + } + for (const hc of helperCodes) { + if (!keep(hc, shape.name)) continue; + if (sites.some((x) => x.code === hc && x.file === rel && x.shape === shape.name)) continue; + sites.push({ code: hc, file: rel, shape: shape.name }); + } + continue; } else if (shape.resolve === 'template' && !/^[A-Za-z][A-Za-z0-9_]*$/.test(code)) { // Interpolated: no literal exists to check against the registry. It // becomes a site under its FAMILY identity (`${…}` → `*`) rather than // being dropped — see `templateFamily`. code = templateFamily(code); - if (!/^[A-Z*][A-Z0-9_*]*$/.test(code)) continue; // lowercase → check:error-code-casing + if (!/^[A-Za-z*][A-Za-z0-9_*]*$/.test(code)) continue; + if (!keep(code, shape.name, true)) continue; if (!sites.some((s) => s.code === code && s.file === rel && s.shape === shape.name)) { sites.push({ code, file: rel, shape: shape.name }); } continue; } - if (!/^[A-Z][A-Z0-9_]*$/.test(code)) continue; // lowercase → check:error-code-casing - if (registered.has(code)) continue; - if (sites.some((s) => s.code === code && s.file === rel && s.shape === shape.name)) continue; - sites.push({ code, file: rel, shape: shape.name }); + if (!keep(code, emitAs)) continue; + if (sites.some((s) => s.code === code && s.file === rel && s.shape === emitAs)) continue; + sites.push({ code, file: rel, shape: emitAs }); } } } @@ -634,6 +917,12 @@ function selfTest() { // [#9223] the two shapes that used to match nothing at all. objlitconst: `const OBJ_CONST = 'OBJ_CONST_ONE';\nthrow Object.assign(new Error('x'), { code: OBJ_CONST });`, objlittemplate: 'send(res, { code: `TEMPLATE_${action.toUpperCase()}_FAILED`, status: 500 });', + // [#9460] the two shapes in the ASSIGN position that used to match nothing. + assignconst: `const ASSIGN_CONST = 'ASSIGN_CONST_ONE';\nconst err = new Error('x'); err.code = ASSIGN_CONST;`, + codehelper: + `function fail(code: string, msg: string): Error {\n` + + ` const e = new Error(msg);\n (e as any).code = code;\n return e;\n}\n` + + `throw fail('HELPER_ONE', 'x');`, }; const registered = new Set(['ALREADY_REGISTERED']); for (const [name, source] of Object.entries(samples)) { @@ -763,12 +1052,127 @@ function selfTest() { 'a registered code in backticks was still reported', ); - // Lowercase stays with check:error-code-casing, in both new shapes. - ok(one('a({ code: `lower_${x}_thing` });').sites.length === 0, 'a lowercase template family was claimed by this gate'); + // [#9460] A lowercase TEMPLATE family is now claimed here, and that flip is + // the point: `check:error-code-casing` has no pattern for a backtick, so + // leaving it "to that gate" left it to nobody. + ok( + one('a({ code: `lower_${x}_thing` });').sites.some((s) => s.code === 'lower_*_thing'), + 'a lowercase template family was still delegated to a gate that cannot see a backtick', + ); + // `objlitconst` reads SCREAMING constant NAMES only, so a lowercase-named + // one is not this shape at all — unchanged by #9460, and stated so the + // bound is not mistaken for the casing rule above. ok( one(`const lc = 'lower_thing';\na({ code: lc });`).sites.length === 0, - 'a lowercase-named constant was claimed by this gate', + 'a lowercase-NAMED constant was read as an objlitconst', + ); + } + + // [#9460] The lowercase ownership rule, in BOTH directions — the whole point + // of the widening, so a regression must fail here rather than go quiet. + { + const one = (source) => + deriveSites({ registered, files: [{ rel: 'packages/x/src/a.ts', source }], readFile: () => '' }); + + // Delegated: `check:error-code-casing` reads these exact characters and + // carries the D6/D6b/D6c discrimination this gate does not have. + ok(one(`err.code = 'lowercase_thing';`).sites.length === 0, 'a quoted lowercase assign was claimed by this gate'); + ok(one(`f({ code: 'lowercase_thing' });`).sites.length === 0, 'a quoted lowercase objlit was claimed by this gate'); + ok(one(`ctx.addIssue({ code: 'custom' });`).sites.length === 0, "Zod's own `custom` was called an ObjectStack code"); + + // Owned here: no quoted literal sits at the stamp site, so that gate is + // structurally blind and dropping it reported the code to NOBODY. + ok( + one(`const LC = 'lower_const';\nclass E { readonly code = LC; }`).sites.some((s) => s.code === 'lower_const'), + 'a lowercase value reached through a constant was delegated to a gate that cannot see it', + ); + ok( + one(`class E extends Error { readonly code = 'lower_field'; }`).sites.some((s) => s.code === 'lower_field'), + 'a lowercase classfield was delegated — that gate has no pattern for `readonly code =`', + ); + + // The card's own producer, reduced to its shape: the stamp knows the token + // `code` and not the value; the call site knows the value and never writes + // the token. Both gates read it and both reported nothing. + const posture = + `function postureError(code: string, message: string): Error {\n` + + ` const err = new Error(message);\n (err as any).code = code;\n return err;\n}\n` + + `throw postureError('owd_widening_forbidden', 'x');`; + ok( + one(posture).sites.some((s) => s.code === 'owd_widening_forbidden' && s.shape === 'codehelper'), + 'the code-carrying-helper producer this widening exists for was not reported', + ); + } + + // [#9460] The helper resolver's own machinery. + { + const one = (source) => + deriveSites({ registered, files: [{ rel: 'packages/x/src/a.ts', source }], readFile: () => '' }); + + // The parameter INDEX is derived, never assumed to be zero: two live + // helpers put `code` second, and a first-argument rule reads a number and + // an English sentence as error codes. + const second = + `function makeError(status: number, code: string, message: string): Error {\n` + + ` const err = new Error(message);\n err.code = code;\n return err;\n}\n` + + `throw makeError(422, 'SECOND_ARG_ONE', 'x');`; + const secondSites = one(second); + ok(secondSites.sites.some((s) => s.code === 'SECOND_ARG_ONE'), 'the helper read the wrong argument index'); + ok(!secondSites.sites.some((s) => s.code === '422'), 'the helper read argument zero regardless of the parameter'); + + // A CONSTRUCTOR is a helper too, and its call sites are `new Class(...)`. + const ctor = + `class Refusal extends Error {\n readonly code: string;\n` + + ` constructor(code: string, message: string) {\n super(message);\n this.code = code;\n }\n}\n` + + `throw new Refusal('CTOR_ONE', 'x');`; + ok(one(ctor).sites.some((s) => s.code === 'CTOR_ONE'), 'a code-carrying constructor was not resolved'); + + // …and it must name the ENCLOSING class, not the file's first one. + const twoClasses = `class Unrelated { constructor(x: string) {} }\n${ctor}`; + ok(one(twoClasses).sites.some((s) => s.code === 'CTOR_ONE'), 'the constructor resolved against the wrong class'); + + // An argument that is a CONSTANT resolves through the same machinery the + // indirect shapes use — the MCP bridge throws `exposureError(msg, CONST, 404)`. + const viaConst = + `const BRIDGE_CODE = 'VIA_CONST_ONE';\n` + + `function boom(message: string, code: string): Error {\n` + + ` const e = new Error(message);\n e.code = code;\n return e;\n}\n` + + `throw boom('x', BRIDGE_CODE);`; + ok(one(viaConst).sites.some((s) => s.code === 'VIA_CONST_ONE'), 'a constant argument to a helper did not resolve'); + + // A helper this scan CAN see but whose callers all pass runtime values is + // REPORTED, never dropped — the bound the header states. + const opaque = + `function boom(code: string, message: string): Error {\n` + + ` const e = new Error(message);\n e.code = code;\n return e;\n}\n` + + `throw boom(pickCode(), 'x');`; + ok(one(opaque).unresolved.length === 1, 'a helper with no resolvable argument produced no finding'); + + // An identifier that is NOT a parameter is not this shape — it is either + // `assignconst`'s (a module constant) or a runtime value, and calling it a + // helper would invent a call site that does not exist. + ok( + one(`for (const rec of rows) { slot.code = rec; }`).sites.length === 0, + 'a plain domain field named `code` was read as a code stamp', + ); + ok( + one(`for (const rec of rows) { slot.code = rec; }`).unresolved.length === 0, + 'a plain domain field named `code` was reported as an unresolvable code', + ); + + // The declaration's own header is not a call to itself. + ok( + one(`function boom(code: string): Error { const e = new Error('x'); e.code = code; return e; }`) + .sites.length === 0, + 'the helper declaration was read as its own call site', + ); + + // The argument splitter keeps nested commas out of the count. + ok( + splitTopLevel(`a, f(b, c), \`t${'${x}'}\`, d`).length === 4, + 'splitTopLevel counted a nested or templated comma as a separator', ); + ok(parseParamNames('readonly a: Map = x, b?: string').join(',') === 'a,b', 'parseParamNames mis-read a parameter list'); } // [#9223] Workspace-package resolution: `packages/` is inside the scan, so a From 7c1840e46599a7ab74b561d61807cc575aad558b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 11:30:58 +0000 Subject: [PATCH 2/2] fix(runtime): declare `assignconst` and `codehelper` in CodeStampShape (#9460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two new stamp positions the widened scan reports need members in the declaration table's own union, or every row carrying them is a tsc error. Caught by `pnpm --filter @objectstack/runtime typecheck`, not by the gate — the gate reads the table textually and never typechecks it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_012WKSnqAaoqtW3QX7SSf1Vk --- .../src/dispatcher-error-vocabulary.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 07bd446e35..971f7588f8 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -83,7 +83,29 @@ export type CodeStampShape = * (`${…}` → `*`, e.g. `APPROVAL_*_FAILED`) and a row must classify it. The * only verdict that can honestly cover a family is `runtime-pinned`. */ - | 'objlittemplate'; + | 'objlittemplate' + /** + * [#9460] `err.code = CONST` — the assign position's constant sibling, and + * the last of the four stamp positions to get one. #9223 closed exactly + * this gap for object literals; the assign position kept it, so + * `err.code = DENY_CODE` matched NOTHING and was not reported as + * unresolved either. + */ + | 'assignconst' + /** + * [#9460] The stamp inside a CODE-CARRYING HELPER: a file declares one + * factory — `postureError(code, message)`, `makeError(status, code, + * message)`, a `constructor(code, message)` — and throws through it + * everywhere. The stamp `(err as any).code = code` knows the token `code` + * but not the value; the CALL SITE knows the value and never writes the + * token. Every pattern in `check:dispatcher-error-vocabulary` AND in + * `check:error-code-casing` anchors on that token, so both gates read such + * a file and both reported nothing, each leaving it to the other — which is + * how `plugin-security`'s live 403 sat unswept through two ADR-0112 + * batches. The scan joins the two halves through the PARAMETER, whose index + * names the argument to read at each call site. + */ + | 'codehelper'; /** * Where the stamped code can end up. `dispatcher` is the door this card is