From a61c00acc6731f8e6b621e3f20b876d6d46de2b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 13:57:01 +0000 Subject: [PATCH 1/4] wip(docs-audit): container-qualified data-property anchors (#13713) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV --- scripts/docs-audit/affected-docs.mjs | 200 ++++++++++++++++++++++++--- 1 file changed, 182 insertions(+), 18 deletions(-) diff --git a/scripts/docs-audit/affected-docs.mjs b/scripts/docs-audit/affected-docs.mjs index 8f843b23ad..3d7203d9d7 100644 --- a/scripts/docs-audit/affected-docs.mjs +++ b/scripts/docs-audit/affected-docs.mjs @@ -374,10 +374,131 @@ function memberFormOn(line, name) { * legible, move no row. The container name is what separates the two cases above, and it * is the field no row printed before. */ -function declarationProvenance(winner, container, line) { +function declarationProvenance(winner, container, line, note = null) { const noun = winner.kind === 'member' ? memberFormOn(line, winner.name) : declNoun(winner); if (!container) return `a top-level ${noun}`; - return `${article(noun)} ${noun} of ${declNoun(container)} ${container.name}`; + const clause = `${article(noun)} ${noun} of ${declNoun(container)} ${container.name}`; + return note ? `${clause}, ${note}` : clause; +} + +/** + * ---- CONTAINER-QUALIFIED DATA-PROPERTY ANCHORS (option D, #13713) ------------ + * + * The two generated `packages/spec` artifacts this qualification reads. Both are + * GENERATED (`pnpm --filter @objectstack/spec gen:declaration-map`, `gen:schema`) and + * covered by `check:generated`; ⛔ never hand-edit them from this side, and ⛔ never + * grow a local container->spec-type table here when one is missing — a missing mapping + * is a spec-lane card, and a container this cannot resolve is handled below by KEEPING + * today's behaviour. + */ +const DECLARATION_MAP_DIR = 'packages/spec/declaration-map'; +const AUTHORABLE_SURFACE_FILE = 'packages/spec/authorable-surface.base.json'; + +/** + * The container qualifier, built from those two artifacts. PURE, so `--self-test` pins + * every branch of the rule with no repo state (the live loader below is the only part + * that touches the disk). + * + * `shards` — the parsed `declaration-map/*.json` documents, each + * `{ category, entries: { TSDeclarationName: 'cat/SpecType' }, collisions: [] }`. + * `keys` — `authorable-surface.base.json`'s `keys`, each `'cat/SpecType:property'`. + * + * `resolve(name)` answers with THREE distinguishable states, and the distinction is the + * whole safety property: a string is a resolved spec type, `null` is AMBIGUOUS (the name + * is listed in a shard's `collisions`, or two shards map it to different types), and + * `undefined` is UNMAPPED. Only the resolved state may ever suppress an anchor. + */ +function buildContainerSurface(shards, keys) { + const byName = new Map(); + for (const shard of shards) { + for (const name of (shard && shard.collisions) || []) byName.set(name, null); + } + for (const shard of shards) { + for (const [name, target] of Object.entries((shard && shard.entries) || {})) { + if (byName.has(name)) { + if (byName.get(name) !== target) byName.set(name, null); // two answers is no answer + } else byName.set(name, target); + } + } + const authorable = new Set(keys || []); + return { + available: true, + resolve: (name) => byName.get(name), + isAuthorable: (specType, prop) => authorable.has(`${specType}:${prop}`), + }; +} + +/** + * The qualifier used when the artifacts cannot be read. Every container resolves to + * UNMAPPED, so case (c) below fires for all of them and the run behaves exactly as it did + * before this qualification existed. That is the deliberate degradation: this script runs + * in contexts where `packages/spec` may be absent, and an unreadable artifact must cost + * noise, never silence. + */ +const UNAVAILABLE_CONTAINER_SURFACE = { available: false, resolve: () => undefined, isAuthorable: () => false }; + +let containerSurfaceCache; + +/** + * What the qualification REMOVED, published rather than left silent — the discipline both + * existing anchor guards already follow (`overbroadAnchors`, `weakAnchorsDropped`). A + * suppressed anchor that no field names is a false negative nobody can audit, and a false + * negative is the exact cost this card is allowed to spend and must therefore account for. + */ +const containerQualifiedDrops = new Set(); + +/** The live qualifier — read once per run, from the repo root, dependency-free. */ +function liveContainerSurface() { + if (containerSurfaceCache !== undefined) return containerSurfaceCache; + try { + const dir = join(repoRoot, DECLARATION_MAP_DIR); + const shards = readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8'))); + const surface = JSON.parse(readFileSync(join(repoRoot, AUTHORABLE_SURFACE_FILE), 'utf8')); + if (!shards.length || !Array.isArray(surface.keys) || !surface.keys.length) throw new Error('empty artifacts'); + containerSurfaceCache = buildContainerSurface(shards, surface.keys); + } catch (e) { + // ONE note, on stderr, so the degradation is visible without changing any output the + // consumers parse. Anchors are unaffected — see UNAVAILABLE_CONTAINER_SURFACE. + process.stderr.write(`affected-docs: container qualification disabled (${DECLARATION_MAP_DIR} / ${AUTHORABLE_SURFACE_FILE} unreadable: ${e && e.message}); data-property anchors keep their pre-#13713 behaviour\n`); + containerSurfaceCache = UNAVAILABLE_CONTAINER_SURFACE; + } + return containerSurfaceCache; +} + +/** + * ⭐ THE RULE #13713 IMPLEMENTS, and the one place option B could creep back in. + * + * For a DATA PROPERTY `prop` whose declaring container is `C` (#12824's construct — the + * same `name:` form mints the best anchor the tool has and the worst): + * + * (a) `C` resolves to `cat/T` and `cat/T:prop` IS an authorable key ⇒ MINT. + * Today's behaviour, now justified rather than accidental — `userActions` on + * `ObjectSchemaBase` -> `data/Object:userActions`. + * (b) `C` resolves to `cat/T` and `cat/T:prop` is NOT authorable ⇒ DROP the + * property anchor and fall through to the container branch that already exists. + * No new fallback is invented: the caller's second `return` decides, exactly as it + * does for every other line that reaches it. + * (c) `C` is UNMAPPED or AMBIGUOUS ⇒ MINT (unchanged). + * + * ⛔ (c) MUST NEVER BECOME "DROP". That is option B (blanket prefer-container) by another + * name, and it is vetoed on two rounds of measurement: its "zero recall loss" was measured + * on the wrong population (#13306 re-derived the real recall at 48.8%) and the rows it + * drops are the most valuable ones the tool mints. A false positive costs a reader a + * minute; a false negative ships a falsified page. Fail toward noise, never toward silence. + * + * Only `name:` / `name?:` / `name =` MEMBERS are qualified. A method, a nested interface, + * a namespaced type — anything whose winning declaration is not a data property — is not + * the construct the ruling is about and is left alone. + */ +function qualifyDataProperty(inner, outer, surface) { + if (!inner || inner.kind !== 'member') return { mint: true, note: null }; + if (memberFormOn(inner.line, inner.name) !== 'field') return { mint: true, note: null }; + const target = surface.resolve(outer.name); + if (target === undefined || target === null) return { mint: true, note: null }; // (c) unmapped / ambiguous + if (surface.isAuthorable(target, inner.name)) return { mint: true, note: `an authorable key of ${target}` }; // (a) + return { mint: false, note: null, dropped: `${target}:${inner.name}` }; // (b) } /** @@ -1168,16 +1289,30 @@ function declarationChainAt(lines, idx) { * declaration that minted this anchor, so an emitted row reads "via `organizationId`, a * field of interface `MetaOverlayCacheKey`" instead of leaving the reader to guess whether * that is an authorable key or an internal cache struct. ⛔ It is not consulted here, in - * `symbolAnchorsFromSource`, in `admitAnchor` or in the bridge — the two returns below - * select exactly the names they selected before it existed. + * `symbolAnchorsFromSource`, in `admitAnchor` or in the bridge — the returns below + * select their names without reading it, exactly as they did before it existed. + * + * `surface` is the container qualifier (#13713, option D of #12824's ruling) and it is the + * ONE input here that can move a name. Injected rather than reached for, so `--self-test` + * pins all three branches of `qualifyDataProperty` with no repo state; live, it is read + * once per run off the two generated `packages/spec` artifacts. A data property of a + * container that resolves to a spec type whose `:prop` is not authorable drops to the + * container branch below — and an UNMAPPED or AMBIGUOUS container keeps minting, which is + * the ruled safety direction and ⛔ not a gap to tidy up. See `qualifyDataProperty`. */ -function documentableDeclarationsAt(lines, idx) { +function documentableDeclarationsAt(lines, idx, surface = liveContainerSurface()) { const chain = declarationChainAt(lines, idx); if (!chain.length) return []; const outer = chain[chain.length - 1]; const inner = chain.length > 1 ? chain[chain.length - 2] : null; const usable = (d) => d && !GENERIC_ANCHOR_NAMES.has(d.name) && !GENERIC_ANCHOR_NAMES.has(d.name.toLowerCase()) && d.name.length >= 3; - if (inner && outer.container && usable(inner)) return [{ name: inner.name, container: !!inner.container, from: declarationProvenance(inner, outer, inner.line) }]; + if (inner && outer.container && usable(inner)) { + const q = qualifyDataProperty(inner, outer, surface); + if (q.mint) return [{ name: inner.name, container: !!inner.container, from: declarationProvenance(inner, outer, inner.line, q.note) }]; + if (q.dropped) containerQualifiedDrops.add(`${inner.name} (${declNoun(outer)} ${outer.name} \u2192 ${q.dropped} is not an authorable key)`); + // ⛔ NO new fallback here: fall through to the container branch, which decides on + // exactly the terms it already decides on for every other line that reaches it. + } if (usable(outer) && outer.kind !== 'member') return [{ name: outer.name, container: !!outer.container, from: declarationProvenance(outer, null, outer.line) }]; return []; } @@ -1311,7 +1446,7 @@ function isLiteralAnchorShape(lit) { } /** Route tails and identifier-shaped string literals appearing on the changed lines. */ -function literalAnchorsFromLines(lines, changed) { +function literalAnchorsFromLines(lines, changed, surface = liveContainerSurface()) { const routes = new Set(); const literals = new Set(); // WHERE each one sat, for the emitted row (#12824). Derived lazily — the enclosing @@ -1324,7 +1459,7 @@ function literalAnchorsFromLines(lines, changed) { if (line === undefined) continue; let enclosing; const enclosingName = () => { - if (enclosing === undefined) enclosing = documentableDeclarationsAt(lines, n - 1)[0] || null; + if (enclosing === undefined) enclosing = documentableDeclarationsAt(lines, n - 1, surface)[0] || null; return enclosing ? enclosing.name : null; }; for (const m of line.replace(/\$\{[^}]*\}/g, '').matchAll(/(?:\/[A-Za-z0-9_:.$*{}-]+){2,}/g)) { @@ -1356,7 +1491,7 @@ function literalAnchorsFromLines(lines, changed) { * derivation anywhere stays bridgeable even if some other line derived it as a * container. `bridgeable ⊆ names` always. */ -function symbolAnchorsFromSource(text, changed) { +function symbolAnchorsFromSource(text, changed, surface = liveContainerSurface()) { const lines = text.split('\n'); const names = new Set(); const bridgeable = new Set(); @@ -1366,7 +1501,7 @@ function symbolAnchorsFromSource(text, changed) { const from = new Map(); for (const n of changed) { if (n - 1 < 0 || n - 1 >= lines.length) continue; - for (const d of documentableDeclarationsAt(lines, n - 1)) { + for (const d of documentableDeclarationsAt(lines, n - 1, surface)) { names.add(d.name); noteFrom(from, d.name, d.from); if (!d.container) bridgeable.add(d.name); @@ -2748,8 +2883,23 @@ function selfTest() { ' }', '}', ].join('\n'); - const anchorsAt = (src, lineNo) => symbolAnchorsFromSource(src, [lineNo]).names; - const bridgeableAt = (src, lineNo) => symbolAnchorsFromSource(src, [lineNo]).bridgeable; + // ---- the container qualifier every anchor case below runs under (#13713) ---- + // A FIXTURE surface, not the live artifacts, so `--self-test` keeps its no-repo-state + // contract and every case states its own preconditions. Its four entries are copied + // from the real generated map rather than invented — `ObjectSchemaBase`/`ObjectSchema` + // and `DatasourceSchema` are `data.json` entries, `ListViewShapeSchema` is a real + // `ui.json` collision — and the containers deliberately LEFT OUT (`MetaOverlayCacheKey`, + // `DatasourceDef`, `SysScimConnectionBinding`, `RestServer`, …) are exactly the ones the + // real map does not carry either. That absence is what the case-(c) pins below exercise. + const selfTestSurface = buildContainerSurface( + [ + { entries: { ObjectSchemaBase: 'data/Object', DatasourceSchema: 'data/Datasource' }, collisions: [] }, + { entries: { ListViewShapeSchema: 'ui/ListView' }, collisions: ['ListViewShapeSchema'] }, + ], + ['data/Object:userActions', 'data/Object:managedBy', 'data/Datasource:schemaMode', 'ui/ListView:userActions'], + ); + const anchorsAt = (src, lineNo, surface = selfTestSurface) => symbolAnchorsFromSource(src, [lineNo], surface).names; + const bridgeableAt = (src, lineNo, surface = selfTestSurface) => symbolAnchorsFromSource(src, [lineNo], surface).bridgeable; const symbolCases = [ // [1-based line, expected anchor set, label] [9, ['auditMetaItem'], 'a changed METHOD BODY anchors on the method, not on its 20k-line class'], @@ -2811,15 +2961,15 @@ function selfTest() { ' }),', '});', ].join('\n'); - const fromAt = (src, lineNo) => { - const d = documentableDeclarationsAt(src.split('\n'), lineNo - 1)[0]; + const fromAt = (src, lineNo, surface = selfTestSurface) => { + const d = documentableDeclarationsAt(src.split('\n'), lineNo - 1, surface)[0]; return d ? `${d.name} — ${d.from}` : null; }; const provenanceCases = [ [cacheStructSource, 5, 'organizationId — a field of interface MetaOverlayCacheKey', 'the NOISY face: a data property of an internal cache struct, and the row now says so'], - [authorableSource, 2, 'userActions — a field of const object ObjectSchemaBase', - 'the VALUABLE face: the same `name:` form on an authorable spec object — option B dropped this row, C prints it'], + [authorableSource, 2, 'userActions — a field of const object ObjectSchemaBase, an authorable key of data/Object', + 'the VALUABLE face: the same `name:` form on an authorable spec object — option B dropped this row, C prints it and D says WHY it is kept'], [protocolSource, 9, 'auditMetaItem — a method of class ObjectStackProtocolImplementation', 'a changed method body names its method AND the class it lives in'], [protocolSource, 1, 'ObjectStackProtocolImplementation — a top-level class', @@ -2859,7 +3009,7 @@ function selfTest() { [authorableSource, 2, 'a data property of an authorable schema'], [schemaSource, 6, 'a function-local line'], ]) { - const got = symbolAnchorsFromSource(src, [line]); + const got = symbolAnchorsFromSource(src, [line], selfTestSurface); check('symbolAnchorsFromSource.from', `the provenance key set IS the anchor set — ${label}`, `line ${line}`, JSON.stringify([...got.names].sort()), JSON.stringify([...got.from.keys()].sort())); } @@ -5195,6 +5345,13 @@ const unanchoredRuleNote = unanchoredRuleBlocks.length const overbroadNote = overbroadAnchors.length ? `; ${overbroadAnchors.length} over-broad anchor(s) dropped (${overbroadAnchors.join(', ')})` : ''; +// The third narrowing (#13713), reported on the same terms as the two guards above: a +// data-property anchor this run suppressed BECAUSE its declaring container resolved to a +// spec type that does not declare the property as authorable. Every other data property +// still mints, including every one whose container this could not resolve. +const containerQualifiedNote = containerQualifiedDrops.size + ? `; ${containerQualifiedDrops.size} data-property anchor(s) container-qualified away (${[...containerQualifiedDrops].sort().join(', ')})` + : ''; const crossCuttingNote = crossCuttingSymbols.length ? `; ${crossCuttingSymbols.length} cross-cutting symbol(s) contributed no route anchor (${crossCuttingSymbols.join(', ')})` : ''; @@ -5210,7 +5367,7 @@ const bridgeCoverageNote = bridgeCoverage.measured emit( affected.map((a) => a.doc), changedPackages, - `${affected.length} docs name something this change touched (${anchorSummary}) across ${changedPackages.length} changed package(s) since ${sinceRef}${skipNote}${anchorlessNote}${unmappedCommandNote}${unanchoredRuleNote}${crossCuttingNote}${bridgeCoverageNote}${overbroadNote}`, + `${affected.length} docs name something this change touched (${anchorSummary}) across ${changedPackages.length} changed package(s) since ${sinceRef}${skipNote}${anchorlessNote}${unmappedCommandNote}${unanchoredRuleNote}${crossCuttingNote}${bridgeCoverageNote}${overbroadNote}${containerQualifiedNote}`, affected, { testFilesSkipped, scriptFilesSkipped, devOnlyManifestsSkipped }, { @@ -5224,6 +5381,7 @@ emit( bridgeCoverage, weakAnchorsDropped, overbroadAnchors, + containerQualifiedDrops: [...containerQualifiedDrops].sort(), packageMentionDocs, }, ); @@ -5302,6 +5460,7 @@ function emit(docList, changedPackages, summary, detail, skipped = {}, anchorInf const { anchors: anchorList = [], anchorlessChanges: anchorless = [], crossCuttingSymbols: crossCutting = [], weakAnchorsDropped: weak = [], overbroadAnchors: overbroad = [], packageMentionDocs: coarse = [], + containerQualifiedDrops: containerDrops = [], unmappedCommandFiles: unmappedCommands = [], unanchoredRuleBlocks: unanchoredRules = [], // `null` for the `--all` arm, which derives no anchors and runs no bridge. Distinct // from `{ measured: false }`, which means the bridge was available and stood down. @@ -5360,6 +5519,11 @@ function emit(docList, changedPackages, summary, detail, skipped = {}, anchorInf // one level down, and these two fields are what keep it reviewable. weakAnchorsDropped: weak, overbroadAnchors: overbroad, + // The same accounting for the THIRD narrowing (#13713 / option D of #12824's + // ruling). A container-qualified drop is a deliberate false negative, and a + // false negative that no field names is one nobody can audit — so it is named, + // with the spec key that failed to be authorable. + containerQualifiedDrops: containerDrops, // The superseded COARSE set: docs merely MENTIONING a changed package. Kept for // the deliberately-wide backstop, and labelled so it is never mistaken for the // work list again (it was measured wrong in both directions — see the header). From 9c138ec28ba77cc40f2fd58890a8fee3272c870f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 14:01:26 +0000 Subject: [PATCH 2/4] test(docs-audit): pin the three branches of container qualification (#13713) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV --- scripts/docs-audit/affected-docs.mjs | 136 +++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/scripts/docs-audit/affected-docs.mjs b/scripts/docs-audit/affected-docs.mjs index 3d7203d9d7..cc226a4f3c 100644 --- a/scripts/docs-audit/affected-docs.mjs +++ b/scripts/docs-audit/affected-docs.mjs @@ -2998,6 +2998,142 @@ function selfTest() { check('memberFormOn', label, line.trim(), want, memberFormOn(line, name)); } + // ---- CONTAINER-QUALIFIED DATA-PROPERTY ANCHORS (option D, #13713) --------- + // The ruling of 2026-08-31 took option D as a project and ruled option B OUT — including + // as a fallback. These pins hold the three branches apart, and the (c) pins are the + // anti-B guard: they go red the moment an unmapped or ambiguous container starts + // dropping. `selfTestSurface` above is what every case here runs against, so each one + // states its own precondition instead of depending on today's generated artifacts. + // + // The DROP leg is a deliberate false negative, so it is pinned twice: once for the + // anchor it removes and once for the ledger entry that names the removal. A drop no + // field reports is one no reviewer can audit. + const authorableUseSite = [ + 'const DatasourceSchema = strictObject({', // maps to data/Datasource + ' managedBy: z.literal("system"),', // an authorable key NAME, not of THIS type + '});', + ].join('\n'); + const unmappedUseSite = [ + 'const SysScimConnectionBinding = strictObject({', // an internal type, in no shard + ' managedBy: z.literal("system"),', + '});', + ].join('\n'); + const ambiguousSource = [ + 'const ListViewShapeSchema = strictObject({', // a REAL collision in ui.json + ' userActions: strictObject({}),', + '});', + ].join('\n'); + const datasourceDefSource = [ + 'export interface DatasourceSchema {', + ' schemaMode?: string;', + '}', + ].join('\n'); + // The REAL declaration form of the second ruled true positive, transcribed from + // `packages/objectql/src/engine.ts` — used against the LIVE surface further down. + const datasourceDefSourceLive = [ + 'export interface DatasourceDef {', + ' schemaMode?: string;', + '}', + ].join('\n'); + const methodOnMappedSource = [ + 'const ObjectSchemaBase = strictObject({', + ' resolveAffordances(input: unknown) {', // a METHOD, not a data property + ' return input;', + ' },', + '});', + ].join('\n'); + + const qualificationCases = [ + [authorableSource, 2, ['userActions'], + '(a) MINT — a data property of a container that maps to a spec type declaring it authorable'], + [datasourceDefSource, 2, ['schemaMode'], + '(a) MINT — the second ruled true positive, reached through the map rather than through case (c)'], + [authorableUseSite, 2, ['DatasourceSchema'], + '(b) DROP — an authorable key NAME on a mapped container that does not declare it (the SCIM use-site class) falls to the container branch, minting no property anchor'], + [unmappedUseSite, 2, ['managedBy'], + '(c) KEEP — ⛔ the SAME use site on an UNMAPPED container still mints. This is the anti-option-B pin: fail toward noise, never toward silence'], + [ambiguousSource, 2, ['userActions'], + '(c) KEEP — an AMBIGUOUS container (listed in a shard collision) is not an answer, so it changes nothing'], + [cacheStructSource, 5, ['organizationId'], + '(c) KEEP — the noisiest measured anchor stays, because no shard maps `MetaOverlayCacheKey`. The realised reduction is smaller than the projection for exactly this reason, and that is the ruled direction'], + [methodOnMappedSource, 2, ['resolveAffordances'], + 'a METHOD of a mapped container is not the construct the ruling is about — untouched'], + ]; + for (const [src, line, want, label] of qualificationCases) { + check('documentableDeclarationsAt.qualified', label, `line ${line}`, JSON.stringify(want), JSON.stringify([...anchorsAt(src, line)])); + } + + // The DROP is published, with the spec key that failed to be authorable. + containerQualifiedDrops.clear(); + anchorsAt(authorableUseSite, 2); + check('containerQualifiedDrops', 'a container-qualified drop names itself, its container and the key that was not authorable', + 'one entry', 'managedBy (const object DatasourceSchema → data/Datasource:managedBy is not an authorable key)', + [...containerQualifiedDrops].join(' | ')); + containerQualifiedDrops.clear(); + anchorsAt(unmappedUseSite, 2); + check('containerQualifiedDrops', 'and a KEPT anchor is not reported as a drop — the ledger counts removals only', + 'no entries', '', [...containerQualifiedDrops].join(' | ')); + containerQualifiedDrops.clear(); + + // ⛔ THE DEGRADATION. `packages/spec` may be absent where this script runs, and an + // unreadable artifact must cost noise rather than silence: every container reads as + // UNMAPPED, so every case above collapses onto today's behaviour. + for (const [src, line, want, label] of [ + [authorableUseSite, 2, ['managedBy'], 'the drop leg becomes a keep'], + [authorableSource, 2, ['userActions'], 'and the true positive is still minted'], + ]) { + check('documentableDeclarationsAt.unavailable', `no artifacts ⇒ pre-#13713 behaviour — ${label}`, `line ${line}`, + JSON.stringify(want), JSON.stringify([...anchorsAt(src, line, UNAVAILABLE_CONTAINER_SURFACE)])); + } + check('documentableDeclarationsAt.unavailable', 'and the degraded run reports no drops', 'no entries', '', + [...containerQualifiedDrops].join(' | ')); + + // The merge itself: three states, and only the resolved one may ever suppress an anchor. + const mergeSurface = buildContainerSurface( + [{ entries: { A: 'data/Object', C: 'ui/ListView' }, collisions: ['B'] }, + { entries: { B: 'data/Object', C: 'data/Object' }, collisions: [] }], + ['data/Object:x'], + ); + const mergeCases = [ + ['A', 'data/Object', 'a name one shard maps resolves'], + ['B', null, 'a name a shard lists as a COLLISION is ambiguous — null, never a target'], + ['C', null, 'and so is a name two shards map to DIFFERENT types, collision list or not'], + ['D', undefined, 'a name no shard carries is UNMAPPED — distinct from ambiguous, and both keep'], + ]; + for (const [name, want, label] of mergeCases) { + check('buildContainerSurface.resolve', label, name, String(want), String(mergeSurface.resolve(name))); + } + check('buildContainerSurface.isAuthorable', 'the authorable test is on the RESOLVED type plus the property', 'data/Object:x', + true, mergeSurface.isAuthorable('data/Object', 'x')); + check('buildContainerSurface.isAuthorable', 'and a property of the wrong type is not authorable by NAME alone — #12824 disproved discriminator 3', 'ui/ListView:x', + false, mergeSurface.isAuthorable('ui/ListView', 'x')); + + // ---- the two ruled true positives, against the LIVE generated artifacts ---- + // ⭐ Deliberately NOT fixtures. The ruling pins these two rows, and a fixture surface + // proves nothing about the map the tool actually reads: if `gen:declaration-map` or + // `gen:schema` stops emitting what this reads, the qualification silently reverts to + // pre-#13713 behaviour and no fixture pin would notice. These cases are what notice. + const live = liveContainerSurface(); + check('liveContainerSurface', 'the generated artifacts are readable — a silent revert to pre-#13713 behaviour is itself the regression', 'available', true, live.available); + check('liveContainerSurface', 'ObjectSchemaBase resolves — the `userActions` pin runs through case (a)', 'data/Object', 'data/Object', String(live.resolve('ObjectSchemaBase'))); + check('liveContainerSurface', 'and data/Object:userActions is authorable, which is what keeps that row', 'authorable', true, live.isAuthorable('data/Object', 'userActions')); + // ⚠️ HONEST NOTE, and it is the reconciliation this card owes its own projection: + // `schemaMode` is declared on `DatasourceDef`, an objectql-local interface that the + // generated map does NOT carry. So this pin survives through case (c) — the unmapped + // keep — and NOT through the authorable lookup. ⛔ Do not "fix" that by teaching this + // script a mapping: a missing entry is a spec-lane card. The line below states the + // absence as a fact so a future map that DOES carry it goes red here and is read + // deliberately rather than silently. + check('liveContainerSurface', 'DatasourceDef is NOT in the generated map — the schemaMode pin therefore survives via case (c), the unmapped keep', 'unmapped', 'undefined', String(live.resolve('DatasourceDef'))); + check('liveContainerSurface', 'and data/Datasource:schemaMode IS authorable, so the pin also survives via case (a) the day the map carries its container', 'authorable', true, live.isAuthorable('data/Datasource', 'schemaMode')); + for (const [src, line, want, label] of [ + [authorableSource, 2, ['userActions'], 'userActions at its declaration form still mints — ruled true positive 1'], + [datasourceDefSourceLive, 2, ['schemaMode'], 'schemaMode at its declaration form still mints — ruled true positive 2'], + ]) { + check('documentableDeclarationsAt.live', label, `line ${line}`, JSON.stringify(want), JSON.stringify([...anchorsAt(src, line, live)])); + } + containerQualifiedDrops.clear(); + // ⭐ THE INVARIANT THAT MAKES "零 recall 变化" A PROPERTY OF THE CODE. Provenance is a // parallel map, so the only way it could move the list is by introducing or withholding // a NAME. Both directions are pinned: its key set is exactly the anchor set, never a From eed4f96652bbf55ee6c3410c6bbf315f09a099cb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 14:10:08 +0000 Subject: [PATCH 3/4] docs(docs-audit): document the container qualification (#13713) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV --- scripts/docs-audit/README.md | 65 +++++++++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/scripts/docs-audit/README.md b/scripts/docs-audit/README.md index aeaf26c581..74eddd7bb0 100644 --- a/scripts/docs-audit/README.md +++ b/scripts/docs-audit/README.md @@ -48,7 +48,7 @@ Three anchor kinds, each exact: | anchor | what it is | how it is derived | |:--|:--|:--| -| `symbol` | a documentable declaration the diff touched | the top-level declaration, or a member of a top-level **container** (class / interface / type / enum / schema object), enclosing each changed line — on **both** sides of the diff, so a removed export still anchors the pages naming it | +| `symbol` | a documentable declaration the diff touched | the top-level declaration, or a member of a top-level **container** (class / interface / type / enum / schema object), enclosing each changed line — on **both** sides of the diff, so a removed export still anchors the pages naming it. A member that is a **data property** is additionally qualified by its declaring container against the authorable surface (see below) | | `route` | a wire path the change touched | a path literal on a changed line, plus every route whose **registrar handler** references a changed symbol | | `sdk` | the client method bound to an anchor route | the declared `route` ⟷ `client` rows in the repo's route ledgers | @@ -104,12 +104,69 @@ maintainer's decision of 2026-08-31 took this (option C) and ruled OUT filtering rows. A false positive costs a reader a minute; a false negative ships a falsified page. So no guard, no threshold and no bridge hop reads `from` — `--self-test` pins that the provenance key set is exactly the anchor set, in both directions, so a future change -cannot start deciding with it without going red. Container-qualified *discrimination* -lives on #13712 (the spec half) and #13713 (the docs-audit half), and needs a TS-name → -spec-name mapping this does not have. +cannot start deciding with it without going red. Container-qualified *discrimination* is +the separate, later step below (option D), and it decides from the declarations directly, +never by parsing this clause back out. + +### A data property is qualified by its declaring container (#13713) + +Option D of the same ruling, and the half that *does* move rows. It landed once the spec +side (#13712) published the mapping it needs: `packages/spec/declaration-map/*.json` maps a +**TS declaration name** to a **spec type name** (`ObjectSchemaBase` → `data/Object`), and +`packages/spec/authorable-surface.base.json` keys the authorable surface as +`container:property` (`data/Object:userActions`). Both are generated and covered by +`check:generated`; this script only ever reads them, and ⛔ never carries a local mapping +table of its own — a container the map lacks is a spec-lane card, not a local fix. + +For a data property `prop` (`name:` / `name?:` / `name =`) whose most specific enclosing +declaration is the container `C`: + +| | `C` resolves to | `C:prop` authorable | verdict | +|:--|:--|:--|:--| +| (a) | `cat/T` | yes | **mint** — today's behaviour, now justified. The row also says so: `a field of const object ObjectSchemaBase, an authorable key of data/Object` | +| (b) | `cat/T` | no | **drop** the property anchor and fall through to the container branch that already existed — no new fallback | +| (c) | nothing, or two different types | — | **mint, unchanged** | + +⛔ **(c) must never become "drop".** That is option **B** (blanket prefer-container) by +another name, and it is vetoed on two rounds of measurement: its "zero recall loss" was +measured against a ground truth of 10 of 46 pages, most of which were never in this tool's +corpus at all (the real recall is 48.8%, re-derived), and the rows it drops are the most +valuable ones the tool mints. **Fail toward noise, never toward silence.** A method, a +nested interface, a namespaced type — anything whose winning declaration is not a data +property — is outside the rule entirely. + +Two consequences worth stating plainly, because both are easy to mistake for bugs: + +- **Both ruled true positives survive, and they survive by different routes.** + `userActions` is a key of `ObjectSchemaBase`, which the map carries, so it is kept by + (a) — `data-modeling/objects.mdx` is still listed and the row now names the spec key. + `schemaMode` is declared on `DatasourceDef`, an **objectql-local** interface the + generated map does **not** carry, so it is kept by (c) — the unmapped keep, not the + authorable lookup. `--self-test` pins that absence as a fact rather than papering over + it; the day the map carries `DatasourceDef`, `data/Datasource:schemaMode` is already an + authorable key and the row survives via (a) instead. +- **The realised reduction is far smaller than the −17.4% the projection quoted**, and for + the same reason: the projection hand-classified the 20 dropping containers, and 18 of + them are internal implementation types (`MetaOverlayCacheKey`, `LocalizationCacheEntry`, + `AUTH_MODEL_TO_PROTOCOL`, …) that the generated map does not carry either. Under the + ruled rule those are case (c) and they **keep**. Only containers the map resolves can + drop. Widening the drop set to reach the projected number would be option B; the number + moves when the *map's coverage* grows, which is spec-lane work. + +Every drop is published in `containerQualifiedDrops`, naming the property, its container +and the spec key that failed to be authorable — the same discipline as the two guards +below. A deliberate false negative that no field names is one no reviewer can audit. + +If either artifact is unreadable (this script runs in contexts where `packages/spec` may be +absent) every container reads as unmapped, the run behaves exactly as it did before this +existed, and one note goes to stderr. `--self-test` pins that degradation too. ### Two guards, and both publish what they removed +(Three narrowings publish now — the container qualification above reports +`containerQualifiedDrops` on the same terms. These two are the ones that run over the +whole anchor set, in every kind, after it is derived.) + The first build of this derivation was, on some PRs, *noisier* than the proxy it replaced (134 rows where the old tool gave 26). Two guards fixed that, and both run **before** the route bridge — a name left in the set does not merely add a noisy row, it mints noisy route From a718f2967d7b943c0d7b013f1ed0cf889744334e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 14:14:58 +0000 Subject: [PATCH 4/4] fix(docs-audit): read the live authorable-surface ratchet, not only its pinned anchor (#13713) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV --- scripts/docs-audit/README.md | 19 ++++++++-- scripts/docs-audit/affected-docs.mjs | 57 +++++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/scripts/docs-audit/README.md b/scripts/docs-audit/README.md index 74eddd7bb0..7e8be71c79 100644 --- a/scripts/docs-audit/README.md +++ b/scripts/docs-audit/README.md @@ -113,10 +113,21 @@ never by parsing this clause back out. Option D of the same ruling, and the half that *does* move rows. It landed once the spec side (#13712) published the mapping it needs: `packages/spec/declaration-map/*.json` maps a **TS declaration name** to a **spec type name** (`ObjectSchemaBase` → `data/Object`), and -`packages/spec/authorable-surface.base.json` keys the authorable surface as -`container:property` (`data/Object:userActions`). Both are generated and covered by -`check:generated`; this script only ever reads them, and ⛔ never carries a local mapping -table of its own — a container the map lacks is a spec-lane card, not a local fix. +the authorable surface is keyed as `container:property` (`data/Object:userActions`). All of +it is generated and covered by `check:generated`; this script only ever reads it, and ⛔ +never carries a local mapping table of its own — a container the map lacks is a spec-lane +card, not a local fix. + +⚠️ The authorable surface is read as the **union** of `packages/spec/authorable-surface/` +(the live per-category ratchet, read as one set) and +`packages/spec/authorable-surface.base.json` (the artifact #12824 names). The second is an +**anchor**, pinned at a fixed `baseRev` for the deletion gate — measured on this tree it +lagged the ratchet by 532 keys, including `data/Object:editMode` and every key of +`security/OrgScopingEntitlement` and `api/ProvenanceWaiver`. Reading the anchor alone would +suppress anchors on genuinely authorable keys, and that class grows with every key added +after `baseRev`. A union can only ever keep an anchor one source vouches for, never drop +one more. `[RETIRED]` is stripped for the same reason: a tombstoned key still rejects with +an upgrade prescription, so it is still surface a page documents. For a data property `prop` (`name:` / `name?:` / `name =`) whose most specific enclosing declaration is the container `C`: diff --git a/scripts/docs-audit/affected-docs.mjs b/scripts/docs-audit/affected-docs.mjs index cc226a4f3c..0923d8c83a 100644 --- a/scripts/docs-audit/affected-docs.mjs +++ b/scripts/docs-audit/affected-docs.mjs @@ -392,8 +392,29 @@ function declarationProvenance(winner, container, line, note = null) { * today's behaviour. */ const DECLARATION_MAP_DIR = 'packages/spec/declaration-map'; +const AUTHORABLE_SURFACE_DIR = 'packages/spec/authorable-surface'; const AUTHORABLE_SURFACE_FILE = 'packages/spec/authorable-surface.base.json'; +/** + * ⚠️ BOTH authorable-surface artifacts are read, as a UNION, and the reason is a measured + * false-negative class rather than belt-and-braces. + * + * `authorable-surface.base.json` is what #12824 names, and it is an ANCHOR: its own + * `description` says it is "a verbatim copy of the keys in authorable-surface/ as they + * stood at `baseRev`" — a fixed commit for the deletion gate. Measured on this tree it + * lagged the live ratchet by 532 keys, and the lag is load-bearing here: `data/Object:editMode` + * and every key of `security/OrgScopingEntitlement` and `api/ProvenanceWaiver` are absent + * from it and present in `authorable-surface/`, so reading the anchor ALONE would suppress + * anchors on genuinely authorable keys — a false negative, growing with every key added + * after `baseRev`, which is the one direction the ruling forbids. + * + * The union can only ever KEEP an anchor that either source vouches for, never drop one + * more. `[RETIRED]` is stripped for the same reason: the ratchet's own description says a + * tombstoned key "still rejects with an upgrade prescription", so it is still surface a + * page documents, and an exact-match lookup would silently drop all 103 of them. + */ +const AUTHORABLE_KEY_SUFFIX_RE = /\s\[[A-Z]+\]$/; + /** * The container qualifier, built from those two artifacts. PURE, so `--self-test` pins * every branch of the rule with no repo state (the live loader below is the only part @@ -420,7 +441,7 @@ function buildContainerSurface(shards, keys) { } else byName.set(name, target); } } - const authorable = new Set(keys || []); + const authorable = new Set((keys || []).map((k) => String(k).replace(AUTHORABLE_KEY_SUFFIX_RE, ''))); return { available: true, resolve: (name) => byName.get(name), @@ -455,13 +476,22 @@ function liveContainerSurface() { const shards = readdirSync(dir) .filter((f) => f.endsWith('.json')) .map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8'))); - const surface = JSON.parse(readFileSync(join(repoRoot, AUTHORABLE_SURFACE_FILE), 'utf8')); - if (!shards.length || !Array.isArray(surface.keys) || !surface.keys.length) throw new Error('empty artifacts'); - containerSurfaceCache = buildContainerSurface(shards, surface.keys); + const keys = []; + try { + const sdir = join(repoRoot, AUTHORABLE_SURFACE_DIR); + for (const f of readdirSync(sdir)) { + if (f.endsWith('.json')) keys.push(...(JSON.parse(readFileSync(join(sdir, f), 'utf8')).keys || [])); + } + } catch { /* the live ratchet is optional; the anchor below is the floor */ } + try { + keys.push(...(JSON.parse(readFileSync(join(repoRoot, AUTHORABLE_SURFACE_FILE), 'utf8')).keys || [])); + } catch { /* and vice versa — one of the two is enough */ } + if (!shards.length || !keys.length) throw new Error('empty artifacts'); + containerSurfaceCache = buildContainerSurface(shards, keys); } catch (e) { // ONE note, on stderr, so the degradation is visible without changing any output the // consumers parse. Anchors are unaffected — see UNAVAILABLE_CONTAINER_SURFACE. - process.stderr.write(`affected-docs: container qualification disabled (${DECLARATION_MAP_DIR} / ${AUTHORABLE_SURFACE_FILE} unreadable: ${e && e.message}); data-property anchors keep their pre-#13713 behaviour\n`); + process.stderr.write(`affected-docs: container qualification disabled (${DECLARATION_MAP_DIR} / ${AUTHORABLE_SURFACE_DIR} + ${AUTHORABLE_SURFACE_FILE} unreadable: ${e && e.message}); data-property anchors keep their pre-#13713 behaviour\n`); containerSurfaceCache = UNAVAILABLE_CONTAINER_SURFACE; } return containerSurfaceCache; @@ -3107,6 +3137,14 @@ function selfTest() { true, mergeSurface.isAuthorable('data/Object', 'x')); check('buildContainerSurface.isAuthorable', 'and a property of the wrong type is not authorable by NAME alone — #12824 disproved discriminator 3', 'ui/ListView:x', false, mergeSurface.isAuthorable('ui/ListView', 'x')); + // `[RETIRED]` is a TOMBSTONE, not a deletion: the ratchet's own description says such a + // key "still rejects with an upgrade prescription", so it is still surface a page + // documents. An exact-match lookup would silently drop all 103 of them. + const retiredSurface = buildContainerSurface([{ entries: { S: 'ui/PageCardProps' }, collisions: [] }], ['ui/PageCardProps:body [RETIRED]']); + check('buildContainerSurface.isAuthorable', 'a tombstoned key is still authorable — the annotation is stripped, not matched', 'ui/PageCardProps:body', + true, retiredSurface.isAuthorable('ui/PageCardProps', 'body')); + check('buildContainerSurface.isAuthorable', 'and the annotation is not smuggled into the key itself', 'ui/PageCardProps:body [RETIRED]', + false, retiredSurface.isAuthorable('ui/PageCardProps', 'body [RETIRED]')); // ---- the two ruled true positives, against the LIVE generated artifacts ---- // ⭐ Deliberately NOT fixtures. The ruling pins these two rows, and a fixture surface @@ -3126,6 +3164,15 @@ function selfTest() { // deliberately rather than silently. check('liveContainerSurface', 'DatasourceDef is NOT in the generated map — the schemaMode pin therefore survives via case (c), the unmapped keep', 'unmapped', 'undefined', String(live.resolve('DatasourceDef'))); check('liveContainerSurface', 'and data/Datasource:schemaMode IS authorable, so the pin also survives via case (a) the day the map carries its container', 'authorable', true, live.isAuthorable('data/Datasource', 'schemaMode')); + // ⭐ THE ANCHOR IS A FLOOR, NOT THE SOURCE. `authorable-surface.base.json` is pinned at a + // fixed `baseRev` for the deletion gate, so reading it ALONE suppresses anchors on every + // key added since — a false negative that grows. This pin is what goes red if the union + // with the live `authorable-surface/` ratchet is ever removed: `data/Object:editMode` is + // a real key of `ObjectSchemaBase`, present in the ratchet and absent from the anchor. + check('liveContainerSurface', 'a key added since the anchor commit is still authorable — the live ratchet is read too', 'data/Object:editMode', + true, live.isAuthorable('data/Object', 'editMode')); + check('liveContainerSurface', 'and a tombstoned key of a mapped container is authorable through the live ratchet as well', 'ui/PageCardProps:body', + true, live.isAuthorable('ui/PageCardProps', 'body')); for (const [src, line, want, label] of [ [authorableSource, 2, ['userActions'], 'userActions at its declaration form still mints — ruled true positive 1'], [datasourceDefSourceLive, 2, ['schemaMode'], 'schemaMode at its declaration form still mints — ruled true positive 2'],