diff --git a/.changeset/objlit-code-helper-position.md b/.changeset/objlit-code-helper-position.md new file mode 100644 index 0000000000..ced8a54b34 --- /dev/null +++ b/.changeset/objlit-code-helper-position.md @@ -0,0 +1,62 @@ +--- +"@objectstack/runtime": patch +--- + +fix(scripts,runtime): the code-helper stamp shape reaches the OBJECT-LITERAL position (#13233) + +`check:dispatcher-error-vocabulary`'s `codehelper` shape was anchored on an +assignment — `.code = ident` — so the equally ordinary helper that builds an +object literal was invisible to it: + +```ts +function postureError(code: string, message: string) { + return { severity: 'error', code, message }; // <- nothing matched +} +``` + +No pattern in that gate or in `check:error-code-casing` fired there. `objlit` +needs a quote, `objlitconst` needs a SCREAMING_SNAKE identifier (the +conventional parameter name is `code`), `objlittemplate` needs backticks. So +there was no site **and** no unresolved entry — the one way the gate's own +"reported, never dropped" bound can fail without anything saying so. The new +`objlithelper` shape closes it, guarded structurally twice over: the innermost +enclosing bracket at the `code` token must be a `{` (which is what separates an +object literal from the argument list `f(a, code, b)` and the array +`[a, code]`), and the identifier must be a parameter of the enclosing +declaration. Together with the class-method declaration form, the live instance +that motivated the enquiry — `Parser#error` in `@objectstack/sdui-parser` — is +now reached. + +Measured on `packages/**` non-test source, through the gate's own derivation +rather than a separate instrument: **13 helpers · 117 newly reached in-file call +sites · 29 new verdict rows · 5 helpers that reduce to nothing · 0 unregistered +wire codes hiding**. All 29 rows are one genre — ADR-0114 D2 `FieldErrorCode` +members stamped by four validation helpers whose `code` parameter is *typed* to +that closed enum, landing at `ApiError.details.fields[].code` and never at +`error.code` (ADR-0112 D6). They are declared `foreign-vocabulary`. + +`@objectstack/runtime` carries the declaration half. `UNREGISTERED_CODE_SITES` +gains those 29 rows, and a second declared list `UNRESOLVED_CODE_HELPERS` +classifies the five helpers the scan can see but cannot read. That list is the +answer to the blocker this change had to clear: an `unresolved` entry is pushed +unconditionally and no row discharged it, so every one of the five would have +been a **red gate with no verdict available**. A `reason: 'helper'` entry is now +dischargeable by a row carrying a door, a verdict and its evidence, reconciled +in both directions like any site row. The restriction is the argument: every +other unresolved reason names a remedy the author can carry out ("resolve the +constant", "spell it `const`"), and a row there would buy an exemption from +work that is possible — a helper whose callers live in another package, pass a +vocabulary declared out of the gate's population, or pass a genuine runtime +value has no such remedy. + +Internal to the package (`dispatcher-error-vocabulary.ts` is not re-exported +from the package index), so no published surface changes and no runtime +behaviour moves — the gate's population does. + +⚠️ Recorded rather than smoothed over: this position's precision is measurably +lower than the assignment position's. `.code = ident` needs a property named +`code` on a value being mutated; `{ code }` is how any record carries any field +called `code`. Two of the thirteen helpers reached carry no error code at all — +an SMS one-time password and a YAML fence body — and no sibling-key test +separates them (the obvious candidate, requiring a `message` sibling, drops 25 +of the 29 rows with them). Both are classified rather than filtered out. diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index d069e922db..7a6e70fefa 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -105,7 +105,36 @@ export type CodeStampShape = * batches. The scan joins the two halves through the PARAMETER, whose index * names the argument to read at each call site. */ - | 'codehelper'; + | 'codehelper' + /** + * [#13233] The SAME code-carrying helper, stamping through an OBJECT + * LITERAL instead of an assignment: `return { severity, code, message }` + * (shorthand) or `{ code: errCode }` (longhand). + * + * `codehelper` was anchored on `.code =`, but the reasoning it implements — + * the identifier is a PARAMETER, so the literals live at the CALL SITES — + * belongs to the HELPER, not to the assignment operator. Until this shape + * landed, no pattern in this gate or in `check:error-code-casing` fired + * here at all: `objlit` needs a quote, `objlitconst` needs a + * SCREAMING_SNAKE identifier (the conventional parameter name is `code`), + * `objlittemplate` needs backticks. So there was no site AND no unresolved + * entry — the one way "reported, never dropped" can fail without anything + * saying so. + * + * ⚠️ Its precision is measurably lower than the assignment position's, and + * a reader of these rows should know why. `.code = ident` needs a property + * named `code` on a value being mutated, which is nearly always an error; + * `{ code }` is simply how any record carries any field called `code`. Of + * the 13 helpers this shape reaches on `packages/**`, two carry no error + * code at all — an SMS one-time password and a YAML fence body — and no + * sibling-key test separates them (measured: the largest true positive, + * `record-validator`'s `fail`, stamps `{ field, code, def, constraint, + * messageKey, options, value }` with no `message` at all). Both are + * classified in {@link UNRESOLVED_CODE_HELPERS} rather than filtered out, + * because a filter drawn around the inconvenient cases is an exemption + * wearing a predicate's clothes. + */ + | 'objlithelper'; /** * Where the stamped code can end up. `dispatcher` is the door this card is @@ -594,6 +623,575 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ '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.', }, + // ── [#13233] field-level catalogs, reached by the OBJECT-LITERAL helper ── + // + // The 29 rows below are the whole verdict cost of widening `codehelper` to + // the object-literal stamp position, and they are one genre from end to + // end: ADR-0114 D2 `FieldErrorCode` members, stamped by four validation + // helpers that take the code as a PARAMETER and build a field-error record + // around it. Nothing here reaches `error.code`. + // + // ⭐ The evidence is stronger than a reading of the call sites, and that is + // why this whole block can share one argument. Each helper's `code` + // parameter is TYPED to `FieldErrorCode` — the closed `z.enum` in + // `packages/spec/src/api/errors.zod.ts`, which `FieldErrorSchema.code` + // parses and which ADR-0114 D2 governs. So the vocabulary these sites stamp + // is already compiler-enforced against a catalog that is not the ledger's, + // and it lands at `ApiError.details.fields[].code`, one level below + // `error.code`. ADR-0112 D6 draws exactly this line: a field-level code is + // not wire vocabulary and is not #8846's business. + // + // ⚠️ `door: 'none'` here means "does not reach `error.code`", not "never + // leaves the process" — these DO travel to a client, inside + // `details.fields[]`. The door this table asks about is the `error.code` + // door, and the enum above is what guards the other one. + // fail() — record-validator.ts + { + code: 'required', + file: 'packages/objectql/src/validation/record-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'record-validator\'s `fail(code: FieldErrorCode, …)` builds one `{ field, code, def, constraint, ' + + 'messageKey, options, value }` per violated constraint. Its `code` parameter is typed `code: ' + + 'FieldErrorCode`, so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'required\' is one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, so ' + + 'no ledger row can be owed for it (ADR-0112 D6).', + }, + { + code: 'invalid_type', + file: 'packages/objectql/src/validation/record-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'record-validator\'s `fail(code: FieldErrorCode, …)` builds one `{ field, code, def, constraint, ' + + 'messageKey, options, value }` per violated constraint. Its `code` parameter is typed `code: ' + + 'FieldErrorCode`, so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'invalid_type\' is one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, ' + + 'so no ledger row can be owed for it (ADR-0112 D6).', + }, + { + code: 'invalid_boolean', + file: 'packages/objectql/src/validation/record-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'record-validator\'s `fail(code: FieldErrorCode, …)` builds one `{ field, code, def, constraint, ' + + 'messageKey, options, value }` per violated constraint. Its `code` parameter is typed `code: ' + + 'FieldErrorCode`, so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'invalid_boolean\' is one of them. It reaches `ApiError.details.fields[].code`, never ' + + '`error.code`, so no ledger row can be owed for it (ADR-0112 D6).', + }, + { + code: 'invalid_number', + file: 'packages/objectql/src/validation/record-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'record-validator\'s `fail(code: FieldErrorCode, …)` builds one `{ field, code, def, constraint, ' + + 'messageKey, options, value }` per violated constraint. Its `code` parameter is typed `code: ' + + 'FieldErrorCode`, so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'invalid_number\' is one of them. It reaches `ApiError.details.fields[].code`, never ' + + '`error.code`, so no ledger row can be owed for it (ADR-0112 D6).', + }, + { + code: 'invalid_date', + file: 'packages/objectql/src/validation/record-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'record-validator\'s `fail(code: FieldErrorCode, …)` builds one `{ field, code, def, constraint, ' + + 'messageKey, options, value }` per violated constraint. Its `code` parameter is typed `code: ' + + 'FieldErrorCode`, so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'invalid_date\' is one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, ' + + 'so no ledger row can be owed for it (ADR-0112 D6).', + }, + { + code: 'invalid_time', + file: 'packages/objectql/src/validation/record-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'record-validator\'s `fail(code: FieldErrorCode, …)` builds one `{ field, code, def, constraint, ' + + 'messageKey, options, value }` per violated constraint. Its `code` parameter is typed `code: ' + + 'FieldErrorCode`, so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'invalid_time\' is one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, ' + + 'so no ledger row can be owed for it (ADR-0112 D6).', + }, + { + code: 'invalid_email', + file: 'packages/objectql/src/validation/record-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'record-validator\'s `fail(code: FieldErrorCode, …)` builds one `{ field, code, def, constraint, ' + + 'messageKey, options, value }` per violated constraint. Its `code` parameter is typed `code: ' + + 'FieldErrorCode`, so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'invalid_email\' is one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, ' + + 'so no ledger row can be owed for it (ADR-0112 D6).', + }, + { + code: 'invalid_url', + file: 'packages/objectql/src/validation/record-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'record-validator\'s `fail(code: FieldErrorCode, …)` builds one `{ field, code, def, constraint, ' + + 'messageKey, options, value }` per violated constraint. Its `code` parameter is typed `code: ' + + 'FieldErrorCode`, so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'invalid_url\' is one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, ' + + 'so no ledger row can be owed for it (ADR-0112 D6).', + }, + { + code: 'invalid_phone', + file: 'packages/objectql/src/validation/record-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'record-validator\'s `fail(code: FieldErrorCode, …)` builds one `{ field, code, def, constraint, ' + + 'messageKey, options, value }` per violated constraint. Its `code` parameter is typed `code: ' + + 'FieldErrorCode`, so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'invalid_phone\' is one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, ' + + 'so no ledger row can be owed for it (ADR-0112 D6).', + }, + { + code: 'invalid_option', + file: 'packages/objectql/src/validation/record-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'record-validator\'s `fail(code: FieldErrorCode, …)` builds one `{ field, code, def, constraint, ' + + 'messageKey, options, value }` per violated constraint. Its `code` parameter is typed `code: ' + + 'FieldErrorCode`, so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'invalid_option\' is one of them. It reaches `ApiError.details.fields[].code`, never ' + + '`error.code`, so no ledger row can be owed for it (ADR-0112 D6).', + }, + { + code: 'min_length', + file: 'packages/objectql/src/validation/record-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'record-validator\'s `fail(code: FieldErrorCode, …)` builds one `{ field, code, def, constraint, ' + + 'messageKey, options, value }` per violated constraint. Its `code` parameter is typed `code: ' + + 'FieldErrorCode`, so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'min_length\' is one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, so ' + + 'no ledger row can be owed for it (ADR-0112 D6).', + }, + { + code: 'max_length', + file: 'packages/objectql/src/validation/record-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'record-validator\'s `fail(code: FieldErrorCode, …)` builds one `{ field, code, def, constraint, ' + + 'messageKey, options, value }` per violated constraint. Its `code` parameter is typed `code: ' + + 'FieldErrorCode`, so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'max_length\' is one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, so ' + + 'no ledger row can be owed for it (ADR-0112 D6).', + }, + { + code: 'min_value', + file: 'packages/objectql/src/validation/record-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'record-validator\'s `fail(code: FieldErrorCode, …)` builds one `{ field, code, def, constraint, ' + + 'messageKey, options, value }` per violated constraint. Its `code` parameter is typed `code: ' + + 'FieldErrorCode`, so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'min_value\' is one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, so ' + + 'no ledger row can be owed for it (ADR-0112 D6).', + }, + { + code: 'max_value', + file: 'packages/objectql/src/validation/record-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'record-validator\'s `fail(code: FieldErrorCode, …)` builds one `{ field, code, def, constraint, ' + + 'messageKey, options, value }` per violated constraint. Its `code` parameter is typed `code: ' + + 'FieldErrorCode`, so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'max_value\' is one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, so ' + + 'no ledger row can be owed for it (ADR-0112 D6).', + }, + { + code: 'max_scale', + file: 'packages/objectql/src/validation/record-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'record-validator\'s `fail(code: FieldErrorCode, …)` builds one `{ field, code, def, constraint, ' + + 'messageKey, options, value }` per violated constraint. Its `code` parameter is typed `code: ' + + 'FieldErrorCode`, so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'max_scale\' is one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, so ' + + 'no ledger row can be owed for it (ADR-0112 D6).', + }, + // fallback() — rule-validator.ts + { + code: 'invalid_initial_state', + file: 'packages/objectql/src/validation/rule-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'rule-validator\'s `fallback(code, …)` builds the state-machine field error `{ field, code, ' + + 'message, label }`. Its `code` parameter is typed `code: \'invalid_initial_state\' | ' + + '\'invalid_transition\'`, so the value is a member of the closed ADR-0114 D2 catalog by ' + + 'construction; \'invalid_initial_state\' is one of them. It reaches ' + + '`ApiError.details.fields[].code`, never `error.code`, so no ledger row can be owed for it ' + + '(ADR-0112 D6).', + }, + { + code: 'invalid_transition', + file: 'packages/objectql/src/validation/rule-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'rule-validator\'s `fallback(code, …)` builds the state-machine field error `{ field, code, ' + + 'message, label }`. Its `code` parameter is typed `code: \'invalid_initial_state\' | ' + + '\'invalid_transition\'`, so the value is a member of the closed ADR-0114 D2 catalog by ' + + 'construction; \'invalid_transition\' is one of them. It reaches `ApiError.details.fields[].code`, ' + + 'never `error.code`, so no ledger row can be owed for it (ADR-0112 D6).', + }, + // coerceError() — import-coerce.ts + { + code: 'invalid_boolean', + file: 'packages/rest/src/import-coerce.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'import-coerce\'s `coerceError(meta, field, code: FieldErrorCode, …)` builds the per-cell ' + + '`FieldCoerceError` an import row reports. Its `code` parameter is typed `code: FieldErrorCode`, ' + + 'so the value is a member of the closed ADR-0114 D2 catalog by construction; \'invalid_boolean\' is ' + + 'one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, so no ledger row ' + + 'can be owed for it (ADR-0112 D6).', + }, + { + code: 'invalid_date', + file: 'packages/rest/src/import-coerce.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'import-coerce\'s `coerceError(meta, field, code: FieldErrorCode, …)` builds the per-cell ' + + '`FieldCoerceError` an import row reports. Its `code` parameter is typed `code: FieldErrorCode`, ' + + 'so the value is a member of the closed ADR-0114 D2 catalog by construction; \'invalid_date\' is ' + + 'one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, so no ledger row ' + + 'can be owed for it (ADR-0112 D6).', + }, + { + code: 'invalid_number', + file: 'packages/rest/src/import-coerce.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'import-coerce\'s `coerceError(meta, field, code: FieldErrorCode, …)` builds the per-cell ' + + '`FieldCoerceError` an import row reports. Its `code` parameter is typed `code: FieldErrorCode`, ' + + 'so the value is a member of the closed ADR-0114 D2 catalog by construction; \'invalid_number\' is ' + + 'one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, so no ledger row ' + + 'can be owed for it (ADR-0112 D6).', + }, + { + code: 'invalid_option', + file: 'packages/rest/src/import-coerce.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'import-coerce\'s `coerceError(meta, field, code: FieldErrorCode, …)` builds the per-cell ' + + '`FieldCoerceError` an import row reports. Its `code` parameter is typed `code: FieldErrorCode`, ' + + 'so the value is a member of the closed ADR-0114 D2 catalog by construction; \'invalid_option\' is ' + + 'one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, so no ledger row ' + + 'can be owed for it (ADR-0112 D6).', + }, + { + code: 'reference_ambiguous', + file: 'packages/rest/src/import-coerce.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'import-coerce\'s `coerceError(meta, field, code: FieldErrorCode, …)` builds the per-cell ' + + '`FieldCoerceError` an import row reports. Its `code` parameter is typed `code: FieldErrorCode`, ' + + 'so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'reference_ambiguous\' is one of them. It reaches `ApiError.details.fields[].code`, never ' + + '`error.code`, so no ledger row can be owed for it (ADR-0112 D6).', + }, + { + code: 'reference_not_found', + file: 'packages/rest/src/import-coerce.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'import-coerce\'s `coerceError(meta, field, code: FieldErrorCode, …)` builds the per-cell ' + + '`FieldCoerceError` an import row reports. Its `code` parameter is typed `code: FieldErrorCode`, ' + + 'so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'reference_not_found\' is one of them. It reaches `ApiError.details.fields[].code`, never ' + + '`error.code`, so no ledger row can be owed for it (ADR-0112 D6).', + }, + // invalidQueryParam() — query-param.ts + { + code: 'invalid_boolean', + file: 'packages/runtime/src/query-param.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'query-param\'s `invalidQueryParam(param, code: FieldErrorCode, …)` hands `validationFailure` a ' + + 'single `{ field, code, message }`. Its `code` parameter is typed `code: FieldErrorCode`, so the ' + + 'value is a member of the closed ADR-0114 D2 catalog by construction; \'invalid_boolean\' is one of ' + + 'them. It reaches `ApiError.details.fields[].code`, never `error.code`, so no ledger row can be ' + + 'owed for it (ADR-0112 D6).', + }, + { + code: 'invalid_number', + file: 'packages/runtime/src/query-param.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'query-param\'s `invalidQueryParam(param, code: FieldErrorCode, …)` hands `validationFailure` a ' + + 'single `{ field, code, message }`. Its `code` parameter is typed `code: FieldErrorCode`, so the ' + + 'value is a member of the closed ADR-0114 D2 catalog by construction; \'invalid_number\' is one of ' + + 'them. It reaches `ApiError.details.fields[].code`, never `error.code`, so no ledger row can be ' + + 'owed for it (ADR-0112 D6).', + }, + { + code: 'invalid_option', + file: 'packages/runtime/src/query-param.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'query-param\'s `invalidQueryParam(param, code: FieldErrorCode, …)` hands `validationFailure` a ' + + 'single `{ field, code, message }`. Its `code` parameter is typed `code: FieldErrorCode`, so the ' + + 'value is a member of the closed ADR-0114 D2 catalog by construction; \'invalid_option\' is one of ' + + 'them. It reaches `ApiError.details.fields[].code`, never `error.code`, so no ledger row can be ' + + 'owed for it (ADR-0112 D6).', + }, + { + code: 'invalid_type', + file: 'packages/runtime/src/query-param.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'query-param\'s `invalidQueryParam(param, code: FieldErrorCode, …)` hands `validationFailure` a ' + + 'single `{ field, code, message }`. Its `code` parameter is typed `code: FieldErrorCode`, so the ' + + 'value is a member of the closed ADR-0114 D2 catalog by construction; \'invalid_type\' is one of ' + + 'them. It reaches `ApiError.details.fields[].code`, never `error.code`, so no ledger row can be ' + + 'owed for it (ADR-0112 D6).', + }, + { + code: 'max_value', + file: 'packages/runtime/src/query-param.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'query-param\'s `invalidQueryParam(param, code: FieldErrorCode, …)` hands `validationFailure` a ' + + 'single `{ field, code, message }`. Its `code` parameter is typed `code: FieldErrorCode`, so the ' + + 'value is a member of the closed ADR-0114 D2 catalog by construction; \'max_value\' is one of them. ' + + 'It reaches `ApiError.details.fields[].code`, never `error.code`, so no ledger row can be owed ' + + 'for it (ADR-0112 D6).', + }, + { + code: 'min_value', + file: 'packages/runtime/src/query-param.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'query-param\'s `invalidQueryParam(param, code: FieldErrorCode, …)` hands `validationFailure` a ' + + 'single `{ field, code, message }`. Its `code` parameter is typed `code: FieldErrorCode`, so the ' + + 'value is a member of the closed ADR-0114 D2 catalog by construction; \'min_value\' is one of them. ' + + 'It reaches `ApiError.details.fields[].code`, never `error.code`, so no ledger row can be owed ' + + 'for it (ADR-0112 D6).', + }, +]; + +/** + * [#13233] Why a helper's codes cannot be read from source, and whether that + * matters. A DIFFERENT question from {@link CodeVerdict}, so a different + * vocabulary — a site row answers "does this code reach a wire and is it + * registered", and none of its members can answer "we never learn the value". + * + * ⛔ Deliberately not merged into `CodeVerdict`: a member that says "no row is + * owed here" is safe on a helper whose codes are unknown and would be a + * blanket exemption on a site that spells a literal — the hole this gate + * exists to close. + */ +export type UnresolvedHelperVerdict = + /** + * The parameter is not an ADR-0112 `error.code` at all — it belongs to + * another vocabulary that merely spells itself `code`. The same line + * `foreign-vocabulary` draws for a site, one indirection earlier. + */ + | 'foreign-vocabulary' + /** + * The parameter is TYPED to the closed `ErrorCode` union, so the compiler + * already enforces exactly what a registry check would — better, and at + * every call site including the ones in other packages that this in-file + * scan cannot see. The principle is already on the record above: the REST + * door's author-side responder takes `code: ErrorCode` and therefore "can + * never be a finding here". + */ + | 'compiler-narrowed' + /** + * The helper does not AUTHOR a code; it re-stamps one that arrived on a + * caught value. The authoring sites are scanned in their own right, under + * their own shapes, and #9106 demotes anything unregistered to the wire's + * `declaredCode` at the door. Reading a runtime value here is the #9460 + * bound, not a gap. + */ + | 'restamped-elsewhere'; + +/** + * [#13233] A code-carrying helper this scan can SEE but cannot READ: the stamp + * resolves to a parameter, and no in-file call site passes a value that reduces + * to a literal. + * + * ## Why this list exists at all + * + * `check-dispatcher-error-vocabulary` pushes an `unresolved` entry for any + * value it cannot reduce, and — for every other reason it does so — that entry + * is an unconditional RED with a remedy the author can carry out: resolve the + * constant, or spell the `let` as a `const`. A HELPER has no such remedy. Its + * callers may live in another package (`sendError` has zero in-file calls), or + * pass a vocabulary this gate has declared out of its own population + * (`Parser#error`'s sixteen kebab diagnostics), or pass a genuine runtime value + * (`http-dispatcher`'s `thrown.code`). Left undischargeable, each is a red + * nobody CAN clear — which is not a gate, it is a broken build. + * + * So the helper is classified here instead, with a door, a verdict and its + * evidence, and the gate reconciles this list in BOTH directions: an entry the + * scan no longer reports goes stale and REDS, exactly like a site row. ⭐ That + * is what makes it a widening rather than an exemption — before #13233 none of + * these helpers produced a site OR an unresolved, because no shape reached + * them; now every one is recorded, evidenced, and ratcheted. + * + * ⛔ Not a parking space. A helper belongs here only when the codes genuinely + * cannot be read from this repo's source. If its callers CAN spell literals, + * the fix is to make them spell literals — then the codes become ordinary + * sites with ordinary rows, and the gate reds until this entry is deleted. + * + * MEASURED, not chosen. Re-derive with: + * node scripts/check-dispatcher-error-vocabulary.mjs + */ +export interface UnresolvedCodeHelper { + /** Repo-relative file. No line number — line numbers rot, files do not. */ + readonly file: string; + /** The declaring function, constructor or method, by name. */ + readonly helper: string; + /** Which of its parameters the stamp reads. */ + readonly param: string; + readonly shape: CodeStampShape; + readonly door: CodeDoor; + readonly verdict: UnresolvedHelperVerdict; + /** Why this verdict — the evidence, not a restatement of the verdict. */ + readonly why: string; +} + +export const UNRESOLVED_CODE_HELPERS: readonly UnresolvedCodeHelper[] = [ + { + file: 'packages/plugins/plugin-auth/src/auth-manager.ts', + helper: 'deliverPhoneOtp', + param: 'code', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'The `code` here is an SMS ONE-TIME PASSWORD, not an error code. `deliverPhoneOtp(phone, code)` ' + + 'renders it into the notification-template payload `{ code, appName, minutes }` and into the ' + + "provider's `templateParams: { code }` — the digits a user types back in. It never reaches an " + + 'error envelope in any field. Its two in-file callers are better-auth `sendOTP` hooks that pass ' + + 'the library\'s generated value, so nothing reduces and nothing should: this is the shape of ' + + 'false positive the object-literal position cannot filter structurally, recorded rather than ' + + 'silently excluded by a predicate drawn around it.', + }, + { + file: 'packages/spec/scripts/check-yaml-examples.ts', + helper: 'block', + param: 'code', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'The `code` here is a raw YAML FENCE BODY. `block(code, decl)` is a self-test fixture factory ' + + 'building a `TaggedBlock` — `{ source, fenceLine, bodyStartLine, code, decl }` — whose `code` ' + + 'field is the text inside a documentation ```yaml fence, and its eleven callers pass multi-line ' + + 'YAML documents. `TaggedBlock.code` is declared "Raw fence body" in the same file. The second ' + + 'of the two non-error `code` parameters this position reaches, and the reason the widening ' + + 'declares its precision limit instead of claiming it has none.', + }, + { + file: 'packages/sdui-parser/src/parse.ts', + helper: 'error', + param: 'code', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'The live instance the whole object-literal enquiry was opened for, and it needed BOTH ' + + 'blindnesses closed to be reachable: `Parser#error(code, message, start?, tag?)` is a class ' + + 'method (the declaration form) that stamps through an object literal (the position). Its ' + + 'sixteen in-file callers all pass KEBAB literals — `no-root`, ' + + "`multiple-roots`, `bad-tag` — which this gate's literal grammar refuses by design, so the " + + 'helper reduces to nothing. That silence is already declared: `KEBAB_DIAGNOSTIC_VOCABULARY` ' + + 'puts the ADR-0112 D6c author-time diagnostic family outside BOTH vocabulary gates, and ' + + 'widening the grammar to reduce these would move that boundary rather than honour it.', + }, + { + file: 'packages/types/src/response-envelope.ts', + helper: 'sendError', + param: 'code', + shape: 'objlithelper', + door: 'rest', + verdict: 'compiler-narrowed', + why: + 'The signature is `sendError(res, status, code: ErrorCode, message, extra)` — the parameter is ' + + 'typed to the CLOSED union, so an unregistered spelling cannot reach `error: { code, message }` ' + + 'without failing the build first. The scan reports it only because the check it wants is ' + + 'already made somewhere it cannot look: `helperCodesFor` scans the DECLARING file and this ' + + 'helper has zero in-file callers, being the shared envelope responder other packages import. ' + + 'The same reasoning `CodeDoor` above already records for the REST door\'s author-side ' + + 'responder, which takes the identical parameter type.', + }, + { + file: 'packages/runtime/src/http-dispatcher.ts', + helper: 'error', + param: 'code', + shape: 'objlithelper', + door: 'dispatcher', + verdict: 'restamped-elsewhere', + why: + 'This is the dispatcher door itself — `error(message, httpStatus, details?, code?, extra?)` — ' + + 'and it AUTHORS no code. Of its seven in-file callers, six pass no fourth argument at all and ' + + 'the seventh passes `thrown.code`, a value read off a caught error at runtime. Every producer ' + + 'that put that string there is a stamp site this gate scans under its own shape, so covering ' + + 'it here would double-count them rather than add reach; and the door narrows what it emits, ' + + 'demoting an unregistered spelling to the wire\'s `declaredCode`. This is the declared ' + + 'runtime-value bound, arriving one indirection later than usual.', + }, ]; /** diff --git a/scripts/check-dispatcher-error-vocabulary.mjs b/scripts/check-dispatcher-error-vocabulary.mjs index 23d118309a..f2d91fefb8 100644 --- a/scripts/check-dispatcher-error-vocabulary.mjs +++ b/scripts/check-dispatcher-error-vocabulary.mjs @@ -324,6 +324,33 @@ export const SHAPES = [ // `resolveConstant` before giving up on it — that is where a local ternary // of literals is reduced, and it reports under `assignconst`. { name: 'codehelper', re: /\.code\s*=\s*([A-Za-z_$][\w$]*)\s*[;,\n)]/g, resolve: 'helper', lowercase: 'here' }, + // [#13233] The SAME helper reasoning as `codehelper`, in the OBJECT-LITERAL + // stamp position: `return { severity, code, message }` (shorthand) and + // `{ code: code }` / `{ code: errCode }` (longhand). `codehelper` was + // anchored on the assignment operator, but "the identifier is a PARAMETER, + // so the literals live at the CALL SITES" is a property of the HELPER, not + // of `=`. Until this shape landed, no pattern in either gate fired here at + // all — no site AND no unresolved, the one way this gate's "reported, never + // dropped" bound cannot notice itself failing. + // + // The regex is a CANDIDATE generator, not the shape. Two structural guards + // in `deriveSites` decide, and both are load-bearing: + // · the innermost enclosing bracket at the `code` token must be `{`, which + // is what separates an object literal from the argument list + // `f(a, code, b)` and the array `[a, code]`. A regex cannot earn that — + // measured on this tree: the naive `[{,]` anchor alone reports 19 + // helpers where the structural guard reports 13, and not one of the six + // extras is an object-literal stamp (`onChildExit(sig, code)` and + // `findBareAny(code)` are argument lists; `postureError` is already a + // `codehelper` site read through its own call). This is the same + // over-match that made #13131 discard a regex instrument for the census. + // · the identifier must be a PARAMETER of the enclosing declaration + // (`helperCodesFor`), exactly as in the assignment position. + // ⛔ Named `objlithelper` rather than folded into `codehelper` on purpose: + // SHAPES is a PUBLISHED list whose price is that an unrecognised spelling + // reports nothing, so a new recognizer that hides under an existing name + // cannot be counted, pinned or ratcheted separately from the one it borrows. + { name: 'objlithelper', re: /(?:\bcode\s*:\s*|[{,]\s*(?=code\s*[,;}\n]))([A-Za-z_$][\w$]*)\s*[,;}\n]/g, resolve: 'objlithelper', lowercase: 'here' }, ]; /** @@ -422,166 +449,152 @@ export const KEBAB_DIAGNOSTIC_VOCABULARY = Object.freeze({ }); /** - * ## [#13131] A code-carrying helper that stamps through an OBJECT LITERAL is - * ## invisible to `codehelper` — MEASURED, and deliberately NOT closed here + * ## [#13233] The OBJECT-LITERAL stamp position — CLOSED, and what it cost * - * `codehelper` above is anchored on an ASSIGNMENT: its regex is `.code = ident`. + * `codehelper` was anchored on an ASSIGNMENT: its regex is `.code = ident`. * The reasoning it implements — "the identifier is a PARAMETER, so the literals * live at the CALL SITES" — is a property of the HELPER, not of the assignment * operator, and it holds just as well for the equally ordinary helper that * builds an object literal: * * function postureError(code: string, message: string) { - * return { severity: 'error', code, message }; // <- nothing matches + * return { severity: 'error', code, message }; // <- nothing matched * } * - * No shape in either gate anchors there. `objlit` needs a quote, `objlitconst` + * No shape in either gate anchored there. `objlit` needs a quote, `objlitconst` * needs a SCREAMING_SNAKE identifier after the colon (and the conventional * parameter name is `code`), `objlittemplate` needs backticks, and `codehelper` - * needs the `.code =`. So this is worse than a wrong verdict: no pattern fires - * at all, which means there is no site AND no unresolved entry either — nothing - * is reported, and nothing says so. That is precisely the bound this gate - * states for itself ("a value it cannot reduce is REPORTED, never dropped") - * failing in the one way the bound cannot notice. - * - * ## TWO blindnesses, not one — and the second is why the first is not enough - * - * ① POSITION. The object-literal stamp position has no shape (above). OPEN. + * needs the `.code =`. So it was worse than a wrong verdict: no pattern fired + * at all, which means there was no site AND no unresolved entry — nothing + * reported, and nothing saying so. That is precisely the bound this gate states + * for itself ("a value it cannot reduce is REPORTED, never dropped") failing in + * the one way the bound cannot notice. `objlithelper` in SHAPES closes it. + * + * ## TWO blindnesses, and BOTH are now closed + * + * ① POSITION. The object-literal stamp position had no shape. [#13233] CLOSED — + * `objlithelper`, guarded structurally by `enclosingOpeners` (the enclosing + * bracket must be `{`) and by `helperCodesFor` (the identifier must be a + * parameter). * ② DECLARATION FORM. `enclosingDeclaration`'s `DECL_HEADER_RE` recognised * `function f(`, `constructor(` and `const|let|var f = (` — and NO class - * method. So a code-carrying helper that is a METHOD was out of reach even - * in the position `codehelper` already implements: the same body that - * produces a site as a free function produced nothing as - * `private error(code, …)`. [#13226] CLOSED — see `METHOD_HEADER` below. + * method. [#13226] CLOSED — see `METHOD_HEADER` below. * - * These are independent, and the card's own live instance needs BOTH: + * They were independent, and the card's own live instance needed BOTH: * `Parser#error(code, message, start?, tag?)` in `packages/sdui-parser` is a - * class method that stamps through an object literal. Widening the position - * alone would still not reach it. - * - * ## [#13226] What closing ② cost, and what it did NOT buy - * - * ⭐ Closing ② moved NOTHING on this tree: sites and `unresolved` are - * byte-identical before and after, measured through the real `deriveSites` over - * `packages/**` against a worktree at the parent commit. Two independent - * reasons, and the second is the one worth carrying forward: - * - * · No class-method helper stamps in the ASSIGNMENT position today. Of the 17 - * `.code = ident` matches in `packages/**` non-test source, 5 are true - * helpers (the identifier is a parameter) and every one of those is a - * `function` or a `constructor` — 0 are methods. - * · The 4 helpers ② was blocking all stamp through an OBJECT LITERAL, so - * blindness ① still drops them. ⚠️ The 25 call sites and 3 undischargeable - * `unresolved` in the split below are therefore NOT a cost ② paid; they are - * a cost ① will pay, on the day the position widens. Reading them as ②'s is - * the mistake this paragraph exists to prevent. - * - * What ② bought is that the widening of ① will REACH those helpers when it - * lands. `enclosingDeclaration` at the `Parser#error` stamp offset now answers - * `error(code, message, start, tag)`; before it answered the Parser - * CONSTRUCTOR. (The constructor's parameter names parsed as `["readonly", - * "readonly"]` when this was first measured — that half was a separate defect - * in `parseParamNames`, since repaired, and it reads `["src","opts"]` now. - * Both halves had to be true for the stamp to resolve; neither alone sufficed.) - * - * ## The measurement (blast radius), on `packages/**` non-test source - * - * Method: the predicate — a `code` property of an object literal whose value is - * an identifier that is a PARAMETER of the enclosing function — evaluated on - * the real TypeScript AST, with call-site arguments reduced by THIS gate's own - * literal grammar (`/^[A-Za-z][A-Za-z0-9_]*$/`, so a hyphen does not reduce) - * and checked against the registered vocabulary and the declaration table. - * A regex instrument was tried first and discarded: it matched `${code}` inside - * a template and `f(a, code, b)` inside an argument list, and then LOST a true - * positive because a bracket inside a regex literal unbalanced its scan. - * - * predicate matches 14 helpers (13 named, 1 anonymous) - * (a) call sites newly reached 116 — 75 reduce to a literal, 41 do not - * (b) NEW verdict rows needed 29 — 0 of them already carry a row - * (c) undischargeable `unresolved` findings 5 - * - * Split by whether `enclosingDeclaration` could see the declaration at all - * BEFORE [#13226] — the split is history now that ② is closed, and it is kept - * because it is what the ① widening will actually meet: - * - * reachable then (blindness ① only) 10 helpers · 91 call sites · 29 rows · 2 unresolved - * blocked by blindness ② 4 helpers · 25 call sites · 0 rows · 3 unresolved - * - * ⚠️ [#13226] re-derived these on today's tree and they had DRIFTED — upward, - * by exactly one helper. The first census read 13 helpers · 106 call sites · 4 - * unresolved; a 14th helper (`block` in `packages/spec/scripts/`, 10 call sites, - * none reducing) landed between the two readings and accounts for the whole - * difference (106 + 10 = 116, 4 + 1 = 5). (b) 29 and the ZERO below did not - * move, and the blindness-② line reproduced digit for digit. The lesson is not - * that the numbers were wrong — they were right when taken — but that this is a - * census of a moving tree, so a reader acting on it re-runs it rather than - * quoting it. - * - * ⚠️ (b) is not the whole cost, and reading it as the whole cost is the trap - * this note exists to prevent. All 29 new rows are LOWERCASE `FieldErrorCode` - * diagnostics (`required`, `invalid_type`, `min_value`, …) in four files — - * ADR-0112 D6 genre, not wire codes. The four `unresolved` findings are the - * expensive half: an `unresolved` entry is pushed UNCONDITIONALLY and no - * declaration row discharges it (see `reconcile`), so each one is a RED gate - * with no verdict available — including `Parser#error`, whose 16 call sites all - * pass kebab literals that this gate's grammar refuses to reduce. + * class method that stamps through an object literal. ⭐ It is reached now, and + * `--self-test`'s boundary case — which asserted for one round that a + * class-method-through-object-literal helper derives NOTHING — is the + * assertion that flipped to say so. + * + * ## What each closure cost, measured on `packages/**` non-test source + * + * ② moved NOTHING: sites and `unresolved` were byte-identical before and after, + * for two reasons, and the second is the one that carried forward. No class- + * method helper stamps in the ASSIGNMENT position today (of the 17 `.code = + * ident` matches, 5 are true helpers and every one is a `function` or a + * `constructor`). And the 4 helpers ② unblocked all stamp through an OBJECT + * literal, so ① still dropped them — their cost was ①'s to pay, and ① has now + * paid it. + * + * ① is the expensive one: + * + * helpers reached 13 (all named; see `invisibleDeclarationForms`) + * (a) call sites newly reached 117 — 76 reduce to a literal, 41 do not + * (b) NEW verdict rows 29 — 0 of them already carried a row + * (c) helpers that reduce to NOTHING 5 + * + * (b) is one genre end to end: 29 lowercase ADR-0114 D2 `FieldErrorCode` + * members (`required`, `invalid_type`, `min_value`, …) in four files, every one + * stamped by a helper whose `code` parameter is TYPED to that closed enum. They + * land at `ApiError.details.fields[].code`, never at `error.code` — ADR-0112 D6 + * genre — and they are declared `foreign-vocabulary` in `UNREGISTERED_CODE_SITES`. + * + * (c) was the card's declared BLOCKER, and it is answered by mechanism rather + * than by suppression: an `unresolved` entry with reason `helper` is now + * DISCHARGEABLE by a row in `UNRESOLVED_CODE_HELPERS`, reconciled in both + * directions like any site row. Every other unresolved reason stays an + * unconditional red, because every other reason names a remedy the author can + * carry out. See `reconcile`. + * + * ## ⚠️ The precision this position does NOT have — measured, not conceded + * + * `.code = ident` needs a property called `code` on a value being MUTATED, + * which in this repo is almost always an error. `{ code }` is simply how any + * record carries any field named `code`, and the widening reaches all of them. + * Of the 13 helpers, TWO carry no error code whatsoever: + * `auth-manager.deliverPhoneOtp(phone, code)` stamps an SMS one-time password, + * and `check-yaml-examples.block(code, decl)` stamps a raw YAML fence body. + * + * ⛔ And there is no sibling-key test that separates them, which was measured + * rather than assumed. The obvious candidate — require the stamping literal to + * carry a `message` sibling, the ADR-0112 envelope shape this record's own + * `shorthand` spells — FAILS on the largest true positive: `record-validator`'s + * `fail` stamps `{ field, code, def, constraint, messageKey, options, value }` + * with no `message` at all, and alone contributes 15 of the 29 rows. + * `share-links.sendErr` stamps `{ code }` and nothing else. A narrowing that + * dropped the two false positives would drop 25 of the 29 rows with them. + * + * ⇒ the two are CLASSIFIED in `UNRESOLVED_CODE_HELPERS`, not filtered out. A + * predicate drawn around the cases one finds inconvenient is an exemption + * wearing a predicate's clothes, and it would have been drawn where no + * principle could later re-derive it. * * ## No victim today — verified, not assumed * - * Of the 31 SCREAMING_SNAKE values reached, 31 are already registered; the - * count of values that are BOTH ADR-0112 D1 shaped AND unregistered — i.e. a - * real wire code hiding behind this blindness — is ZERO. `check:error-code-casing` - * likewise reports nothing on any of the files involved. So the value of - * closing this is preventing a future defect, not fixing a present one. - * - * ⚠️ [#13226] re-measured this number, because it is the one that grades the - * work: a single unregistered D1-shaped code hiding here would make this a live - * defect rather than a future one. It is still ZERO (the reached population - * drifted 33 → 31; every one of them registered). ⛔ It is a property of the - * TREE, not of the gate, so it expires: whoever widens ① re-measures it rather - * than citing this line. - * - * ⚠️ One bound the measurement does NOT have. `helperCodesFor` scans only the - * DECLARING file, so every count here is over IN-FILE call sites. An exported - * helper called from other packages contributes 0 — `sendError` in - * `packages/types/src/response-envelope.ts` has 0 in-file calls and is one of - * the four `unresolved`. Counting cross-file callers needs resolution this - * source scan does not have; that number is UNMEASURED, and it is a lower bound - * on (a), never an upper one. - * - * ## ⛔ What this declaration deliberately does NOT do - * - * ⛔ It does not widen `codehelper`. Triage ruled the measuring round scoped to - * measurement: 「先量出加宽后的爆炸半径, ⛔ 不先加宽。数没量出来之前,"该不该加宽" - * 不是一个可裁的问题。」 The numbers above are that measurement; whether to - * widen is now a decidable question and belongs on its own card, with the - * verdict rows (`domain:cli`) as a rider on the same change. + * Of the 33 SCREAMING_SNAKE values reached, 33 are already registered; the + * count of values BOTH ADR-0112 D1 shaped AND unregistered — a real wire code + * hiding behind this blindness — is ZERO. `check:error-code-casing` likewise + * reports nothing on any file involved. So closing this prevented a future + * defect; it fixed no present one. ⛔ It is a property of the TREE, not of the + * gate, so it expires: re-measure rather than citing this line. * - * ⛔ It does not add verdict rows. A row for a site no shape derives is a - * `stale-row` finding, so the rows cannot land before the widening. + * ⚠️ One bound none of these numbers has. `helperCodesFor` scans only the + * DECLARING file, so every count is over IN-FILE call sites. An exported helper + * called from other packages contributes 0 — `sendError` in + * `packages/types/src/response-envelope.ts` has 0 in-file calls and is one of + * the five in (c). That figure is a LOWER bound on (a), never an upper one. + * + * ## ⛔ What this closure deliberately does NOT do + * + * ⛔ It does not widen the literal grammar to admit a hyphen so that + * `Parser#error`'s kebab call sites reduce. That family is declared out of BOTH + * gates by `KEBAB_DIAGNOSTIC_VOCABULARY` above, and reducing it here would move + * that boundary under cover of a position change. + * ⛔ It does not reach a `code:` fed by a lower-case LOCAL in an object literal + * (`const c = x ? 'A' : 'B'; return { code: c }`) — the object-literal twin of + * the `assignconst` local #9568 closed for the assign position. `objlithelper` + * declines it (the identifier is not a parameter), `objlitconst` needs + * SCREAMING_SNAKE, and `objlit` needs a quote. UNCLOSED and UNCENSUSED, on the + * record here rather than in the gap between three shapes. + * ⛔ It does not admit the ANONYMOUS ARROW, for the reason below: a nameless + * helper offers nothing for a call scan to anchor on. */ export const OBJECT_LITERAL_CODE_HELPER_BLINDNESS = Object.freeze({ /** - * The two spellings of the unseen position. Each is a complete helper whose - * only difference from `SAME_GENRE_CONTROL` is the stamping line. + * The two spellings of the position, now RECOGNISED. Each is a complete + * helper whose only difference from `SAME_GENRE_CONTROL` is the stamping + * line, and `--self-test` drives all three: the assignment control and both + * object-literal spellings must now derive the SAME site, which is how a + * closure that only half-works is caught. */ shorthand: ` return { severity: 'error', code, message };`, longhand: ` return { severity: 'error', code: code, message };`, /** - * The SAME-GENRE POSITIVE CONTROL: the identical helper stamping through the - * assignment `codehelper` does implement. `--self-test` runs both, so the - * zero on the object-literal spellings is a READING rather than a harness - * that stopped working — the discipline `KEBAB_DIAGNOSTIC_VOCABULARY` above - * records, applied to a different axis. + * The SAME-GENRE control: the identical helper stamping through the + * assignment `codehelper` implements. It was the witness that the zero on the + * object-literal spellings was a READING rather than a broken harness; it is + * now the EQUIVALENCE partner, and it is still checked on every run, because + * "both derive the same thing" reads identically when both are broken. */ control: ` const e = new Error(message);\n (e as any).code = code;\n return e;`, /** Unregistered in both `StandardErrorCode` and the ledger, in both casings. */ probe: 'HELPER_SCREAMING', probeLowercase: 'helper_lowercase', /** - * Blindness ②: the declaration forms `enclosingDeclaration` cannot see. + * Declaration forms `enclosingDeclaration` still cannot see. * - * [#13226] `class method` came OFF this list — `METHOD_HEADER` recognises it. + * [#13226] `class method` came off this list — `METHOD_HEADER` recognises it. * The anonymous arrow stays, and stays for a reason that is not laziness: it * has no NAME, and every one of `helperCodesFor`'s call scans is built from * the declaration's name. A nameless helper can therefore produce neither a @@ -596,21 +609,29 @@ export const OBJECT_LITERAL_CODE_HELPER_BLINDNESS = Object.freeze({ invisibleDeclarationForms: Object.freeze(['anonymous arrow']), /** [#13226] Declaration forms that ARE recognised, in `DECL_HEADER_RE` order. */ recognisedDeclarationForms: Object.freeze(['function', 'constructor', 'const/let/var arrow', 'class method']), - /** The live instance, read as evidence only — it is NOT edited by this card. */ + /** The live instance the two closures together made reachable. */ liveInstance: 'packages/sdui-parser/src/parse.ts Parser#error(code, message, start?, tag?)', /** - * Measured blast radius of closing blindness ①, `packages/**` non-test source. - * [#13226] re-derived on today's tree — see the drift note in the prose above. + * [#13233] What closing the POSITION actually moved, re-derived on this tree + * through the real `deriveSites` rather than through a separate instrument. */ measured: Object.freeze({ - helpers: 14, - callSitesNewlyReached: 116, + helpers: 13, + callSitesNewlyReached: 117, newVerdictRows: 29, - undischargeableUnresolved: 5, + unresolvedHelpers: 5, + unresolvedHelpersDischarged: 5, unregisteredWireCodesHiding: 0, + /** + * Helpers whose `code` parameter carries no error code at all — an SMS + * one-time password and a YAML fence body. The precision the object-literal + * position cannot have, kept as a NUMBER so that "the widening is clean" + * cannot quietly become the story. + */ + nonErrorCodeHelpersReached: 2, }), /** - * [#13226] What closing blindness ② actually moved on the tree: NOTHING. + * [#13226] What closing blindness ② moved on the tree: NOTHING. * Kept as a number because "the fix changed no output" is the kind of claim * that rots into "the fix did nothing" — the positive control in `--self-test` * is what says the recognizer fires. @@ -1121,6 +1142,49 @@ function resolveFromWorkspacePackage(name, spec, { scanned, packageDirs } = {}) * reported, never dropped. */ +/** + * [#13233] The innermost enclosing OPEN BRACKET for each offset in `indices`, + * as `{ ch, at }` — or `null` where the offset is at bracket depth zero, and + * ABSENT from the map where the offset is inside a string, a template or a + * regex literal. + * + * This is the guard that lets a textual scan say "object literal" rather than + * guess it. `objlithelper`'s regex has to admit a leading `,` to reach a + * property that is not the first one, and that same `,` is what separates + * arguments (`f(a, code, b)`) and array elements (`[a, code]`) — three + * positions written identically, distinguished only by which bracket is open. + * + * ⚠️ Absent-not-null for a match inside a string is DELIBERATE and is the one + * place this shape is stricter than its siblings. Every other SHAPE regex runs + * over the `maskComments`ed source, which by contract leaves STRINGS standing, + * so `'{ code, message }'` written inside a string literal matches them. The + * quote-skipping pass below never visits those offsets, so the caller sees no + * opener and declines. Declining is right — a brace inside a string opens + * nothing — and it is stated here rather than discovered, because a shape that + * silently sees less than the list it belongs to is exactly the class of + * defect this gate keeps paying for. + * + * One pass per file, so the cost is linear in the source rather than in + * matches × source, which a per-match backward walk would be. + */ +export function enclosingOpeners(src, indices) { + const want = new Set(indices); + const out = new Map(); + const stack = []; + for (let i = 0; i < src.length; i += 1) { + if (want.has(i)) out.set(i, stack.length ? stack[stack.length - 1] : null); + const c = src[i]; + if (c === '(' || c === '[' || c === '{') stack.push({ ch: c, at: i }); + else if (c === ')' || c === ']' || c === '}') stack.pop(); + else if (c === "'" || c === '"' || c === '`') { + const quote = c; + i += 1; + while (i < src.length && src[i] !== quote) i += src[i] === '\\' ? 2 : 1; + } + } + return out; +} + /** The substring inside the parentheses opening at `open`, brackets balanced. */ export function sliceBalanced(src, open) { let depth = 0; @@ -1416,7 +1480,19 @@ export function helperCodesFor(ident, offset, src, resolveIdent = () => null) { * two-drivers-one-constant-name case) stays two findings. */ function addUnresolved(list, entry) { - if (list.some((u) => u.file === entry.file && u.shape === entry.shape && u.value === entry.value)) return; + // [#13233] `helper` joins the key rather than replacing part of it. A file + // can declare TWO helpers whose code parameter is spelled `code` — the + // object-literal position reaches both — and folding them into one entry + // would hand the declaration table a row that discharges a helper nobody + // classified. Every pre-existing caller leaves `helper` undefined on both + // sides, so this is identity for them. + if ( + list.some( + (u) => u.file === entry.file && u.shape === entry.shape && u.value === entry.value && u.helper === entry.helper, + ) + ) { + return; + } list.push(entry); } @@ -1479,7 +1555,18 @@ export function deriveSites({ registered, files, readFile, packageDirs = new Map for (const { rel, stripped } of scanned) { for (const shape of SHAPES) { shape.re.lastIndex = 0; - for (const m of stripped.matchAll(shape.re)) { + const matches = [...stripped.matchAll(shape.re)]; + // [#13233] The object-literal helper position needs to know WHICH bracket + // is open at each candidate, which is a per-file fact — computed once for + // the whole match set rather than per match. + const openers = + shape.resolve === 'objlithelper' + ? enclosingOpeners( + stripped, + matches.map((m) => m.index + m[0].indexOf('code')), + ) + : null; + for (const m of matches) { let code = m[1]; let emitAs = shape.name; const emit = (value, shapeName) => { @@ -1549,7 +1636,17 @@ export function deriveSites({ registered, files, readFile, packageDirs = new Map // 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' }); + // [#13233] The helper's NAME rides along so the discharge table can + // key on the declaration rather than on the parameter's spelling — + // two helpers in one file both taking a `code` are one entry + // otherwise, and one row would then discharge both. + addUnresolved(unresolved, { + file: rel, + shape: shape.name, + value: code, + reason: 'helper', + helper: enclosingDeclaration(stripped, m.index)?.name, + }); continue; } for (const hc of helperCodes) { @@ -1558,6 +1655,49 @@ export function deriveSites({ registered, files, readFile, packageDirs = new Map sites.push({ code: hc, file: rel, shape: shape.name }); } continue; + } else if (shape.resolve === 'objlithelper') { + // [#13233] The same join as `helper` above — a PARAMETER at the + // stamp, its literals at the call sites — one stamp position over. + // Two structural guards, in the order that makes the cheap one first. + const codeAt = m.index + m[0].indexOf('code'); + const opener = openers.get(codeAt); + // Not inside a `{`: an argument list, an array, or (absent from the + // map) a string. The regex has to admit `,` to reach a non-first + // property, so this is what makes the shape mean what it is named. + if (!opener || opener.ch !== '{') continue; + // `${code}` inside a template — a brace that opens an interpolation, + // not a literal. Belt and braces: the pass above already skips + // template bodies, so this fires only if a backtick was itself + // consumed as part of a skipped span. + if (opener.at > 0 && stripped[opener.at - 1] === '$') continue; + const objHelperCodes = helperCodesFor(code, codeAt, stripped, (arg) => + resolveConstant(arg, stripped, rel, readFile, ctx), + ); + // NOT a parameter — so this is not a code-carrying helper at all, and + // it is NOT this shape's to report. `code: CONST` is `objlitconst`'s + // (it resolves or reports there), `code: 'X'` is `objlit`'s, and a + // `code:` fed by a lower-case LOCAL in an object literal is reached + // by NEITHER — the object-literal twin of the `assignconst` local + // that #9568 closed for the assign position. That remainder is on + // the record in OBJECT_LITERAL_CODE_HELPER_BLINDNESS, unclosed and + // uncensused, rather than swallowed here. + if (objHelperCodes === null) continue; + if (objHelperCodes.length === 0) { + // A helper this scan CAN see whose callers all pass values it + // cannot reduce. Reported, never dropped — and, unlike every other + // unresolved reason, DISCHARGEABLE by a declaration row, because + // the author has no source-side remedy for it. See `reconcile`. + addUnresolved(unresolved, { + file: rel, + shape: shape.name, + value: code, + reason: 'helper', + helper: enclosingDeclaration(stripped, codeAt)?.name, + }); + continue; + } + for (const hc of objHelperCodes) emit(hc, 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 @@ -1616,13 +1756,58 @@ export function parseDeclaration(source) { return rows; } +/** + * [#13233] The second declared list: helpers this scan can SEE but cannot + * reduce, classified. + * + * Read textually and anchored on its own array, for the same reasons + * `parseDeclaration` is. + */ +export function parseUnresolvedHelpers(source) { + const start = source.indexOf('export const UNRESOLVED_CODE_HELPERS'); + if (start < 0) { + throw new Error(`${DECLARATION}: UNRESOLVED_CODE_HELPERS not found — the anchor moved.`); + } + const body = maskComments(source).slice(start); + const end = body.indexOf('\n];'); + if (end < 0) { + throw new Error(`${DECLARATION}: UNRESOLVED_CODE_HELPERS terminator not found — the anchor moved.`); + } + const rows = []; + for (const entry of body.slice(0, end).split(/\}\s*,/)) { + const file = /\bfile:\s*'([^']+)'/.exec(entry); + const helper = /\bhelper:\s*'([^']+)'/.exec(entry); + const param = /\bparam:\s*'([^']+)'/.exec(entry); + const shape = /\bshape:\s*'([^']+)'/.exec(entry); + const door = /\bdoor:\s*'([^']+)'/.exec(entry); + const verdict = /\bverdict:\s*'([^']+)'/.exec(entry); + if (!file || !helper || !param || !shape || !door || !verdict) continue; + rows.push({ + file: file[1], + helper: helper[1], + param: param[1], + shape: shape[1], + door: door[1], + verdict: verdict[1], + }); + } + return rows; +} + // --------------------------------------------------------------------------- // Reconciliation // --------------------------------------------------------------------------- const key = (s) => `${s.code}@${s.file}#${s.shape}`; -export function reconcile({ sites, declared, registered, unresolved }) { +/** + * [#13233] A helper's identity for the discharge table: WHICH declaration, and + * which of its parameters carries the code. Not the code itself — that is the + * one thing an unresolved helper does not have. + */ +const helperKey = (h) => `${h.helper ?? ''}(${h.param ?? h.value})@${h.file}#${h.shape}`; + +export function reconcile({ sites, declared, registered, unresolved, declaredHelpers = [] }) { const findings = []; const declaredByKey = new Map(declared.map((d) => [key(d), d])); const siteKeys = new Set(sites.map(key)); @@ -1705,7 +1890,46 @@ export function reconcile({ sites, declared, registered, unresolved }) { } } + // [#13233] The DISCHARGE half, and the one place this gate lets a declaration + // answer an `unresolved` instead of the source. + // + // ⚠️ Restricted to `reason: 'helper'`, and the restriction is the whole + // argument. The other two reasons name a remedy the author can carry out — + // `constant` says resolve it or teach the resolver the spelling, `reassigned` + // says spell it `const` — so a row there would buy an exemption from work + // that is possible. A HELPER whose callers pass values this scan cannot + // reduce has no such remedy: the callers may be in another package + // (`sendError` has 0 in-file calls), or they may pass a vocabulary this gate + // has declared out of its own population (`Parser#error`'s 16 kebab + // diagnostics), or they may pass a genuine runtime value (`http-dispatcher`'s + // `thrown.code`). Left undischargeable, each of those is a RED with no + // verdict available — not a row somebody forgot, but a red nobody CAN clear. + // + // It is a WIDENING, not a weakening, and the baseline is what says so: on the + // parent commit these helpers produced no site AND no unresolved, because no + // shape reached them at all. After it they are seen, recorded with evidence, + // and reconciled in BOTH directions — a row whose helper stops being + // unresolved goes stale and REDS, exactly like a site row. ⛔ What it must + // never become is a place to park a helper nobody looked at: the row carries + // a door and a verdict on the same closed vocabularies every site row uses. + const helperRowByKey = new Map(declaredHelpers.map((r) => [helperKey(r), r])); + const unresolvedHelperKeys = new Set( + unresolved.filter((u) => u.reason === 'helper').map((u) => helperKey(u)), + ); + for (const row of declaredHelpers) { + if (unresolvedHelperKeys.has(helperKey(row))) continue; + findings.push({ + kind: 'stale-helper-row', + text: + `${DECLARATION} declares the code helper '${row.helper}(${row.param})' at ${row.file} ` + + `(${row.shape}) as unresolvable, but the scan no longer reports it. Either the helper moved or ` + + `went away, or its call sites now reduce to literals — in which case the codes are ordinary ` + + `sites and want ordinary rows. Delete this one.`, + }); + } + for (const u of unresolved) { + if (u.reason === 'helper' && helperRowByKey.has(helperKey(u))) continue; // [#10918] A reassigned mutable local has a literal in it — the reduction // is refused for a reason the author can act on, so the remedy named is the // one that applies. Telling them to "teach resolveConstant() the spelling" @@ -1713,7 +1937,14 @@ export function reconcile({ sites, declared, registered, unresolved }) { findings.push({ kind: 'unresolved-constant', text: - u.reason === 'reassigned' + u.reason === 'helper' + ? `${u.file}: the code helper '${u.helper ?? '?'}(${u.value})' (${u.shape}) is visible to this ` + + `scan, but none of its in-file call sites passes a value that reduces to a literal, so the ` + + `codes it stamps are unknown. Reported rather than dropped. This is the one unresolved ` + + `reason a DECLARATION can answer, because the source may hold no remedy: add a row to ` + + `UNRESOLVED_CODE_HELPERS in ${DECLARATION} with a door, a verdict and the evidence — or, if ` + + `the callers can spell literals, make them and let it become ordinary sites.` + : u.reason === 'reassigned' ? `${u.file}: '${u.value}' (${u.shape}) is a \`let\`/\`var\` this file also REASSIGNS, so its ` + `initializer is not the set of codes it stamps and reducing it would report a value the ` + `program may never send. Reported rather than dropped. Make it a \`const\` (a ternary or a ` + @@ -1841,7 +2072,24 @@ function selfTest() { `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');`, + // [#13233] the same helper, one stamp position over. + objlithelper: + `function diag(code: string, msg: string) {\n` + + ` return { severity: 'error', code, message: msg };\n}\n` + + `report(diag('OBJ_HELPER_ONE', 'x'));`, }; + // [#13233] Every PUBLISHED shape carries a sample, checked rather than + // assumed. The per-shape loop below iterates `samples`, not `SHAPES`, so a + // shape added without one is simply never pinned — and the run still prints a + // confident "N shapes OK" with N one too small. That is this file's own + // recurring defect (a published list whose price is that an unrecognised + // member reports nothing, SILENTLY) turned on the pin harness itself, and it + // was live: `objlithelper` landed unpinned and the count read 8 of 9. + ok( + SHAPES.every((s) => s.name in samples), + `SHAPES publishes ${SHAPES.map((s) => s.name).filter((n) => !(n in samples)).join(', ')} with no ` + + '--self-test sample, so nothing pins it. Add one in the same edit that adds the shape.', + ); const registered = new Set(['ALREADY_REGISTERED']); for (const [name, source] of Object.entries(samples)) { const { sites } = deriveSites({ @@ -2776,28 +3024,29 @@ function selfTest() { } } - // [#13131] The OBJECT-LITERAL stamp position inside a code-carrying helper, - // and the CLASS-METHOD declaration form, are both outside this gate — - // MEASURED (see OBJECT_LITERAL_CODE_HELPER_BLINDNESS above) and declared out - // rather than closed, because the round that measured them was scoped to - // measurement. Pinned HERE, in the one place the declaration lives, so a - // later widening of either half cannot land silently: something fails, and - // what fails names the decision. + // [#13233] The OBJECT-LITERAL stamp position inside a code-carrying helper is + // now IN this gate's population, and these are the pins that say so. They + // were a set of ZEROS for two rounds — the position measured and declared out + // while the measuring card's scope forbade widening — and flipping them is + // the deliberate act that records the widening. ⛔ Do not soften one back + // into a zero to make a change pass: the zero meant "declared blind" and the + // assertion now means "reached", and those are different claims about the + // tree. // - // ⚠️ Nothing below asserts a bare zero. Every zero is paired with the + // ⚠️ Nothing below asserts a bare number. Every assertion is paired with the // SAME-GENRE POSITIVE CONTROL — the identical helper stamping through the - // assignment this gate does implement — so a harness that stopped working - // fails on the control instead of passing on the subject. + // assignment this gate has always implemented — because "the object-literal + // spellings derive what the assignment spelling derives" reads exactly the + // same when BOTH have stopped working. { const B = OBJECT_LITERAL_CODE_HELPER_BLINDNESS; - const DECISION = - 'a code-carrying helper that stamps through an OBJECT LITERAL is now visible to a gate. ' + - 'That is a gate-POPULATION change, and [#13226] re-measured its blast radius on the current tree: ' + - `${OBJECT_LITERAL_CODE_HELPER_BLINDNESS.measured.callSitesNewlyReached} newly reached ` + - `call sites, ${OBJECT_LITERAL_CODE_HELPER_BLINDNESS.measured.newVerdictRows} new verdict rows in ` + - `${DECLARATION}, and ${OBJECT_LITERAL_CODE_HELPER_BLINDNESS.measured.undischargeableUnresolved} ` + - 'UNDISCHARGEABLE unresolved findings. Land the rows and the declaration together, and rewrite ' + - 'OBJECT_LITERAL_CODE_HELPER_BLINDNESS — do not adjust this pin to match.'; + const REGRESSION = + 'the object-literal stamp position is UNREACHED again. #13233 closed it (shape `objlithelper`, ' + + `guarded by enclosingOpeners + helperCodesFor) at a measured cost of ` + + `${B.measured.newVerdictRows} verdict rows and ${B.measured.unresolvedHelpers} classified helpers in ` + + `${DECLARATION}. If that closure is being reverted, the rows and UNRESOLVED_CODE_HELPERS go with ` + + 'it — a half-revert leaves the table asserting sites no shape derives. ⛔ Do not adjust this pin ' + + 'to match a regression.'; const helper = (body, probe) => `export function postureError(code: string, message: string) {\n${body}\n}\n` + @@ -2813,34 +3062,40 @@ function selfTest() { return n + findViolations(source, REL).length; }; - // ① BLINDNESS ①, the stamp POSITION. The control fires; the two - // object-literal spellings of the same helper are seen by NOTHING — - // not matched, so not reported, and not unresolved either. + // ① BLINDNESS ① — CLOSED. The control fires, and so do BOTH object-literal + // spellings, in BOTH casings, deriving the SAME code the assignment + // spelling derives. The equivalence is the assertion; a shape that + // matched but resolved to something else would pass a "did it match" + // test and fail this one. const control = helper(B.control, B.probe); - ok(matched(control) > 0, `the same-genre control matches no recognizer at all — the pin below cannot read as a zero`); + ok(matched(control) > 0, `the same-genre control matches no recognizer at all — the comparison below is dead`); ok( derive(control).sites.some((s) => s.shape === 'codehelper' && s.code === B.probe), - 'the assignment-spelled code helper no longer derives a `codehelper` site — the control for #13131 is dead', + 'the assignment-spelled code helper no longer derives a `codehelper` site — the control for #13233 is dead', ); for (const [name, body] of [['shorthand', B.shorthand], ['longhand', B.longhand]]) { for (const probe of [B.probe, B.probeLowercase]) { const source = helper(body, probe); - ok(matched(source) === 0, `a recognizer now MATCHES the ${name} object-literal code helper ('${probe}') — ${DECISION}`); + ok(matched(source) > 0, `no recognizer matches the ${name} object-literal code helper ('${probe}') — ${REGRESSION}`); const { sites, unresolved } = derive(source); ok( - sites.length === 0 && unresolved.length === 0, - `the ${name} object-literal code helper ('${probe}') now derives ${sites.length} site(s) and ` + - `${unresolved.length} unresolved — ${DECISION}`, + sites.some((s) => s.shape === 'objlithelper' && s.code === probe), + `the ${name} object-literal code helper ('${probe}') derives no \`objlithelper\` site — ${REGRESSION}`, + ); + ok( + unresolved.length === 0, + `the ${name} object-literal code helper ('${probe}') reduced to nothing though its caller ` + + `spells a literal — the call scan, not the recognizer, is what broke`, ); } } - // [#13226] The BOUNDARY between the two blindnesses, pinned as its own - // case because closing ② made it reachable to reason about for the first - // time. A class method that stamps through an OBJECT LITERAL is the card's - // live instance (`Parser#error`), and it must STILL derive nothing: the - // declaration form is now seen, the stamp POSITION is not. This is the - // assertion that says which half of the work is done — if it starts - // failing, ① was widened, and the rows and the declaration owe an update. + // [#13233] The BOUNDARY between the two blindnesses. This case asserted for + // one round that a class-method-through-object-literal helper derives + // NOTHING — it was the tripwire that said which half of the work was done, + // and #13233 is the half that makes it fire. It is kept, inverted, because + // the card's live instance (`Parser#error`) is exactly this shape and needs + // BOTH closures: the declaration form (#13226) and the position (#13233). + // Either one regressing puts this back to zero. { const methodObjLit = `class Thing {\n private fail(code: string, message: string) {\n` + @@ -2850,13 +3105,116 @@ function selfTest() { ok( decl && decl.isMethod && parseParamNames(decl.params).includes('code'), 'the declaration half of the boundary case is dead — `enclosingDeclaration` cannot see the method, ' + - 'so the zero below would be blindness ② again rather than blindness ①', + 'so the site below would be arriving through some other form than the one this case is about', + ); + const { sites } = derive(methodObjLit); + ok( + sites.some((s) => s.shape === 'objlithelper' && s.code === B.probe), + `a CLASS-METHOD helper stamping through an OBJECT LITERAL derives no site — ${REGRESSION}`, + ); + } + + // [#13233] The STRUCTURAL guard, which is the whole difference between this + // shape and the regex instrument #13131 discarded. `objlithelper`'s pattern + // has to admit a leading `,` to reach a property that is not the first one, + // and that same `,` appears in an ARGUMENT LIST and an ARRAY. Only the + // enclosing bracket tells them apart, so each non-`{` bracket is pinned + // against the identical helper body — measured on the real tree as six + // false helpers out of nineteen before the guard. + { + const argList = + `export function ship(a: string, code: string, b: string) {\n` + + ` return send(a, code, b);\n}\n` + + `export function go() { return ship('x', '${B.probe}', 'y'); }\n`; + const array = + `export function ship(a: string, code: string) {\n` + + ` return [a, code];\n}\n` + + `export function go() { return ship('x', '${B.probe}'); }\n`; + for (const [what, source] of [['an argument list', argList], ['an array literal', array]]) { + const { sites, unresolved } = derive(source); + ok( + !sites.some((s) => s.shape === 'objlithelper') && unresolved.length === 0, + `\`code\` passed through ${what} was read as an object-literal stamp — the enclosing-bracket ` + + 'guard is gone, and every `f(a, code, b)` in the repo is now a finding', + ); + } + // The positive half of the same guard: the identical parameter in a real + // object literal IS a stamp. Without this, deleting the shape entirely + // would pass the two assertions above. + const objLit = + `export function ship(a: string, code: string) {\n` + + ` return { a, code };\n}\n` + + `export function go() { return ship('x', '${B.probe}'); }\n`; + ok( + derive(objLit).sites.some((s) => s.shape === 'objlithelper' && s.code === B.probe), + 'the enclosing-bracket guard rejects a genuine object literal too — the pins above are passing ' + + 'because nothing is recognised at all', + ); + } + + // [#13233] The DISCHARGE path, and the restriction that keeps it from being + // an exemption. A `helper` unresolved is answerable by a row; a `constant` + // unresolved is NOT, because its remedy is in the source. + { + const opaque = + `export function fail(code: string, message: string) {\n` + + ` return { severity: 'error', code, message };\n}\n` + + `export function go(x: unknown) { return fail(readCode(x), 'm'); }\n`; + const { unresolved } = derive(opaque); + ok( + unresolved.length === 1 && unresolved[0].reason === 'helper' && unresolved[0].helper === 'fail', + `a helper whose caller passes a runtime value produced ${JSON.stringify(unresolved)} rather than one ` + + 'unresolved entry naming the helper — the discharge table cannot key on what is not recorded', + ); + const row = { + file: REL, + helper: 'fail', + param: 'code', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + }; + const args = { sites: [], declared: [], registered: new Set(), unresolved }; + ok( + reconcile({ ...args, declaredHelpers: [row] }).length === 0, + 'a declared UNRESOLVED_CODE_HELPERS row did not discharge its unresolved helper — the red it ' + + 'exists to clear is unclearable', + ); + ok( + reconcile({ ...args, declaredHelpers: [] }).some((f) => f.kind === 'unresolved-constant'), + 'an UNDECLARED unresolved helper produced no finding — the discharge is unconditional, which ' + + 'makes the whole position invisible again', + ); + ok( + reconcile({ ...args, declaredHelpers: [{ ...row, helper: 'somethingElse' }] }).some( + (f) => f.kind === 'unresolved-constant', + ) && + reconcile({ ...args, declaredHelpers: [{ ...row, helper: 'somethingElse' }] }).some( + (f) => f.kind === 'stale-helper-row', + ), + 'a row naming a DIFFERENT helper still discharged this one (and did not report itself stale) — ' + + 'one row would then cover every helper in the file', + ); + // ⛔ The restriction. `constant` and `reassigned` name a remedy the author + // can carry out, so a row must NOT buy an exemption from doing it. + const constUnresolved = [{ file: REL, shape: 'classconst', value: 'SOME_CONST', reason: 'constant' }]; + ok( + reconcile({ + sites: [], + declared: [], + registered: new Set(), + unresolved: constUnresolved, + declaredHelpers: [{ ...row, shape: 'classconst', param: 'SOME_CONST', helper: '' }], + }).some((f) => f.kind === 'unresolved-constant'), + 'a declaration row discharged an UNRESOLVABLE CONSTANT — the discharge is meant only for the ' + + 'one reason with no source-side remedy, and it has become a general exemption', ); - const { sites, unresolved } = derive(methodObjLit); + // Both directions: a row whose helper the scan no longer reports is stale. ok( - sites.length === 0 && unresolved.length === 0, - `a CLASS-METHOD helper stamping through an OBJECT LITERAL now derives ${sites.length} site(s) and ` + - `${unresolved.length} unresolved — ${DECISION}`, + reconcile({ sites: [], declared: [], registered: new Set(), unresolved: [], declaredHelpers: [row] }).some( + (f) => f.kind === 'stale-helper-row', + ), + 'a row for a helper the scan no longer reports did not go stale — the list can only ever grow', ); } @@ -3015,8 +3373,10 @@ function main() { ); const { sites, unresolved } = deriveSites({ registered, files, readFile, packageDirs }); - const declared = parseDeclaration(readFileSync(join(ROOT, DECLARATION), 'utf8')); - const findings = reconcile({ sites, declared, registered, unresolved }); + const declarationSource = readFileSync(join(ROOT, DECLARATION), 'utf8'); + const declared = parseDeclaration(declarationSource); + const declaredHelpers = parseUnresolvedHelpers(declarationSource); + const findings = reconcile({ sites, declared, registered, unresolved, declaredHelpers }); // [#9098] The door-typing half. `walkSources` skips the door file only if it // is a test or the declaration — it is neither, so read it from the scanned @@ -3040,18 +3400,21 @@ function main() { `${KEBAB_DIAGNOSTIC_VOCABULARY.governedBy}: no grammar in this gate or in check:error-code-casing `+ `admits a hyphen, so coverage of them is zero BY DECLARATION, not by accident — see `+ `KEBAB_DIAGNOSTIC_VOCABULARY in this file, pinned by --self-test.` + - `\n [#13131] a code-carrying helper that stamps through an OBJECT LITERAL ({ code }) is outside ` + - `this gate: no shape matches, so there is no site AND no unresolved. Coverage of it is zero BY ` + - `DECLARATION, not by accident. [#13226] the CLASS-METHOD declaration form is no longer part of ` + - `that bound — it is recognised, so a method helper stamping in the ASSIGNMENT position resolves ` + - `like any other; what still drops the method helpers in this tree is the stamp POSITION above. ` + - `Of the declaration forms, only the ANONYMOUS ARROW remains unseen (it has no name for a call ` + - `scan to anchor on). ` + - `Measured blast radius of closing the position: ${OBJECT_LITERAL_CODE_HELPER_BLINDNESS.measured.callSitesNewlyReached} ` + - `call sites, ${OBJECT_LITERAL_CODE_HELPER_BLINDNESS.measured.newVerdictRows} new verdict rows, ` + - `${OBJECT_LITERAL_CODE_HELPER_BLINDNESS.measured.undischargeableUnresolved} undischargeable unresolved, ` + + `\n [#13233] a code-carrying helper that stamps through an OBJECT LITERAL ({ code }) IS now in ` + + `this gate's population — the \`objlithelper\` shape, guarded structurally (the enclosing bracket ` + + `must be a \`{\`, and the identifier must be a parameter). With [#13226]'s class-method form, the ` + + `card's live instance (Parser#error) is reached. What it cost on this tree: ` + + `${OBJECT_LITERAL_CODE_HELPER_BLINDNESS.measured.callSitesNewlyReached} call sites, ` + + `${OBJECT_LITERAL_CODE_HELPER_BLINDNESS.measured.newVerdictRows} new verdict rows, ` + + `${OBJECT_LITERAL_CODE_HELPER_BLINDNESS.measured.unresolvedHelpers} helper(s) reducing to nothing ` + + `(all classified in UNRESOLVED_CODE_HELPERS), ` + `${OBJECT_LITERAL_CODE_HELPER_BLINDNESS.measured.unregisteredWireCodesHiding} unregistered wire code(s) ` + - `hiding today — see OBJECT_LITERAL_CODE_HELPER_BLINDNESS in this file, pinned by --self-test.`; + `hiding. ⚠️ Its precision is lower than the assignment position's: ` + + `${OBJECT_LITERAL_CODE_HELPER_BLINDNESS.measured.nonErrorCodeHelpersReached} of the ` + + `${OBJECT_LITERAL_CODE_HELPER_BLINDNESS.measured.helpers} helpers reached carry no error code at ` + + `all, and no sibling-key test separates them (measured). Still unseen: a \`code:\` fed by a ` + + `lower-case LOCAL in an object literal, and the ANONYMOUS ARROW declaration form — see ` + + `OBJECT_LITERAL_CODE_HELPER_BLINDNESS in this file, pinned by --self-test.`; if (argv.includes('--report')) { console.log('Derived sites (code / shape / file):');