Skip to content

feat(lint): ratchet the relationship carrier shape at field-def positions - #13174

Merged
os-elon merged 2 commits into
mainfrom
claude/issue-13103-reference-carrier-ratchet
Aug 29, 2026
Merged

feat(lint): ratchet the relationship carrier shape at field-def positions#13174
os-elon merged 2 commits into
mainfrom
claude/issue-13103-reference-carrier-ratchet

Conversation

@os-elon

@os-elon os-elon commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Fixes #13103

Builds the one guard the card proved viable — the NARROW relationship-carrier ratchet — and drops the broad one the card had already refused.

The predicate

a reference whose value is a LITERAL must be a STRING literal, at a field-def carrier position.

Both qualifiers are load-bearing, and each has its own self-test cases.

  • LITERAL, not "must be a string". Identifiers holding string constants, runtime pass-throughs, template expressions with substitutions, the Zod declarations that DEFINE the key, and reference: null all stay unjudged — counted in the census, never dropped silently.
  • POSITION. reference is an ordinary English word: judging by key name alone reds four generated i18n bundles and three driver assertions on the day it lands.

New gate: packages/lint/scripts/check-reference-carrier-shape.mjs, wired into the required Lint & Repo Gates job. It reads source with scripts/ts-parse.mjs, needs no build and no package import, runs in ~5s.

Re-measured on this tree, not relayed from the card

The card's census was re-run rather than inherited (its numbers predate PR #13102 and five further commits). On 90aa24e4c6:

check-reference-carrier-shape: OK — 5420 file(s) scanned, 504 `reference` site(s).
  field-def carrier position:  443 string literal(s), 0 non-string literal(s) — 10 unjudged (6 non-literal, 4 null/undefined).
  provably not a carrier:      19 site(s), 7 of them non-string literals (excluded by SHAPE, not by path).
  position unresolved:         27 site(s) + 5 conflicting — 0 material (a refusal here is scoped to a non-string literal, where the readings disagree).

object-valued: 0. array-valued: 0. The zero-baseline ratchet holds, so there is no grandfathering file and nowhere to park the next defect.

Why not the broad guard

Whole-fixture ObjectSchema.safeParse was measured at 838 fixtures across 303 files going red, and they are invalid ON PURPOSE — a fixture that parses cleanly cannot drive a rule that fires on malformed input. This repo already adjudicated that carve-out in check:query-options-erasure's own printed text. So the population judged here is the CARRIER, never the fixture.

How a field-def carrier position is identified — and how it REFUSES

Two positive rules say a holder object literal IS one; three say it provably is not. Every one is derived from the spec at run time, never copied into the gate:

rule says source
R1 holder is a value in a fields map, or an element of a fields array structural — the exact shape of #13053
R2 holder declares type as a string literal that is a FieldType FieldType read from packages/spec/src/data/field.zod.ts
N1 reference is itself a key of a fields map, so it NAMES a field structural
N2 holder declares a key data/Field does not — FieldSchema is a strictObject key set read from packages/spec/authorable-surface/data.json
N3 holder declares type as a string literal that is NOT a FieldType the enum would refuse it

Neither side firing — or both — makes the position UNRESOLVED, and that is where the gate refuses rather than guessing. The refusal is scoped to the ambiguity that changes the verdict: a string literal passes under either reading, a non-literal is unjudged under either reading, and a non-string literal is where the readings disagree, so that one exits 3. Today: 27 unresolved plus 5 conflicting positions, every one holding a string literal — green, with the refusal branch live.

reference: null (4 sites in packages/spec/src/ai/solution-blueprint.test.ts) stays green and is verified against the source rather than assumed: StrictField, the shape behind SolutionBlueprintStrict, declares reference: z.string().nullable() at packages/spec/src/ai/solution-blueprint.zod.ts:281. null and undefined express ABSENCE of a target, not a target spelled wrongly, and they get their own census bucket so the exemption is visible.

Exclusions by SHAPE, not by path — and the path spellings were already stale

