Uh oh!
There was an error while loading. Please reload this page.
fix(lint): narrow data-model rules to the canonical reference, and measure the two readers that stay tolerant - #13322
Conversation
`refOf` in `packages/lint/src/data-model-rules.ts` read `def?.reference || def?.reference_to`, so a relationship field spelled with the REJECTED alias resolved a target. #11567 settled that `reference` is the only relationship spelling `@objectstack/spec` declares and `reference_to` is answered with `unrecognized_keys` ("one key, one answer, on both doors"). `lintDataModel` runs over a schema-parsed stack, so the alias cannot appear here — the tolerance was inert, and where it did fire it made `relationship/missing-reference` report a valid target for a field that has none: the rule whose job is to catch the misspelling was the one accepting it. Mirrors the deliberate canonical-only narrowing already recorded in-file for `refOf` in `packages/lint/src/validate-security-posture.ts`, including its `typeof r === 'string'` guard — which also makes the declared `string | undefined` return type true (the old `||` chain returned whatever truthy value was there). Tests pair every alias assertion with a positive control on the canonical spelling, so a `refOf` that resolved nothing could not pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
…rrow-lint-reference-alias
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a9b9af299586123fd3efdfaee0dc73d198d77d36 && git checkout a9b9af299586123fd3efdfaee0dc73d198d77d36
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e8ff4274b4e16a06465f05271423a2cc87774475 1b9a8834c5ae6a846ee6f79ef443511a0928a4f9 && git checkout -B drift-repro e8ff4274b4e16a06465f05271423a2cc87774475 && git merge --no-ff 1b9a8834c5ae6a846ee6f79ef443511a0928a4f9
node scripts/docs-audit/affected-docs.mjs --json e8ff4274b4e16a06465f05271423a2cc87774475 |
os-zhuang
commented
Aug 30, 2026
PM review — accepted for landing (held in draft until CI is complete and green)Reviewed at head Scope — measured, each grep paired with a positive controlThree files: Zero hits for governed surfaces ( The fix
The argument that it applies with more force here is right and worth keeping: One behaviour delta the body does not name explicitly, and which I checked:
|
`check-reference-carrier-shape` refuses a non-string LITERAL at a `reference` carrier position, and it is right to: an authored site of that shape is invisible in both directions at once — refused by `ObjectSchema.safeParse` and read as `undefined` by every rule that resolves it (#13053). The new `a non-string reference is not a target either` case wrote one inline, so the gate reported it as a problem and `Lint & Repo Gates` went red. The value must keep being non-string — the whole assertion is that such a value resolves to nothing, which makes the case a deliberate counter-example rather than an authored carrier. So it is bound through a variable: the gate judges literals and leaves a non-literal unjudged, which keeps its authored-site sweep honest while the assertion drives the identical shape. The reasoning is recorded at the binding, mirroring the same move in PR #13238. ⛔ Not fixed by deleting the case, weakening the gate, or adding a path ignore — that gate has no baseline and wants none, by design. Re-ablated after the change: with `refOf` reverted to the `||` chain this case still goes RED, so the binding did not make the assertion vacuous. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
os-zhuang
commented
Aug 30, 2026
CI red — diagnosed, this PR's own, fix in flightRecording the diagnosis so the red is not sitting here unexplained. ⛔ Not an infra failure and not someone else's. What failed: That is this PR's own new case, No escape hatch, by design. I read the gate (584 lines). Its own docblock: "A baseline here would be a place to put the next defect," and its remedy text: "never by adding a path ignore here." There is no allowlist and there should not be one. The remedy its own doctrine names. Its refusal-scoping comment classifies three cases: ⇒ bind the non-string value through a variable so it is not a literal at the carrier position, with a comment at the binding naming the gate, #13053, and why this site is a deliberate counter-example rather than an authored mistake. House precedent for the same move: PR #13238 bound a field literal through a variable because an inline one would trip TypeScript's excess-property check on the very key that case existed to record, and left a comment at the binding saying so. ⛔ What is not happening: the test case is not being deleted, skipped or weakened. It pins that a non-string The fix must also re-run the ablation for that one case: a variable binding is exactly the kind of change that can quietly make an assertion vacuous, so with
|
Uh oh!
There was an error while loading. Please reload this page.
Part of #13250 — this PR closes one of the three doors that card names. The other two are measured and deliberately left alone; the evidence is below, so the card stays open for the maintainer's call on them.
1. What is fixed — the lint reader
packages/lint/src/data-model-rules.tsread:reference_tois a rejected alias —FieldSchemaanswers it withunrecognized_keysand "Did you meanreference_to→reference?" (#11567, "one key, one answer, on both doors").@objectstack/lintis documented as running over "an in-memory, schema-parsed stack object", so the alias cannot legitimately reach these rules at all: the tolerance was inert. Where it did fire, it maderelationship/missing-reference— the rule whose entire job is to catch a relationship with no target — report a valid target for a field that has none.Narrowed to canonical-only, mirroring the deliberate narrowing already recorded in-file for
refOfinpackages/lint/src/validate-security-posture.ts(including itstypeof r === 'string'guard, which also makes the declaredstring | undefinedreturn type true — the old||chain returned whatever truthy value it found).Behaviour delta, only for metadata the spec already refuses:
relationship/missing-reference(error) now fires on a relationship field whose only target spelling isreference_to; the rules that need a resolved target (relationship/master-detail-required,rollup/missing-summary, the rest of the relationship family) no longer treat such a field as pointing anywhere. Canonicalreferenceis untouched.Ablation — the new tests provably fail without the fix
Both legs rebuilt
@objectstack/lintand confirmed the mutation on disk and indist/before reading any result.Every alias assertion is paired with a positive control on the canonical spelling (and
FieldSchemaaccepting/refusing each), so arefOfthat resolved nothing could not pass.2. What is NOT fixed, and why — the measurement
Control 0 — the parse door really is shut
So anything observed below arrived through a door that is not the parse door.
Reader A —
packages/plugins/plugin-security/src/security-plugin.ts:5846— REACHABLEIts population is
this.ql.getSchema(object), i.e.SchemaRegistry.getObject.registerObjectdeliberately skips Zod (#3896 — its own comment: "the registry is the choke point every metadata door goes through, including the ones that skip Zod and lint"), andengine.tsstates the consequence outright: a row "came in around the parse seam (a rawregisterObject, or a stored/artifact row written before the tightening — the two populations parse-time rejection measurably cannot catch, since the engine registers raw objects and never re-parses)".Measured, real
SchemaRegistry, real round-trip:The alias survives registration verbatim and the reader resolves it.
resolveCbpRelationreturningnullis fail-closed: the read path returnsRLS_DENY_FILTER(0 rows for every non-admin caller) and the write path throwsMasterDetailRelationMissingError. So a raw-registeredcontrolled_by_parentobject whose relation is spelled with the alias would go from "access derived from its master" to "everything denied / writes throw". That is an availability break on a reachable population, and it is the maintainer's call, not this PR's.Reader B —
packages/verify/src/derive.ts:103— REACHABLEIts population is the app config
os verifyloads.loadConfig()does not parse: itbundleRequires the module and returnsmod.default. Parsing happens only if the authored module itself calls adefine*helper.And through the real
loadConfig()on a real config file that never calls adefine*helper:If narrowed, such a required relation becomes
blocked: 'required lookup field "parent" has no `reference` target'— loud rather than silent, but it converts objectsos verifyexercises today into objects it reports as blocked. Also a triage call.3. Verification
Re-verified at
1b9a8834cafter the CI-red follow-up (see section 4).The entire
Lint & Repo Gatesjob was run locally — all 187 commands, extracted from.github/workflows/lint.yml(single-linerun:steps plus every command inside its multi-linerun: |blocks). Exit codes captured before any pipe. Exactly one nonzero, and it is not a gate finding:RUNNER_TEMPis a GitHub Actions variable with no value outside a runner, so the path resolved to/verify-lanes.mjs. That is a runner-only step, not a measurement.Highlights from that sweep, quoting each gate's own verdict line:
check:type-check-debtandcheck:dual-build-cjs-loadshad refused to measure earlier for want of a built workspace; the job's own build steps supplied it, so both are now real greens rather than NOT MEASURED. In particular the ledger re-measure carriessurplus: none, which is the direct confirmation that the added test lines move the@objectstack/cliTEST_DEBT ratchet by zero.Two gates remain NOT MEASURED locally, each by its own refusal text and neither a red:
check-test-completeness.mjs(PREREQUISITE NOT MET — this gate grades a saved turbo test log, and no log was named; CI tees one) and the barecheck-half-states.mjs(PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential; the--self-testform CI actually runs exits 0).4. Follow-up: the CI red on
03c514700, and why the fix is a variable bindingnode packages/lint/scripts/check-reference-carrier-shape.mjsfailed on the new casea non-string reference is not a target either, which wrotereference: { object: 'project' }inline:The gate is right about what it sees, and the value must keep being non-string — the whole assertion is that such a value resolves to nothing, which makes the case a deliberate counter-example rather than an authored carrier. So the value is now bound through a variable, with the reasoning recorded at the binding (mirroring the same move in PR #13238).
That is the gate's own doctrine, not an evasion — from its docblock:
Read from the source:
classifyValuereturnsnon-literalfor an identifier, and both the finding branch and the refuse branch requirenon-string-literal. ⛔ The case was not deleted, skipped or weakened; the gate was not touched; no path ignore was added (it has no baseline and wants none, by design).⭐ Re-ablated after the binding, because a variable binding is exactly the kind of change that can quietly make an assertion vacuous. With
refOfreverted to the||chain, rebuilt, and the mutation confirmed live indist:scripts/pm/dispatch-gates.mjsderived for this diff. Two reasons compounded: it is a package-local script (packages/lint/scripts/…, outside the rootcheck:*namespace), and it is invoked inside a multi-linerun: |block. A derived family is a cheap first half, not the farm.Scope
packages/drivers/driver-mongodb/**untouched (driver-mongodb indexes lookup joins offreference_to— a key the spec REFUSES — so no authored lookup field has ever been indexed on MongoDB #13222 is atneeds-user-decision; PRs test(driver-mongodb): correct the lastreference_tofixture, pin the divergence it hid, and stop the published README teaching it #13224 and test(driver-mongodb): pin the lookup-index divergence on a lane that actually runs #13238 are in flight there).packages/spec/src/**untouched — no tier change.@objectstack/lint, patch);content/docs/releases/untouched.Generated by Claude Code
Generated by Claude Code