From d7e22a3da997c84ff7c7ea003d438f10112ff52f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 17:43:53 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(tooling):=20fifth=20population=20?= =?UTF-8?q?=E2=80=94=20plain=20function=20declarations=20join=20Rule=203's?= =?UTF-8?q?=20sink=20pass,=20and=20the=20gate=20states=20its=20own=20scan?= =?UTF-8?q?=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fifth widening (#13156's A half, adjudicated 2026-08-29): a plain `function` DECLARATION is transparent to the climb exactly when its NAME is consumed from a recognised customer-facing position — the same narrow clause as the fourth population, one declaration form over, never an unconditional crawl of function bodies. Its literals get their own `functionDeclared` bucket with its own blindness floor, so the declaration clause rotting cannot hide behind the arrow members. The C half (shared with the family member card): the gate's output now prints Rule 3's scan boundary — the root, the not-scanned siblings with the deferral pointer, and the recognised sink-shape list — derived from the constants the scan reads, pinned derived-vs-derived in --self-test. At this commit the widened gate is deliberately RED on the real tree (reproduce-first): 4 violations — 2 functionDeclared, 2 message via the new closure into declarations. The strip is the next commit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KX8wnyjStaZcuMyAMNsy3N --- scripts/check-doc-authoring.mjs | 279 +++++++++++++++++++++++++++++--- 1 file changed, 258 insertions(+), 21 deletions(-) diff --git a/scripts/check-doc-authoring.mjs b/scripts/check-doc-authoring.mjs index 0efd4a7a2f..54d5ae4dfd 100644 --- a/scripts/check-doc-authoring.mjs +++ b/scripts/check-doc-authoring.mjs @@ -455,6 +455,36 @@ const INTERNAL_ID = new RegExp(INTERNAL_ID_SOURCE, 'g'); // error-map option away from `message`); a plain string there is a `message`, // since nothing was built in a function. // +// ## The FIFTH population: plain `function` DECLARATIONS (#13156) +// +// The fourth population's clause reaches a function only through the positions +// a function EXPRESSION can occupy — a property value, a call argument, a const +// initializer. A plain `function unknownKeyError(key) { … }` declaration +// occupies none of them: it is a statement, its parent is the SourceFile, so +// the transparency question ("does the FUNCTION sit in a recognised position?") +// had no branch that could ever answer yes, and `collectTextSinkConsts` dropped +// its seeded name at the cleanup pass because only `VariableDeclaration`s were +// registered as declarations. A declaration-scoped helper building refusal +// prose was structurally invisible for the same reason the fourth population +// was — one declaration form over. Adjudicated 2026-08-29 (Class-1, director +// seat, executing the #13002 ruling verbatim: 「同意」, "the ban follows the +// audience, not the spelling"): same audience, same moment, so the declaration +// form joins the sink pass UNDER THE SAME NARROW CLAUSE — a declaration is +// transparent only when its NAME is consumed from a recognised customer-facing +// position (seeded or closed over by `collectTextSinkConsts`, exactly like a +// hoisted const arrow). ⛔ Never an unconditional crawl of arbitrary function +// bodies: an unconsumed helper's body stays unreachable, pinned in --self-test. +// +// Their literals are bucketed `functionDeclared`, NOT folded into +// `functionBuilt`, for the same reason `functionBuilt` was not folded into +// `message`: the blindness floor is PER BUCKET, and the arrow/expression +// members would hold a shared floor up while the declaration clause rotted back +// to `undefined` — the exact silence this population was found by. (The +// options-FACTORY clause is form-agnostic and predates this widening: a +// `function navSurface(): StrictObjectOptions` was already reachable through +// `buildsStrictObjectOptions`, bucketed `functionBuilt`; it keeps that bucket, +// since that clause's rot is `functionBuilt`'s floor to catch.) +// // ## Why the positions alone are not enough: the hoisted-const spelling // // A position-only matcher reads `guidance: { where: '…' }` and stops at the @@ -535,6 +565,38 @@ const POSITIONAL_MESSAGE_CALLS = new Set([ 'positive', 'nonnegative', 'multipleOf', 'nonempty', 'gt', 'gte', 'lt', 'lte', ]); +/** + * Rule 3's scan boundary, stated BY THE GATE'S OWN OUTPUT — the C half of the + * 2026-08-29 adjudication on #13156/#13179, recommended by #13002's triage + * under both of its options because the boundary was invisible from the + * output: `0 violations` over four populated-but-unreached populations and + * `0 violations` over a clean tree printed the same line, and a sweep that was + * locally green could not SAY it had never opened a sibling package. Every + * clause here is derived from the constants the scan actually reads — never + * re-spelled — so a boundary move shows up in the output the same commit it + * happens. + * + * The root limit is deliberate and adjudicated: the root extension beyond + * `packages/spec/src` was DEFERRED on #13179 (today's measured cross-package + * population: one message), with the revival condition codified there — a + * later census finding same-audience ids outside the root reopens it as an + * instrument card. This line is what makes that deferral honest: the next + * boundary move is visible from the output instead of from an archaeology dig. + */ +function scanBoundaryLines() { + return [ + `ℹ Rule 3 scan boundary — root: ${SPEC_SOURCE_ROOT}/ only (*.ts|*.mts, test/spec/bench` + + ' files excluded; customer-facing refusal prose in SIBLING PACKAGES is NOT scanned —' + + ' root extension deferred on #13179, revival condition codified there).', + ' recognised sink shapes: `message:` / `error:` properties · positional messages on' + + ` ${POSITIONAL_MESSAGE_CALLS.size} zod validators · strictObject option keys` + + ` (${[...STRICT_OPTION_KEYS].join(', ')}) · ${[...TOMBSTONE_CALLS].join('/')}()` + + ' tombstone prescriptions · .describe() prose · hoisted text-sink consts (fixed-point,' + + ' per module) · text built inside functions sitting in recognised positions — arrows/' + + 'expressions (functionBuilt) and named function declarations (functionDeclared).', + ]; +} + const posix = (p) => p.split(sep).join('/'); function walk(dir, out) { @@ -899,6 +961,16 @@ function collectTextSinkConsts(sf, ts) { }; const visit = (n) => { + // The fifth population (#13156): a plain `function` DECLARATION is a + // declaration too. Registering its NAME (body as the closure expression, + // exactly the role a const arrow's initializer plays) is what lets a seeded + // `message: unknownKeyError(k)` survive the cleanup pass below — without + // this line the name is deleted as "an import or a parameter" and the whole + // body stays invisible. Registration is NOT recognition: the name still + // becomes a sink only when a recognised position consumes it. + if (ts.isFunctionDeclaration(n) && n.name && n.body) { + decls.set(n.name.text, n.body); + } if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.initializer) { decls.set(n.name.text, n.initializer); if (/_RETIRED_KEY_GUIDANCE$/.test(n.name.text)) sinks.set(n.name.text, 'tombstone'); @@ -974,12 +1046,28 @@ function collectTextSinkConsts(sf, ts) { * @returns {{where: string, bucket: string}|undefined} */ function customerTextPosition(node, ts, sinkConsts = new Map(), fnDepth = 0) { + // The fifth population's clause (#13156): the question "does the FUNCTION + // sit in a recognised position?" arrives here recursively with the function + // node itself. A function EXPRESSION answers through its parent (a property, + // an argument, a const initializer — the climb below). A plain `function` + // DECLARATION has no such parent — it is a statement — so its recognised + // position is its NAME being consumed from one, which is exactly what + // `collectTextSinkConsts` measured. Same fixed point the hoisted const-arrow + // spelling rides on, one declaration form over. + if (ts.isFunctionDeclaration(node) && node.name && sinkConsts.has(node.name.text)) { + return { where: `via ${node.name.text}`, bucket: sinkConsts.get(node.name.text) }; + } let cur = node; let strictKey; /** * The fourth population's clause: `fn` encloses the literal and the climb * wants to leave through it. Transparent only if `fn` is itself somewhere - * customer-facing. + * customer-facing. A function DECLARATION that qualifies gets its own bucket + * (`functionDeclared`, the fifth population) — per-bucket floors are the only + * thing that can catch THIS clause rotting while the arrow members hold a + * shared floor up. The options-factory leg below predates the fifth + * population, reaches every function form through the return-type annotation, + * and keeps `functionBuilt` — that clause's rot is that bucket's to catch. */ const throughFunction = (fn) => { if (!fn || fnDepth >= 4) return undefined; @@ -989,9 +1077,10 @@ function customerTextPosition(node, ts, sinkConsts = new Map(), fnDepth = 0) { return { where: `strictObject ${strictKey} (built in a function)`, bucket: 'functionBuilt' }; } const outer = customerTextPosition(fn, ts, sinkConsts, fnDepth + 1); - return outer - ? { where: `${outer.where} (built in a function)`, bucket: 'functionBuilt' } - : undefined; + if (!outer) return undefined; + return ts.isFunctionDeclaration(fn) + ? { where: `${outer.where} (built in a function declaration)`, bucket: 'functionDeclared' } + : { where: `${outer.where} (built in a function)`, bucket: 'functionBuilt' }; }; // A bound, not a belief: refusal prose in this tree reaches ~14 concatenated // operands, and an unbounded climb would walk to the SourceFile and start @@ -1075,7 +1164,7 @@ function customerTextPosition(node, ts, sinkConsts = new Map(), fnDepth = 0) { */ function findCustomerTextIdViolations(source, file, ts) { const out = []; - const seen = { message: 0, strictObject: 0, tombstone: 0, describe: 0, functionBuilt: 0 }; + const seen = { message: 0, strictObject: 0, tombstone: 0, describe: 0, functionBuilt: 0, functionDeclared: 0 }; const sf = parseSourceFile(file, source); const sinkConsts = collectTextSinkConsts(sf, ts); const visit = (node) => { @@ -1472,7 +1561,7 @@ function selfTest() { console.error(`\n✗ check-doc-authoring self-test failed:\n${failures.join('\n')}\n`); process.exit(1); } - console.log('✓ check-doc-authoring self-test: scope wiring (.claude and the live docs/ corpus in, .claude/worktrees and docs/{audits,handoff,plans} out), detection, the dead-root hard error (red when a ROOT is renamed, green when restored), the empty-scan hard error (red when a root yields nothing and when the whole scan does, green when restored), the published-catalog internal-id rule (red on a planted id in prose, in a fenced comment and in the repo#NNNN spelling, green when removed; hex colours, version numbers, HTTP codes, array indices and the "#1" ordinal all pass; references/ reached, generated artifacts and the internal roots out; the `#` placeholder passes while the concrete ids it replaced stay red, with no exemption to reach for), the spec customer-facing-text internal-id rule (red on an id planted on a LATER line of a concatenated message — the shape a line-oriented census cannot see, proven here — and in a template chain, a positional validator message, the repo#NNNN spelling, a nested strictObject `guidance` prescription, a HOISTED guidance const, a `KeySetGuidance` const consumed only CROSS-MODULE in both the annotated and the `as const satisfies` spelling, a HOISTED refusal message, a `retiredKey()` tombstone, `new Map` and `Object.freeze` guidance tables, `.describe()` prose, and the nested `guidance` of a whole options table written `satisfies StrictObjectOptions`; green when removed; an ADR id on a tombstone, a `.default()` VALUE, `history`/`guidance` outside a strictObject options position, `extraKeys` key names and an inferred local that merely MENTIONS `KeySetGuidance` all pass; test bodies out; the seen floor is PER BUCKET so one matcher rotting while the others carry the total still reds; and the two TYPE ANCHORS are pinned on the predicate itself — the annotation, `satisfies` and `as const satisfies` spellings all read as a strictObject options position while some other satisfied type does not, and the `*_STRICT_OPTIONS` NAME branch still fires where no type is written at all — which is the only place they can be told apart, since end to end they are redundant), the fourth population — customer-facing text BUILT INSIDE A FUNCTION (red on an id in an inline `error: () =>` callback, in a const the callback only dispatches to, inside a `message:` builder function, RETURNED from a tombstone-prescription builder, in a `: StrictObjectOptions` options factory, and in a plain `error:` string; ⛔ the body of an ordinary helper and a local inside a recognised factory stay unswept, because the climb crosses a function only when the FUNCTION sits in a recognised position; and `functionBuilt` carries its own blindness floor, since an unrecognised spelling produces no flag SILENTLY) and the dispatch-gates declaration (every separator-less ROOT declared as a subtree, nothing declared this gate does not walk, the over-claim bounded to SKIP_PATHS) all hold.'); + console.log('✓ check-doc-authoring self-test: scope wiring (.claude and the live docs/ corpus in, .claude/worktrees and docs/{audits,handoff,plans} out), detection, the dead-root hard error (red when a ROOT is renamed, green when restored), the empty-scan hard error (red when a root yields nothing and when the whole scan does, green when restored), the published-catalog internal-id rule (red on a planted id in prose, in a fenced comment and in the repo#NNNN spelling, green when removed; hex colours, version numbers, HTTP codes, array indices and the "#1" ordinal all pass; references/ reached, generated artifacts and the internal roots out; the `#` placeholder passes while the concrete ids it replaced stay red, with no exemption to reach for), the spec customer-facing-text internal-id rule (red on an id planted on a LATER line of a concatenated message — the shape a line-oriented census cannot see, proven here — and in a template chain, a positional validator message, the repo#NNNN spelling, a nested strictObject `guidance` prescription, a HOISTED guidance const, a `KeySetGuidance` const consumed only CROSS-MODULE in both the annotated and the `as const satisfies` spelling, a HOISTED refusal message, a `retiredKey()` tombstone, `new Map` and `Object.freeze` guidance tables, `.describe()` prose, and the nested `guidance` of a whole options table written `satisfies StrictObjectOptions`; green when removed; an ADR id on a tombstone, a `.default()` VALUE, `history`/`guidance` outside a strictObject options position, `extraKeys` key names and an inferred local that merely MENTIONS `KeySetGuidance` all pass; test bodies out; the seen floor is PER BUCKET so one matcher rotting while the others carry the total still reds; and the two TYPE ANCHORS are pinned on the predicate itself — the annotation, `satisfies` and `as const satisfies` spellings all read as a strictObject options position while some other satisfied type does not, and the `*_STRICT_OPTIONS` NAME branch still fires where no type is written at all — which is the only place they can be told apart, since end to end they are redundant), the fourth population — customer-facing text BUILT INSIDE A FUNCTION (red on an id in an inline `error: () =>` callback, in a const the callback only dispatches to, inside a `message:` builder function, RETURNED from a tombstone-prescription builder, in a `: StrictObjectOptions` options factory, and in a plain `error:` string; ⛔ the body of an ordinary helper and a local inside a recognised factory stay unswept, because the climb crosses a function only when the FUNCTION sits in a recognised position; and `functionBuilt` carries its own blindness floor, since an unrecognised spelling produces no flag SILENTLY), the FIFTH population — prose built inside plain `function` DECLARATIONS (#13156: red on an id in a declaration consumed by `message:`, RETURNED to a `retiredKey()` argument, and in a const the declaration only dispatches to; its own `functionDeclared` bucket with its own floor, so the declaration clause rotting cannot hide behind the arrows; ⛔ an unconsumed declaration and one consumed only by an unrecognised call stay unswept — the clause is the fourth population\'s, one declaration form over, never an unconditional crawl), the Rule 3 boundary OUTPUT (names the scanned root, says sibling packages are not scanned, and lists every floored bucket — derived from the same constants the scan reads) and the dispatch-gates declaration (every separator-less ROOT declared as a subtree, nothing declared this gate does not walk, the over-claim bounded to SKIP_PATHS) all hold.'); } /** @@ -1531,13 +1620,23 @@ function selfTestRule3(expect) { " error: () => 'a machine name is a string — quote it.',", '});', ]; - const DOC_CLEAN = [...DOC_CLEAN_BASE, ...DOC_FUNCTION_BUILT].join('\n'); + // The fifth population, clean: prose built inside a plain `function` + // DECLARATION whose name a `message:` consumes (#13156). + const DOC_FUNCTION_DECLARED = [ + 'function unknownDocKeyLine(key: string): string {', + " return '`' + key + '` is not a recognised doc key — delete it.';", + '}', + 'export const F = z.object({ x: z.string() }).refine((v) => !!v.x, {', + " message: unknownDocKeyLine('x'),", + '});', + ]; + const DOC_CLEAN = [...DOC_CLEAN_BASE, ...DOC_FUNCTION_BUILT, ...DOC_FUNCTION_DECLARED].join('\n'); write('packages/spec/src/data/doc.ts', DOC_CLEAN); const target = write('packages/spec/src/ui/action.zod.ts', CLEAN); const scan = () => { let out = []; - const seen = { message: 0, strictObject: 0, tombstone: 0, describe: 0, functionBuilt: 0 }; + const seen = { message: 0, strictObject: 0, tombstone: 0, describe: 0, functionBuilt: 0, functionDeclared: 0 }; for (const f of collectSpecSourceFiles()) { const r = findCustomerTextIdViolations(readFileSync(f, 'utf8'), f, ts); out = out.concat(r.violations); @@ -1558,6 +1657,7 @@ function selfTestRule3(expect) { expect('the detector recognised a strictObject option string', r.seen.strictObject >= 1, true); expect('the detector recognised a tombstone prescription', r.seen.tombstone >= 1, true); expect('the detector recognised text built inside a function', r.seen.functionBuilt >= 1, true); + expect('the detector recognised text built inside a function DECLARATION', r.seen.functionDeclared >= 1, true); expect('the detector recognised a `.describe()` string', r.seen.describe >= 1, true); // Scope: a test body is out, an ordinary source is in. Asserted as a pair @@ -1992,6 +2092,77 @@ function selfTestRule3(expect) { expect('a plain `error:` string is a MESSAGE, not function-built', r.violations[0]?.bucket, 'message'); + // ── The FIFTH population: plain `function` DECLARATIONS (#13156) ──────── + // + // Adjudicated 2026-08-29 under the #13002 ruling's own sentence — the ban + // follows the audience, not the spelling — and under the SAME narrow + // clause as the fourth population: a declaration is transparent only when + // its NAME is consumed from a recognised customer-facing position. The + // negative cases at the end of the precision battery are the boundary. + + // RED #20 — the census's founding shape: a helper DECLARATION returning a + // `+` chain, its name consumed by `message:`. Both real members measured + // live (`listPositionFieldReferenceMessage`, `normalizedMemberMessage`) + // are spelled exactly like this. + writeFileSync(target, [ + "import { z } from 'zod';", + 'function unknownKeyError(key: string): string {', + " return '`' + key + '` has never been an action key — it was removed '", + " + 'from the contract (#12006). Delete it.';", + '}', + 'export const S = z.object({ a: z.string() }).refine((v) => !!v.a, {', + " message: unknownKeyError('legacyMode'),", + '});', + ].join('\n')); + r = scan(); + expect('an id built inside a plain function DECLARATION consumed by `message:` is RED', + r.violations.length, 1); + expect('the declaration red names the function it travelled through', + r.violations[0]?.where, 'via unknownKeyError (built in a function declaration)'); + expect('the declaration red gets its OWN bucket, not `functionBuilt` — per-bucket floors are ' + + 'the only thing that can catch this clause rotting on its own', + r.violations[0]?.bucket, 'functionDeclared'); + + // RED #21 — the tombstone consumption, through the `return` leg. + writeFileSync(target, [ + "import { z } from 'zod';", + "import { retiredKey } from '../shared/retired-key';", + 'function capRemoved(key: string): string {', + ' return `\\`DriverCapabilities.${key}\\` was removed in 17.0.0 (#4634, ADR-0049).`;', + '}', + 'export const S = z.object({', + " joins: retiredKey(capRemoved('joins')),", + '});', + ].join('\n')); + r = scan(); + expect('an id RETURNED from a tombstone-builder function DECLARATION is RED', + r.violations.length, 1); + expect('the tombstone-declaration red names the function', + r.violations[0]?.where, 'via capRemoved (built in a function declaration)'); + expect('the tombstone-declaration red is bucketed with the declaration clause', + r.violations[0]?.bucket, 'functionDeclared'); + + // RED #22 — the closure INTO a declaration: a module const the declaration + // only dispatches to. Registering declarations in the sink pass is what + // makes the const a sink — measured live on `SUBMIT_REDIRECT_RULING` + // (`ui/view.zod.ts`), which rode exactly this shape unseen. + writeFileSync(target, [ + "import { z } from 'zod';", + 'const RULING_SENTENCE =', + " 'ruled 2026-08-11 on #7496 — the redirect pair is refused at authoring time.';", + 'function submitRedirectMessage(kind: string): string {', + " return 'One `' + kind + '` declares two destinations. ' + RULING_SENTENCE;", + '}', + 'export const S = z.object({ a: z.string() }).refine((v) => !!v.a, {', + " message: submitRedirectMessage('form'),", + '});', + ].join('\n')); + r = scan(); + expect('an id in a const dispatched from inside a seeded function DECLARATION is RED', + r.violations.length, 1); + expect('the dispatched-const red names the const it travelled through', + r.violations[0]?.where, 'via RULING_SENTENCE'); + // ── Precision: what must NEVER fire ───────────────────────────────────── // // ⛔ The clause is NOT "climb through function bodies". These three are the @@ -2012,6 +2183,33 @@ function selfTestRule3(expect) { ].join('\n')); expect('precision — an ordinary helper\'s body is NOT swept', scan().violations.length, 0); + // The same boundary, declaration form (#13156). ⛔ The widening is NOT an + // unconditional crawl of function bodies: a plain `function` DECLARATION + // nothing customer-facing consumes stays unreachable — a `.default()` + // VALUE argument is not a recognised consumer. + writeFileSync(target, [ + "import { z } from 'zod';", + 'function slugFor(kind: string): string {', + " return kind + '-#4286';", + '}', + "export const S = z.object({ a: z.string().default(slugFor('x')) });", + ].join('\n')); + expect('precision — a function DECLARATION nothing customer-facing consumes is NOT swept', + scan().violations.length, 0); + + // ...and a declaration consumed only by an UNRECOGNISED call is out too — + // this is the honest edge of the clause, stated rather than smuggled: the + // `warn()`-fed helpers in the real tree are reached by the CENSUS, not by + // this recogniser, until their consumer is a recognised sink. + writeFileSync(target, [ + 'function telemetryLine(tag: string): string {', + " return 'sampled ' + tag + ' (#4286)';", + '}', + 'export function record(tag: string) { console.warn(telemetryLine(tag)); }', + ].join('\n')); + expect('precision — a declaration consumed only by an unrecognised call is NOT swept', + scan().violations.length, 0); + // A function that IS recognised still only yields its recognised POSITIONS. // A local inside the factory, and a key that is not a STRICT_OPTION_KEY, // stay unreachable — crossing the function boundary does not turn the body @@ -2050,14 +2248,26 @@ function selfTestRule3(expect) { // tree, which is exactly how these 28 literals went unseen. writeFileSync(target, CLEAN); // The same baseline MINUS its one function-built member — the tree an - // unrecognised spelling leaves behind. - write('packages/spec/src/data/doc.ts', DOC_CLEAN_BASE.join('\n')); + // unrecognised spelling leaves behind. The DECLARED member stays, so the + // zero is about the arrow/expression clause and nothing else. + write('packages/spec/src/data/doc.ts', [...DOC_CLEAN_BASE, ...DOC_FUNCTION_DECLARED].join('\n')); r = scan(); expect('the function-built bucket can go blind on its own (main reds on it)', r.seen.functionBuilt, 0); - expect('...while the other four stay populated, so the zero above is about THAT clause', + expect('...while the other five stay populated, so the zero above is about THAT clause', r.seen.message >= 1 && r.seen.strictObject >= 1 - && r.seen.tombstone >= 1 && r.seen.describe >= 1, true); + && r.seen.tombstone >= 1 && r.seen.describe >= 1 && r.seen.functionDeclared >= 1, true); + + // ...and the fifth population's floor, same discipline (#13156): only the + // DECLARED member gone — the silence a declaration clause rotting back to + // `undefined` leaves is caught by ITS floor, not the arrows'. + write('packages/spec/src/data/doc.ts', [...DOC_CLEAN_BASE, ...DOC_FUNCTION_BUILT].join('\n')); + r = scan(); + expect('the function-declared bucket can go blind on its own (main reds on it)', + r.seen.functionDeclared, 0); + expect('...while the other five stay populated, so the zero above is about the declaration clause', + r.seen.message >= 1 && r.seen.strictObject >= 1 + && r.seen.tombstone >= 1 && r.seen.describe >= 1 && r.seen.functionBuilt >= 1, true); write('packages/spec/src/data/doc.ts', DOC_CLEAN); // restore the baseline // A validator's VALUE argument is not prose. `.min(3, …)` takes a message; @@ -2135,13 +2345,14 @@ function selfTestRule3(expect) { // gone silent. Emptying only the `.describe()` bucket must still register // as blindness in that bucket while the others stay positive. write('packages/spec/src/data/doc.ts', - [...DOC_CLEAN_BASE.filter((l) => !l.includes('.describe(')), ...DOC_FUNCTION_BUILT].join('\n')); + [...DOC_CLEAN_BASE.filter((l) => !l.includes('.describe(')), + ...DOC_FUNCTION_BUILT, ...DOC_FUNCTION_DECLARED].join('\n')); r = scan(); expect('one bucket can go blind while the others stay populated — describe', r.seen.describe, 0); expect('...and the surviving buckets really did stay positive (so the zero above is about ' + 'that bucket, not an emptied tree)', r.seen.message >= 1 && r.seen.strictObject >= 1 && r.seen.tombstone >= 1 - && r.seen.functionBuilt >= 1, true); + && r.seen.functionBuilt >= 1 && r.seen.functionDeclared >= 1, true); // ...and the whole-population version: no recognised string of any kind. writeFileSync(target, "export const S = 1;\n"); @@ -2151,6 +2362,26 @@ function selfTestRule3(expect) { expect('a tree with no recognised customer-facing string reports every bucket 0 (main reds)', Object.values(r.seen).reduce((a, b) => a + b, 0), 0); + // ── The C half of the 2026-08-29 adjudication: the boundary is STATED ─── + // + // Asserted derived-vs-derived, never re-spelled: the root from the constant + // the walk reads, the buckets from the seen map the floor iterates. A + // boundary line that names a bucket the floor does not guard, or misses one + // it does, is the drift this pin exists to catch — the C output is what + // makes the #13179 root-extension deferral honest, so it must not itself + // rot into describing a scan that moved. + { + const boundary = scanBoundaryLines().join('\n'); + expect('the boundary output names Rule 3\'s scanned root', + boundary.includes(SPEC_SOURCE_ROOT), true); + expect('the boundary output says sibling packages are NOT scanned', + boundary.includes('SIBLING PACKAGES is NOT scanned'), true); + expect('the boundary output names every bucket the per-bucket floor guards', + Object.keys(r.seen).every((b) => boundary.includes(b)), true); + expect('...and the strictObject option keys, derived from the same set the scan reads', + [...STRICT_OPTION_KEYS].every((k) => boundary.includes(k)), true); + } + // Empty is a hard error, not a pass — same discipline as the other two rules. rmSync(join(dir, 'packages', 'spec', 'src'), { recursive: true, force: true }); mkdirSync(join(dir, 'packages', 'spec', 'src'), { recursive: true }); @@ -2234,7 +2465,7 @@ function main() { return; } const messageIdViolations = []; - const seenByBucket = { message: 0, strictObject: 0, tombstone: 0, describe: 0, functionBuilt: 0 }; + const seenByBucket = { message: 0, strictObject: 0, tombstone: 0, describe: 0, functionBuilt: 0, functionDeclared: 0 }; for (const file of specSources) { const r = findCustomerTextIdViolations(readFileSync(file, 'utf8'), file, ts); messageIdViolations.push(...r.violations); @@ -2243,6 +2474,11 @@ function main() { const blindBuckets = Object.keys(seenByBucket).filter((b) => seenByBucket[b] === 0); const totalTextSeen = Object.values(seenByBucket).reduce((a, b) => a + b, 0); + // The boundary is stated on EVERY verdict, red or green — a reader of the + // green line must be able to see what the clean bill covers, and a reader of + // a red must be able to see what a fix inside the root cannot have swept. + for (const line of scanBoundaryLines()) console.log(line); + let failed = false; if (violations.length > 0) { @@ -2291,11 +2527,12 @@ function main() { + `\n\nso "no violations" below would be a verdict on a population this run never located.` + `\n\nThat is the dormant-gate shape, not a clean tree: the spec really does declare refusal` + `\nprose, unknown-key guidance, tombstone prescriptions, \`.describe()\` docs AND prose built` - + `\ninside \`error: () =>\` callbacks and message-builder functions, so a zero here means the` - + `\nDETECTOR stopped matching how one of them is spelled — an options-object key renamed away` - + `\nfrom \`message\`, a new validator helper, a \`strictObject\` wrapper under a new name, a` - + `\nguidance table moved behind a helper \`customerTextPosition()\` does not climb through, or` - + `\n— for \`functionBuilt\` — the function-boundary clause silently back to \`undefined\`.` + + `\ninside \`error: () =>\` callbacks, message-builder functions and plain \`function\`` + + `\ndeclarations, so a zero here means the DETECTOR stopped matching how one of them is` + + `\nspelled — an options-object key renamed away from \`message\`, a new validator helper, a` + + `\n\`strictObject\` wrapper under a new name, a guidance table moved behind a helper` + + `\n\`customerTextPosition()\` does not climb through, or — for \`functionBuilt\` /` + + `\n\`functionDeclared\` — a function-boundary clause silently back to \`undefined\`.` + `\n\nThe floor is PER BUCKET and not on the total, deliberately: \`.describe()\` alone would` + `\nhold a total positive forever while the \`guidance\` matcher rotted unseen.` + `\n\nFix \`customerTextPosition()\` / \`collectTextSinkConsts()\` / STRICT_OPTION_KEYS /` @@ -2350,7 +2587,7 @@ function main() { + `${specSources.length} spec sources clean — no internal issue-id references ` + `(message ${seenByBucket.message} · strictObject ${seenByBucket.strictObject} · ` + `tombstone ${seenByBucket.tombstone} · describe ${seenByBucket.describe} · ` - + `functionBuilt ${seenByBucket.functionBuilt}).`, + + `functionBuilt ${seenByBucket.functionBuilt} · functionDeclared ${seenByBucket.functionDeclared}).`, ); } From 4dd3e5e3987c6105788380391b16bb5b59b5827d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 17:54:59 +0000 Subject: [PATCH 2/4] fix(spec): strip internal tracker ids from function-declaration-built refusal prose, re-pin the twins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strip half of the fifth-population adjudication (2026-08-29, Class-1), under the keep-the-ADR-id charter: 17 ids across 8 spec sources — the 15 the broad declaration census finds on current origin/main (materially fewer than the relayed 47/51/14, measured before today's main moved) plus the 2 hoisted ruling consts the widened sink closure newly reaches (SUBMIT_REDIRECT_RULING, FORM_VIEW_FEATURES_RULING). ADR ids, protocol versions, error codes and ruling dates stay; ids that were the whole parenthetical take the parenthetical with them; load-bearing internal references move to adjacent comments. Twins re-pinned red-then-green, never weakened: 42 assertions across 7 spec test files red before this commit, 607/607 green after, each re-pin a customer-resolvable anchor from the NEW text plus negative id pins at the two ruling-const doors. The objectql and service-analytics twins flagged by the id sweep pin sources OUTSIDE the scanned root and are deliberately untouched (evidence for the cross-package revival census). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KX8wnyjStaZcuMyAMNsy3N --- .changeset/fifth-population-spec-strip.md | 5 +++++ .../spec/src/data/date-range-presets.test.ts | 5 ++++- packages/spec/src/data/date-range-presets.ts | 4 ++-- packages/spec/src/data/default-value-shape.ts | 2 +- packages/spec/src/data/field.test.ts | 2 +- .../spec/src/data/filter-comparand-shape.ts | 4 ++-- .../src/data/filter-preset-comparand.test.ts | 2 +- packages/spec/src/data/filter.test.ts | 2 +- packages/spec/src/data/filter.zod.ts | 9 +++++++-- packages/spec/src/data/object.test.ts | 4 ++-- packages/spec/src/data/object.zod.ts | 10 +++++----- packages/spec/src/stack.zod.ts | 6 +++--- .../spec/src/ui/view-form-features-root.test.ts | 5 ++++- .../src/ui/view-submit-redirect-url.test.ts | 17 ++++++++++------- packages/spec/src/ui/view.zod.ts | 9 +++++++-- 15 files changed, 55 insertions(+), 31 deletions(-) create mode 100644 .changeset/fifth-population-spec-strip.md diff --git a/.changeset/fifth-population-spec-strip.md b/.changeset/fifth-population-spec-strip.md new file mode 100644 index 0000000000..72cf955509 --- /dev/null +++ b/.changeset/fifth-population-spec-strip.md @@ -0,0 +1,5 @@ +--- +"@objectstack/spec": patch +--- + +Customer-facing refusal and warning messages built inside plain `function` declarations (and two hoisted ruling consts) no longer cite internal tracker ids. The teaching stays; customer-resolvable anchors stay — ADR ids, protocol versions, error codes such as `INVALID_FILTER / 400`, and ruling dates — while the `#NNNN` tokens, which resolve to nothing for a refused author, are gone. The doc-authoring gate now recognises function declarations as text sinks (its fifth population, with its own blindness floor) and prints its own scan boundary, so the next boundary move is visible from the gate's output. diff --git a/packages/spec/src/data/date-range-presets.test.ts b/packages/spec/src/data/date-range-presets.test.ts index 0ed7f1d5cd..d976a2f428 100644 --- a/packages/spec/src/data/date-range-presets.test.ts +++ b/packages/spec/src/data/date-range-presets.test.ts @@ -67,7 +67,10 @@ describe('date-range preset vocabulary (#4614, re-homed by #8793)', () => { expect(message).toContain('$gte'); // the position it sat in expect(message).toContain('{30_days_ago}'); // the spelling that works expect(message).toContain('2026-01-15'); // the ISO alternative - expect(message).toContain('#8793'); // attributable from the error alone + // Attributable from the error alone by the customer-resolvable sentence — + // never by a tracker id (#13156's strip). + expect(message).toContain('Refused at authoring time so the error surfaces where the filter is written.'); + expect(message).not.toMatch(/(? { const issue = r.error.issues.find((i) => i.message.includes('last_30_days')); expect(issue, 'the preset refusal must surface through relatedListFilter').toBeTruthy(); expect(issue!.path).toEqual(['relatedListFilter', 'created_at', '$gte']); - expect(issue!.message).toContain('#8793'); + expect(issue!.message).toContain('Refused at authoring time so the error surfaces where the filter is written.'); } }); diff --git a/packages/spec/src/data/filter-comparand-shape.ts b/packages/spec/src/data/filter-comparand-shape.ts index 84ef184233..65ada8d0e5 100644 --- a/packages/spec/src/data/filter-comparand-shape.ts +++ b/packages/spec/src/data/filter-comparand-shape.ts @@ -222,7 +222,7 @@ function nonListComparandError( `"${op}" tests membership of a list — write ${shapePreview([value])} for a single value` + (alternative ? `, or use ${alternative} to compare against it` : '') + `. Authoring spellings: ${spellings.join(', ')}. The filter was NOT applied, and an ` + - `unapplied filter would have returned the UNFILTERED result set (#5869).`, + `unapplied filter would have returned the UNFILTERED result set.`, ); } @@ -247,7 +247,7 @@ function malformedRangeComparandError( `value array. Received ${describeOperand(value)} (${shapePreview(value)}) at ${path}. ` + `A range needs exactly two bounds, in order; the authoring spelling that lowers to ` + `"$between" is "between". The filter was NOT applied, and an unapplied filter would have ` + - `returned the UNFILTERED result set (#5869).`, + `returned the UNFILTERED result set.`, ); } diff --git a/packages/spec/src/data/filter-preset-comparand.test.ts b/packages/spec/src/data/filter-preset-comparand.test.ts index 17eb30c208..d03c2c1ba6 100644 --- a/packages/spec/src/data/filter-preset-comparand.test.ts +++ b/packages/spec/src/data/filter-preset-comparand.test.ts @@ -40,7 +40,7 @@ describe('[#8793] FilterConditionSchema — bare preset names in ordering compar expect(issue.path).toEqual(['created_at', op]); expect(issue.message).toContain(preset); expect(issue.message).toContain('PRESET'); - expect(issue.message).toContain('#8793'); + expect(issue.message).toContain('Refused at authoring time so the error surfaces where the filter is written.'); } } }); diff --git a/packages/spec/src/data/filter.test.ts b/packages/spec/src/data/filter.test.ts index 5594df4533..275b4fffe7 100644 --- a/packages/spec/src/data/filter.test.ts +++ b/packages/spec/src/data/filter.test.ts @@ -465,7 +465,7 @@ describe('RangeOperatorSchema', () => { // generic "Invalid input" zod gives an unexplained union. expect(withReference.error?.issues[0]?.code).toBe('invalid_union'); expect(withReference.error?.issues[0]?.message).toContain('Not a valid $and member'); - expect(alreadyInvalidComparand.error?.issues[0]?.message).toContain('#7711'); + expect(alreadyInvalidComparand.error?.issues[0]?.message).toContain('Declared = enforced (ADR-0049)'); // And the level that DOES judge comparands rejects both — unchanged. expect(FieldOperatorsSchema.safeParse({ $between: [{ $field: 'budget' }, 100] }).success) .toBe(false); diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 60d8a08c8a..07a64dfdb0 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -293,7 +293,10 @@ function listPositionFieldReferenceMessage(position: string): string { + 'both SQL drivers refuse the position with INVALID_FILTER / 400. Write a literal value ' + 'here, or move the reference to a scalar comparison operator ' + '($eq/$ne/$gt/$gte/$lt/$lte), whose WHOLE comparand a { $field } reference may be. ' - + 'Ruled 2026-08-11 on #7596: declared = enforced (ADR-0049).' + // The removal ruling of 2026-08-11 lives on the tracker (#7596) — internal + // readers get the id here; the customer-facing sentence keeps the date and + // the customer-resolvable ADR anchor only. + + 'Ruled 2026-08-11: declared = enforced (ADR-0049).' ); } @@ -1431,7 +1434,9 @@ function normalizedMemberMessage(position: string, input: unknown): string { + '({ "field": { "$op": value } }, whose keys are field names and whose operator map ' + 'must satisfy FieldOperatorsSchema — comparand shapes included), or a nested LOGICAL ' + `GROUP carrying only ${NORMALIZED_LOGICAL_KEYS.join(' / ')} and nothing else. ` - + 'Ruled on #7711: declared = enforced (ADR-0049).' + // The ruling is #7711 on the tracker — the id stays here for internal + // readers; the sentence keeps the customer-resolvable ADR anchor. + + 'Declared = enforced (ADR-0049).' ); } diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index ea1237cefc..334debc015 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -1223,7 +1223,7 @@ describe('ObjectSchema.create()', () => { } expect(message).toContain('lifecycle hook'); expect(message).toContain('record_change'); - expect(message).toContain('#1535'); + expect(message).toContain('ADR-0032 "no silent failure"'); }); // #4990 note 1 asked whether this file's own `suggestKey` shares the @@ -2392,7 +2392,7 @@ describe('#3543 apiMethods legacy-value strip (ObjectCapabilities)', () => { expect(result.apiMethods).toEqual(['get', 'list']); const msg = warn.mock.calls.map((c) => c[0]).join('\n'); expect(msg).toContain('export'); - expect(msg).toContain('#3543'); + expect(msg).toContain('the six primitives get/list/create/update/delete/bulk'); expect(msg).toContain("declare ['list']"); // FROM → TO prescription }); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 479830a615..f0e6512b72 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -111,7 +111,7 @@ export function stripLegacyApiMethods( const warn = opts?.warn ?? ((msg: string) => console.warn(msg)); warn( `[spec] enable.apiMethods declares retired legacy value(s) [${legacy.join(', ')}] — ` + - `the ApiMethod enum is the six primitives get/list/create/update/delete/bulk (#3543). ` + + `the ApiMethod enum is the six primitives get/list/create/update/delete/bulk. ` + `Legacy values are stripped at parse; their semantics are DERIVED from the primitives:\n` + legacy.map((v) => ` • \`${v}\`: ${LEGACY_API_METHOD_GUIDANCE[v]}`).join('\n') + (kept.length === 0 @@ -2361,7 +2361,7 @@ function unknownKeyError(objectName: unknown, unknownKeys: string[], knownKeys: return new Error( `ObjectSchema.create('${name}'): unknown key(s) — ${unknownKeys.join(', ')}.\n` + 'These keys would previously have been stripped silently at build, shipping ' + - 'dead metadata with no diagnostic (ADR-0032 "no silent failure", issue #1535).\n\n' + + 'dead metadata with no diagnostic (ADR-0032 "no silent failure").\n\n' + `${lines.join('\n')}\n\n` + 'Remove the unknown key(s), fix the typo, or move the logic to a supported mechanism.', ); @@ -2502,8 +2502,8 @@ function assertSystemDataIsWritable( + '(written via `isSystem` / a service SYSTEM_CTX), or `append-only` for an immutable ' + 'audit log. If the object IS user-writable, drop the `userActions` entries closing ' + 'create/edit/delete — the `system-data` default already grants create, edit, delete and ' - + 'exportCsv, so `userActions` is for NARROWING those (#3355). The one verb it does not ' - + 'grant is CSV `import`, which is opt-in per object (#4671).', + + 'exportCsv, so `userActions` is for NARROWING those. The one verb it does not ' + + 'grant is CSV `import`, which is opt-in per object.', ); } @@ -2604,7 +2604,7 @@ function forceCbpMasterDetailRequired( throw new Error( `ObjectSchema.create('${name}'): field \`${fieldName}\` declares \`required: false\` on a ` + "`master_detail` reference under `sharingModel: 'controlled_by_parent'` — a contradiction " - + 'with no honest reading (#8772). A controlled-by-parent detail derives ALL of its record ' + + 'with no honest reading. A controlled-by-parent detail derives ALL of its record ' + 'access from the master this field names (ADR-0055); a row allowed to omit the master FK ' + 'is unreadable by everyone (the derived filter `masterFK IN (accessible master ids)` ' + 'never matches null) and unwritable thereafter. Remove `required: false` (the builder ' diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index e7ec422eff..2c452e7943 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -1089,7 +1089,7 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { if (entry.transform === 'javascript') { errors.push( `Mapping '${m.name}' uses transform 'javascript', which the import path does not execute ` + - `(no server-side sandbox — see framework#2611). Use none/constant/map/split/join/lookup, ` + + `(no server-side sandbox). Use none/constant/map/split/join/lookup, ` + `or model the logic as a flow.`, ); } @@ -2010,7 +2010,7 @@ function warnMalformedCollectionKey(key: string): void { `composeStacks: top-level key '${key}' is a collection (concatenated across stacks) but at ` + `least one stack carries a non-array value for it — that value cannot be composed and was ` + `skipped. Author it as an array, or run the stack through strict \`defineStack\` to have ` + - `the shape rejected where it is written. See objectstack-ai/objectstack#5005.`, + `the shape rejected where it is written.`, ); } @@ -2036,7 +2036,7 @@ function warnUncomposedStackKey(key: string, rule: ComposeDisposition): void { `composeStacks: top-level key '${key}' has no declared composition rule — composed with ` + `the default (${rule === 'concat' ? 'arrays are concatenated' : 'single value; conflicting declarations throw'}). ` + `Declare what composing it means in COMPOSE_KEY_DISPOSITIONS (packages/spec/src/stack.zod.ts) ` + - `in the same change that declares the key — see objectstack-ai/objectstack#5005.`, + `in the same change that declares the key.`, ); } diff --git a/packages/spec/src/ui/view-form-features-root.test.ts b/packages/spec/src/ui/view-form-features-root.test.ts index 7c3af7770e..2a6e8edda3 100644 --- a/packages/spec/src/ui/view-form-features-root.test.ts +++ b/packages/spec/src/ui/view-form-features-root.test.ts @@ -65,7 +65,10 @@ function expectFeaturesRefusal( // The identity an author (and an AI author's retry loop) acts on: the root, // the surface, the fail-open reason, the ruling, and the prescription. expect(issue!.message).toContain('Form-view predicates may not name the `features.*` scope root'); - expect(issue!.message).toContain('ruled 2026-08-27 on'); + expect(issue!.message).toContain('ruled 2026-08-27'); + // The negative twin of the citation pin: the ruling is cited by DATE, never + // by a tracker id a refused author cannot resolve (#13156's strip). + expect(issue!.message).not.toMatch(/(? { const msg = reject(redirectTo(url)); expect(msg, 'names the rule').toContain('RELATIVE path only'); expect(msg, 'names the reason the rule exists').toContain('open'); - expect(msg, 'cites the ruling so the refusal is traceable').toContain('ruled 2026-08-11 on'); + expect(msg, 'cites the ruling so the refusal is traceable').toContain('ruled 2026-08-11'); + // The negative twin: traceable by DATE, never by a tracker id a refused + // author cannot resolve (#13156's strip). + expect(msg).not.toMatch(/(? { const msg = reject(redirectTo('//evil.example/thanks')); expect(msg).toContain('protocol-relative'); expect(msg).toContain('ANOTHER ORIGIN'); - expect(msg).toContain('ruled 2026-08-11 on'); + expect(msg).toContain('ruled 2026-08-11'); }); it.each([ @@ -132,7 +135,7 @@ describe('#7496 bullet 1 — relative paths only', () => { expect(msg, 'says WHY a backslash is an origin problem, not a style problem') .toContain('normalise'); expect(msg, 'gives the escape for a legitimate backslash').toContain('%5C'); - expect(msg).toContain('ruled 2026-08-11 on'); + expect(msg).toContain('ruled 2026-08-11'); }); it.each([ @@ -148,7 +151,7 @@ describe('#7496 bullet 1 — relative paths only', () => { expect(msg).toContain('whitespace or control characters'); expect(msg, 'says why stripping is the hazard').toContain('strip'); expect(msg, 'gives the escape for a legitimate space').toContain('%20'); - expect(msg).toContain('ruled 2026-08-11 on'); + expect(msg).toContain('ruled 2026-08-11'); }); it('refuses a document-relative path and explains what it resolves against', () => { @@ -158,7 +161,7 @@ describe('#7496 bullet 1 — relative paths only', () => { const msg = reject(redirectTo('thanks')); expect(msg).toContain('must start with `/`'); expect(msg).toContain('document-relative'); - expect(msg).toContain('ruled 2026-08-11 on'); + expect(msg).toContain('ruled 2026-08-11'); }); it.each([ @@ -174,7 +177,7 @@ describe('#7496 bullet 1 — relative paths only', () => { // leading-slash message would be a confusing thing to read for it. const msg = reject(redirectTo('')); expect(msg).toContain('needs a `url`'); - expect(msg).toContain('ruled 2026-08-11 on'); + expect(msg).toContain('ruled 2026-08-11'); }); }); @@ -207,7 +210,7 @@ describe('#7496 bullet 2 — `{{record.}}` and nothing else', () => { const msg = reject(redirectTo(url)); expect(msg, 'names the vocabulary').toContain('ONLY declared record fields'); expect(msg, 'gives the spelling verbatim').toContain('{{record.field_name}}'); - expect(msg).toContain('ruled 2026-08-11 on'); + expect(msg).toContain('ruled 2026-08-11'); }); it('the refusal states the URL-escaping half of the ruling', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 1a8d75ea1f..56d277916f 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2484,7 +2484,10 @@ function foldFormGroupsIntoSections( * `@objectstack/lint`, which already resolves field references against object * declarations. Enforcing half loudly beats enforcing none. */ -const SUBMIT_REDIRECT_RULING = 'ruled 2026-08-11 on #7496'; +// The ruling is #7496 on the tracker — internal readers get the id here; the +// customer-facing sentence carries the date only, which is what a refused +// author can act on. +const SUBMIT_REDIRECT_RULING = 'ruled 2026-08-11'; /** The ONE interpolation `submitBehavior.url` accepts. Global — used to strip. */ const SUBMIT_REDIRECT_URL_TOKEN_RE = /\{\{record\.[a-z_][a-z0-9_]*\}\}/g; @@ -2620,7 +2623,9 @@ function checkSubmitRedirectUrl(raw: string): string | undefined { * authoring shape is the source string, and build emits the AST from sources * this gate has already accepted. */ -const FORM_VIEW_FEATURES_RULING = 'ruled 2026-08-27 on objectui#6262'; +// The ruling is objectui#6262 on the tracker — internal readers get the id +// here; the customer-facing sentence carries the date only. +const FORM_VIEW_FEATURES_RULING = 'ruled 2026-08-27'; /** CEL string literals (both quote styles, with escapes) — stripped before the root scan. */ const CEL_STRING_LITERAL_RE = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g; From 1c749681632d593a9c6f65fdc80abdf7fa13cc4e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 18:04:54 +0000 Subject: [PATCH 3/4] fix(core): strip tracker ids from HotReloadManager's author-facing refusal messages, re-pin the twins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The member half of the 2026-08-29 family adjudication: the ruled strip is the plugin-registration refusal's tracker id (the id the #13151-verified twins at hot-reload.test.ts:259,302,310 pin); the sibling id in the same refusal table's watchPatterns entry and in the startWatching() removal notice is stripped in the same stroke under the bounded same-class extension — same defect family, same keep-the-ADR-id remedy, same file, same twin suite, declared in the PR body. ADR-0049, the spec/core versions and the scheduleReload migration call stay as the customer-resolvable anchors. Twins red-then-green, never weakened: 8 assertions red before this commit (10/18 passing), 18/18 after; each id pin re-pinned to a content anchor from the NEW text plus negative id pins at all three doors — mirroring the negative pin the spec-side parse door has carried since its own strip. Repo-wide section-7 sweep for the stripped id set: the only other test literal is an expect failure-label, not a content pin. Root extension of the doc-authoring gate's Rule 3 is deliberately NOT part of this change (deferred by the adjudication; the gate's new boundary output is what keeps that deferral visible). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KX8wnyjStaZcuMyAMNsy3N --- .changeset/hot-reload-refusal-id-strip.md | 5 +++++ packages/core/src/hot-reload.test.ts | 25 +++++++++++++++-------- packages/core/src/hot-reload.ts | 8 ++++---- 3 files changed, 25 insertions(+), 13 deletions(-) create mode 100644 .changeset/hot-reload-refusal-id-strip.md diff --git a/.changeset/hot-reload-refusal-id-strip.md b/.changeset/hot-reload-refusal-id-strip.md new file mode 100644 index 0000000000..a6db97dde8 --- /dev/null +++ b/.changeset/hot-reload-refusal-id-strip.md @@ -0,0 +1,5 @@ +--- +"@objectstack/core": patch +--- + +`HotReloadManager`'s refusal messages — the plugin-registration doors for retired `stateStrategy` values and removed config keys, and the `startWatching()` removal notice — no longer cite internal tracker ids. The prescriptions keep their customer-resolvable anchors (ADR-0049 enforce-or-remove, the `@objectstack/spec` / `@objectstack/core` versions, and the `scheduleReload` migration call); the `#NNNN` tokens, which resolve to nothing for the host author reading the refusal, are gone. diff --git a/packages/core/src/hot-reload.test.ts b/packages/core/src/hot-reload.test.ts index b84e0baccc..d3d536d2e6 100644 --- a/packages/core/src/hot-reload.test.ts +++ b/packages/core/src/hot-reload.test.ts @@ -256,11 +256,14 @@ describe('[#12340] stateStrategy refusal', () => { // doors (parse vs registration) and are deliberately not shared. const m = caught?.message ?? ''; expect(m).toContain(retired); - expect(m).toContain('#12340'); - expect(m).toContain('ADR-0049'); + expect(m).toContain('ADR-0049 enforce-or-remove'); expect(m).toContain('were removed'); expect(m).toContain("Use 'memory'"); expect(m).toContain('p'); // locates the offending plugin + // The negative twin (#13179's strip): the prescription anchors on the + // ADR and the version — never on a tracker id the refused author + // cannot resolve. Mirrors the spec-side door's own pin. + expect(m).not.toMatch(/(? { expect(caught?.code).toBe('VALIDATION_ERROR'); expect(caught?.status).toBe(400); expect(caught?.message).toContain('distributedConfig'); - expect(caught?.message).toContain('#12340'); + expect(caught?.message).toContain('ADR-0049 enforce-or-remove'); expect(caught?.message).toContain('nothing ever read it'); + expect(caught?.message).not.toMatch(/(? { // The door must not depend on `enabled`: a false declaration is false // whether or not the feature is switched on. const cfg = { ...configWith('disk'), enabled: false } as HotReloadConfigParsed; - expect(() => mgr.registerPlugin('p', cfg)).toThrow(/#12340/); + expect(() => mgr.registerPlugin('p', cfg)).toThrow(/were removed/); }); for (const live of ['memory', 'none'] as const) { @@ -377,18 +381,20 @@ describe('[#12428] startWatching refusal and the watch-handle removal', () => { // The prescription's load-bearing facts, by CONTENT — this message is the // whole migration document for whoever hits it. const m = caught?.message ?? ''; - expect(m).toContain('#12428'); - expect(m).toContain('ADR-0049'); + expect(m).toContain('ADR-0049 enforce-or-remove'); expect(m).toContain('never watched'); expect(m).toContain('scheduleReload'); expect(m).toContain('p'); // locates the offending plugin + // The negative twin (#13179's strip, extended to this door's sibling id): + // anchored on the ADR and the migration call, never on a tracker id. + expect(m).not.toMatch(/(? { // The old body early-returned when the plugin was unknown or disabled, so // the lie was conditional. The refusal must not be: the method never // worked for anyone, in any state. - expect(() => mgr.startWatching('never-registered')).toThrow(/#12428/); + expect(() => mgr.startWatching('never-registered')).toThrow(/never watched/); }); it('refuses a leftover watchPatterns at registration', () => { @@ -406,8 +412,9 @@ describe('[#12428] startWatching refusal and the watch-handle removal', () => { expect(caught?.code).toBe('VALIDATION_ERROR'); expect(caught?.status).toBe(400); expect(caught?.message).toContain('watchPatterns'); - expect(caught?.message).toContain('#12428'); + expect(caught?.message).toContain('ADR-0049 enforce-or-remove'); expect(caught?.message).toContain('nothing ever read it'); + expect(caught?.message).not.toMatch(/(? { @@ -415,7 +422,7 @@ describe('[#12428] startWatching refusal and the watch-handle removal', () => { // whether or not the feature is switched on. expect(() => mgr.registerPlugin('p', liveConfig({ enabled: false, watchPatterns: ['a/**'] })) - ).toThrow(/#12428/); + ).toThrow(/nothing ever read it/); }); it('still registers a config that does not carry the retired key', () => { diff --git a/packages/core/src/hot-reload.ts b/packages/core/src/hot-reload.ts index 59e3017e9d..d705c7fd9a 100644 --- a/packages/core/src/hot-reload.ts +++ b/packages/core/src/hot-reload.ts @@ -45,7 +45,7 @@ const HONOURED_STATE_STRATEGIES = ['memory', 'none'] as const; */ const RETIRED_STATE_STRATEGY_GUIDANCE = "'disk' and 'distributed' were removed from HotReloadConfig.stateStrategy in " - + '@objectstack/spec 18 (#12340, ADR-0049 enforce-or-remove) — neither was ever ' + + '@objectstack/spec 18 (ADR-0049 enforce-or-remove) — neither was ever ' + "implemented. Both wrote to the same in-memory Map as 'memory' and reported it " + 'only at debug level, so a host that asked for durable or cluster-replicated ' + 'state got process-local memory and no error. ' @@ -111,7 +111,7 @@ const RETIRED_HOT_RELOAD_KEYS: ReadonlyArray = [ [ 'distributedConfig', "'distributedConfig' was removed from " - + 'HotReloadConfig in @objectstack/spec 18 (#12340, ADR-0049 ' + + 'HotReloadConfig in @objectstack/spec 18 (ADR-0049 ' + 'enforce-or-remove) — nothing ever read it. A provider, endpoints, a key ' + 'prefix, a TTL and a replication factor could all be declared and no ' + "connection was ever opened. It left with the stateStrategy: 'distributed' " @@ -121,7 +121,7 @@ const RETIRED_HOT_RELOAD_KEYS: ReadonlyArray = [ [ 'watchPatterns', "'watchPatterns' was removed from HotReloadConfig in @objectstack/spec 18 " - + '(#12428, ADR-0049 enforce-or-remove) — nothing ever read it. Its only two ' + + '(ADR-0049 enforce-or-remove) — nothing ever read it. Its only two ' + 'uses were log lines: no watcher was ever constructed from it, so an author ' + 'could declare a glob and no file change ever triggered a reload. File ' + 'watching is the HOST\'s job in this host-driven library. Delete the key, ' @@ -322,7 +322,7 @@ export class HotReloadManager { startWatching(pluginName: string): never { throw hotReloadRefusal( `[HotReload] Plugin '${pluginName}': startWatching() never watched ` - + 'anything and was removed in @objectstack/core 18 (#12428, ADR-0049 ' + + 'anything and was removed in @objectstack/core 18 (ADR-0049 ' + "enforce-or-remove). It logged 'File watching started' at info level " + 'while no watcher was ever constructed, so no file change could ever ' + 'trigger a reload. File watching is the HOST\'s job in this ' From afb72654e8e8a76fba283d60fd1c4e1700eb3cfa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 18:11:25 +0000 Subject: [PATCH 4/4] test(spec): re-pin the two compose-stacks predicate twins the id sweep's callee filter missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by RUNNING the full mention set rather than trusting the sweep's expect-callee filter: both pins spell the id check inside a .some() predicate, so the nearest enclosing call is w.includes, not expect. Same re-pin discipline — anchored on the warning's own prescription words (COMPOSE_KEY_DISPOSITIONS / 'cannot be composed'), red-then-green (2 red in the consumer batch, 35/35 green after), never weakened. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KX8wnyjStaZcuMyAMNsy3N --- packages/spec/src/compose-stacks-key-loss.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/compose-stacks-key-loss.test.ts b/packages/spec/src/compose-stacks-key-loss.test.ts index 199d18258a..4aa793e7ae 100644 --- a/packages/spec/src/compose-stacks-key-loss.test.ts +++ b/packages/spec/src/compose-stacks-key-loss.test.ts @@ -158,7 +158,7 @@ describe('#5005 rule 2 — conflicting values throw a prescriptive error', () => // ─── Rule 3 — unhandled keys warn ─────────────────────────────────── describe('#5005 rule 3 — a key with no declared rule warns', () => { - it('warns once, names the key and points at #5005', () => { + it('warns once, names the key and carries the declare-a-rule prescription', () => { // A key the schema does not declare: reaches composeStacks only via // `strict: false`, which is exactly how a NEW key looks before someone // remembers to teach the composer about it. @@ -167,11 +167,13 @@ describe('#5005 rule 3 — a key with no declared rule warns', () => { const composed = composeStacks([a, b]) as Record; - // One warning that both names the key AND points at #5005 — not two - // unrelated ones (`defineStack` also warns about undeclared keys). + // One warning that both names the key AND carries the prescription — not + // two unrelated ones (`defineStack` also warns about undeclared keys). + // Anchored on the prescription's own words, never on a tracker id the + // author cannot resolve (#13156's strip). const warnings = warnSpy.mock.calls.map((c) => String(c[0])); expect( - warnings.some((w) => w.includes('composeStacks') && w.includes("'futureThing'") && w.includes('#5005')), + warnings.some((w) => w.includes('composeStacks') && w.includes("'futureThing'") && w.includes('COMPOSE_KEY_DISPOSITIONS')), ).toBe(true); // …and it is composed by the default rule rather than dropped. expect(composed.futureThing).toEqual({ enabled: true }); @@ -195,7 +197,7 @@ describe('#5005 rule 3 — a key with no declared rule warns', () => { expect( warnSpy.mock.calls .map((c) => String(c[0])) - .some((w) => w.includes('composeStacks') && w.includes("'views'") && w.includes('#5005')), + .some((w) => w.includes('composeStacks') && w.includes("'views'") && w.includes('cannot be composed')), ).toBe(true); });