The card excluded three paths because reference there is a map KEY. Each was re-verified rather than inherited, and none survives as a path rule:

  • packages/spec/json-schema/** does not exist under that name; the sharded packages/spec/json-schema.manifest/ that replaced it contains the string reference zero times.
  • packages/spec/liveness/** is JSON, outside a TS/JS source scan entirely. Its reference really is a props-map key: in liveness/field.json it sits under props and under props then inlineColumns then children, never under fields.
  • The generated i18n maps are real and really do write reference: with an object value — four of them, and they are the only object-valued reference in the tree. N1 and N2 both exclude them by shape, which also covers the bundle nobody has generated yet and an object that legitimately declares a field NAMED reference.

Refusal when nothing was measured

A guard whose success condition equals its total-failure condition must refuse. A run that finds no field-def carrier — or no files — exits 3 saying "This is NOT a pass: nothing was measured", the shape check:dual-build-cjs-loads shipped. That branch is not theoretical here: ablating R2 over the real tree produced exactly that refusal (below).

Ablation — predictions named first, every mutation confirmed on disk, every restore proven byte-identical

Run from the committed state, one mutation at a time, each with an absolute-path trap restore, git hash-object compared against the HEAD blob, and git diff HEAD clean after each leg.

# mutation predicted observed
A1 reintroduce the #13053 defect at packages/lint/src/runtime-gate.object-writes.test.ts:358 exit 1, 1 finding at that line exit 1, 1 finding at that line
A2 ablate N1 alone exit 3, 4 i18n sites exit 0 — prediction WRONG, see below
A3 ablate N2 alone exit 3, 3 driver-array sites exit 3, exactly those 3
A4 a non-string literal at an unresolvable position exit 3, 1 site exit 3, 1 site
A5 ablate N1 and N2 together exit 3, 7 sites exit 3, exactly the 4 i18n plus the 3 driver arrays
A6 ablate N1, then run the self-test self-test exit 1 exit 1, failing not-i18n-map and conflict
A7 ablate R2 exit 0, string count falls toward 344 exit 3, "found ZERO carriers … This is NOT a pass"

Two of the seven readings contradicted the prediction, and both are recorded rather than smoothed:

  • A2. N1 alone is NOT what keeps the four real i18n bundles green — the census shows they fire N1+N2, so N2 subsumes N1 on today's tree. N1's own coverage is a shape N2 cannot reach (a map whose keys are all Field keys), and its instrument is therefore the self-test, which A6 shows going red. A5 is the reading that answers the real question: with both rules gone the gate refuses on exactly those 7 sites, so the shape-based exclusion is doing work.
  • A7. The mutation collapsed the field-def population to zero rather than to the R1-only 344, because the ablated branch made N3 fire unconditionally and conflict with R1. The prediction mis-modelled the mutation; the observed direction is the stronger evidence — the empty-measurement refusal firing over the real tree.

Verification

All of the below ran on 90aa24e4c6, the commit this PR pushes.

  • node packages/lint/scripts/check-reference-carrier-shape.mjs --self-test — exit 0: 20 classified-site cases plus 9 direct assertions, including the empty-vs-seeded tree control on disk.
  • node packages/lint/scripts/check-reference-carrier-shape.mjs — exit 0, output quoted above.
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack on the synced tree (the first derivation printed STALE TREE, so origin/main was merged in and it was re-derived). 32 families, all run, all exit 0, including check:self-test-wired, check:comment-mask-adoption, check:parse-guard, check:entry-guard, check:required-contexts, check:aggregator-roster, check:published-files, check:pnpm-filter-targets, check:nul-bytes, check:cross-package-test-inputs, plus the convention pair scripts/pm/bare-root-worklist.mjs --self-test and check:pm-dispatch-gates.
  • pnpm lint (full repo ESLint, not narrowed) — os-verify-lock: VERDICT command-exit 0, 68s under the shared verify lock.
  • Three gates answered with a REFUSAL, not a finding, and are recorded as NOT MEASURED: check-test-completeness.mjs (exit 1 + usage), check:type-check-debt (--re-measure cannot run: 56 workspace dependencies have no built type entry point), check:dual-build-cjs-loads (exit 3, "nothing was measured" — wants pnpm build).

No changeset

check:published-files reports packages/lint's files whitelist as dist, README.md, CHANGELOG.md. This diff adds an unpublished packages/lint/scripts/ file and a workflow step, touches no package.json, and changes no published byte — so it carries the skip-changeset label rather than a changeset.

Landing site

Hosted under packages/lint/scripts/ beside the two gates that already follow this shape, rather than as a new top-level scripts/ gate. Neither existing self-test was the right host: both judge the docs and skills corpus through a verdict imported from @objectstack/lint, while this one judges the whole source tree against packages/spec's FieldSchema, and folding it into either would make that gate's name lie about what it reads. It sits in the pre-build Lint & Repo Gates job rather than its siblings' post-build lane because it needs no build.

Generated by Claude Code


Generated by Claude Code

os-elon and others added 2 commits August 29, 2026 07:11
…ions

A `reference` whose value is a LITERAL must be a STRING literal, at a
field-def carrier position. Lands as a zero-baseline ratchet: re-measured
on this tree at 443 string carriers and 0 object/array ones, so there is
no grandfathering file.

Deliberately not the broad guard: running every object fixture through
ObjectSchema.safeParse was measured at 838 fixtures across 303 files going
red, and those fixtures are invalid on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1a540e82b1421c26ea036ff67e381689075b501cpackageMentionDocs.

@github-actions github-actions Bot added the ci/cd label Aug 29, 2026
@os-elon os-elon added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 29, 2026 — with Claude
@os-elon
os-elon marked this pull request as ready for review August 29, 2026 07:43
@os-elon
os-elon enabled auto-merge August 29, 2026 07:43
@os-elon
os-elon added this pull request to the merge queue Aug 29, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33244436318 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test:  FAIL  test/serve-publishes-bound-port.e2e.test.ts > #13062 the non-zero half — nothing an ordinary boot publishes may move > follows the DEV AUTO-SHIFT onto the port it really 
      ↳ 失败原因: (这条 FAIL 之后 12 行内没有可识别的原因行 —— 点进 job 看)
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 14 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant