Uh oh!
There was an error while loading. Please reload this page.
fix(cli): remove the ghost field types from generate.ts's three vocabularies - #14675
Conversation
…ularies (#13871) `generate.ts` carried three hand-authored field-type vocabularies — the `FIELD_TYPE_MAP` that `os generate types` reads, the `FIELD_TYPE_SQL_MAP` that `os generate migration --format sql` reads, and the `switch (fType)` in the typescript migration generator — none of which had ever been checked against the `FieldType` enum they claim to describe. Between them they named six types the platform has never had: `slug`, `ip_address`, `encrypted`, `integer`, `uuid`, and `geo_point`. Measured, not assumed. `git log -S` over the whole reachable history of `packages/spec/src/data/field.zod.ts` returns zero commits for every one of those tokens, so they are not leftovers of retired types — they were invented here (the maps first, the migration codegen mirroring them six hours later) and propagated table to table inside this one file. Both doors into the generator were driven: - Through every supported authoring path the arms are dead. `os init` scaffolds `export default defineStack({ … })`, `define*` is a strict `Schema.parse`, and a field typed `slug` is refused during config-module evaluation, inside `bundleRequire`, before the generator runs a line. - Through a config that parses nothing (a plain-object default export, or `defineStack(x, { strict: false })`) any string reaches `fType` and the ghost arms fire — `slug` emitted `table.string`, `integer` emitted `table.integer`. So the labels never served a valid input, and on the one input class that could reach them they advertised an acceptance surface no runtime can honour. Every ghost is deleted rather than re-spelled, per member: `number` already had its own entry and arm so `integer` had nothing to correct to; `address` is a structured postal address, not an IP; and the concepts that later arrived under other names (`secret`, `location`) have no entry in these tables at all, which is a coverage question rather than a spelling one and is filed separately. Behaviour is unchanged for every config the platform accepts. For a config that bypasses validation, one of the six now falls to the same default any unknown type gets — `table.text` / `TEXT` / `unknown`. The pin reads all three vocabularies out of the source and fails on any key or case label that is not a `FieldType` member, with a non-vacuity control on each extraction and a structural assertion that a fourth vocabulary cannot arrive unmeasured. It is forward-only: real members with no entry still fall to the deliberate default, which it does not prejudge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…d-types Keeps the gate-family derivation and the verification on a tree that is at origin/main, per scripts/pm/dispatch-gates.mjs's stale-tree refusal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
📓 Docs Drift CheckThis PR changes 1 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 22 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 6627976a5f10964f8f2bbde851d3b7def1dc3e40 && git checkout 6627976a5f10964f8f2bbde851d3b7def1dc3e40
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2aa8456cf2d66ec3825d262686fe4218e57cfd27 3d150eb0786aba6955e8e88fa0080aa5b3f1902c && git checkout -B drift-repro 2aa8456cf2d66ec3825d262686fe4218e57cfd27 && git merge --no-ff 3d150eb0786aba6955e8e88fa0080aa5b3f1902c
node scripts/docs-audit/affected-docs.mjs --json 2aa8456cf2d66ec3825d262686fe4218e57cfd27
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 33670772221 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 33674214419 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13871
packages/cli/src/commands/generate.tscarried three hand-authored field-typevocabularies and none had ever been checked against the
FieldTypeenum theyclaim to describe. This removes every label in them that names a type the
platform does not have, and adds a pin so the class cannot reopen.
The triage gated this card on two readings before any edit. Both were posted on
the issue before the first file was touched — #13871 (comment) — and are summarised here.
Reading ① — where the labels came from
History was read on a shallow clone:
origin/mainis grafted at67192ce0d4(2026-09-01), so
git log --diff-filter=Anames that graft commit as the"adding" commit for every file — an artifact. The real introducing commits are
reachable through the deeper-fetched stale
origin/claude/*branches, and thatis the boundary this reading is declared against.
'slug''ip_address''encrypted''integer''uuid''geo_point'FIELD_TYPE_MAP77241bdec9"feat: Phase 9 … generate types CLI …"'slug''ip_address''encrypted''integer''uuid''geo_point'FIELD_TYPE_SQL_MAPdb015d2827"feat(cli): add generate client, generate migration, codemod, and doctor deprecation scanner commands"'slug''ip_address''encrypted''integer''uuid'switch (fType)db015d2827(same 399-insertion block)Pinned per label AND per site (
git log -S"case 'slug'",git log -S"slug: 'VARCHAR(255)'", …), not inferred from one whole-file match.The reason is not a per-label decision. Neither commit message carries a
rationale. The migration codegen simply mirrored the vocabulary the types codegen
had invented six hours earlier. And the decisive negative reading:
git log -Sover the whole 127-commit reachable history of
packages/spec/src/data/field.zod.tsreturns zero commits for every one ofthe six tokens. They are not leftovers of retired spec types — they never
existed on the other side.
Reading ② — whether the input surface is
FieldType-gatedTraced:
Generate.run→runMigrationGeneration→loadConfig→generateMigrationTs(config)→String(fieldDef.type || 'text')→ the switch.loadConfigitself performs no validation — it isbundleRequireplus anamed-export merge. The gate, when there is one, lives in the authored config
module, as
config.ts's own doc comment states: "Everydefine*helper in@objectstack/specis aSchema.parse(config)… the rejection happens while theconfig MODULE is being evaluated, inside
bundleRequire".Measured on both doors, driven, not inferred.
Probe A — the gate (
defineStackfrompackages/spec/src/index.tsvia tsx):Probe B — the real codegen entry (the actual oclif command run as
migration --dry-runover a plain-object config export — the ungated door):The reading. The input surface is not structurally
FieldType-gated by theCLI, but it is not an alternative declared vocabulary either. Two doors:
os initscaffoldsexport default defineStack({ … })and every config in this repo goes througha
define*helper, which is strict by default. All the labels are refusedbefore the generator runs a line. The arms are unreachable.
defineStack(x, { strict: false })). Nothing parses; the ghost arms fire.Door 2 does not make the labels correct — this is the branch the triage flagged
as the critical one. A type outside
FieldTypeis refused by every other door(
os validate,os build,os serveall keep hearing the rejection;config.tssays so explicitly), so emitting a bespoke column for it does not serve it, it
advertises an acceptance surface no runtime can honour. The third disposition
— record the semantics and change no code — therefore does not hold, and the
ghost finding is not itself wrong. Recorded as a reading rather than a fork: the
three-way split is a per-member disposition and every member lands inside it.
Per-member disposition
Every one is a dead case ⇒ delete. Disposition ② (fix the spelling, do not
delete) applies to zero members on the evidence, and the triage's own example
is why: it asked whether
'integer'should becomenumber— andnumberalready has its own entry in both maps and its own
table.decimalarm, so therewas nothing to correct it to.
'slug'FieldTypecounterpart at all — the platform has no slug type, and never had one (zero hits infield.zod.tshistory). Its arm keepstext/email/phone/url/select/password/color.'ip_address'addressDid you mean 'address'?is a Levenshtein hint, not a semantic identity:addressis a structured postal address stored as JSON on the row, not an IP. Re-spelling would have handedaddressaVARCHAR(45)column.'encrypted'secret— butsecretlanded 2026-05-31 (6514f8df1e, "secret field type with encrypt-on-write to sys_secret"), three and a half months afterencryptedwas invented here, so this was never a misspelling of it. Re-spelling would also have taken a decision this card does not own:secrethas no entry in these tables at all, and what TS/SQL type it deserves belongs to the coverage card.'integer'numberalready occupies both maps and its own arm, so there is no spelling to fix.rating— a real member — keeps thetable.integerarm.'uuid'FieldTypemember either. The twotype: 'uuid'hits elsewhere in the repo are a database column type inDriverIntrospection, not a field type.lookup/master_detailkeep thetable.uuidarm.'geo_point'encrypted: the real GPS member islocation, which has no entry in either map, so re-spelling would be decidinglocation's column type rather than removing a ghost.What changed, and what deliberately did not
Changed —
packages/cli/src/commands/generate.ts, 3 files, +224/-16 overall:switch (fType)ingenerateMigrationTs(the card's surface): five ghostcase labels removed; every arm keeps its real members.
FIELD_TYPE_MAPandFIELD_TYPE_SQL_MAP: six ghost keys each, plus a docblock on each stating the invariant and naming the pin.
generate-field-type-vocabulary.pin.test.ts: the guard.patchchangeset for@objectstack/cli.Declared scope extension. The card names four labels in the switch. Measuring
mechanically rather than checking only those four found a fifth in the same
switch (
'uuid') and six in each of the two sibling tables. Those were takenin-place under the bounded exemption — ① same defect class (one hand-authored
field-type vocabulary in one file, propagated across its three tables), ②
mechanical with the correct shape pinned by
FieldTypeitself, ③ no other claimholds this file and no open PR touches it, ④ same gate family, no new
verification surface — and because one guard that closes the whole class beats
three-quarters of a guard. The extension was declared on the issue before the
first edit, in the same comment as the readings. If a reviewer wants the two
sibling tables trimmed back out, they are a contiguous slice of one commit.
Deliberately NOT changed:
os generatescaffolds four more artifact kindsos validaterefuses — object, view, action and app #14336's region, landed justbefore this branch): untouched.
packages/spec: untouched. No label named a real accept-set gap.FieldTypemembers that have no entry and fall to the deliberatedefault/|| fallback— 21 in the maps, 24 in the switch, includingsecret,address,location,code,tags. That is the opposite defectfrom a ghost and it needs decisions this card does not own (a
POINTcolumn isnot portable to SQLite; the embedded-structured family may want one rule).
Filed unassigned as [finding] packages/cli generate.ts: 21+ real FieldType members have no entry in any of the three field-type vocabularies and fall silently to the default column/TS type #14657. The pin is written forward-only so it does not
prejudge that card.
Behaviour. Unchanged for every config the platform accepts — those configs
cannot carry these types. For a config that bypasses validation, a field typed
with one of the six now falls to the same default any unknown type gets:
table.text/TEXT/unknown.Red-first
The pin was written and run against the unmodified
generate.tsfirst. Itfailed on all three vocabularies, naming every ghost, while the two non-vacuity
control assertions passed — so the extractors demonstrably read something:
Ablation — the pin can actually fail
Run after the implementation was committed, so the restore leg has a real
reference. No rebuild is involved on either leg, and that is a property of the
subject, not an omission: the pin reads
generate.tswithfs.readFileSyncfrom the checkout, so a source mutation reaches it directly; the only
distittouches is spec's, for
FieldType, which is not mutated. Atrap restore EXIT INT TERMwith an absolute path was armed before the mutation.The mutation re-added exactly one ghost label (
case 'integer':), was proved ondisk by counting the injected text and the erased text separately (not by the
editor's exit code, and not by a bare
git diff --stat), and the pin namedexactly that one label. Restore is proved by observing state — the blob hash
matches the HEAD blob and
git diff HEADis empty — never by agit checkoutexit code, and the restore names
HEADexplicitly rather than restoring from apossibly-polluted index.
Verification
All at the final commit
3d150eb078— the branch was merged up toorigin/main2aa8456cf2first, becausedispatch-gates.mjsrefused the earlier tree asSTALE (10 commits behind, 7 of the files the families derive from changed, the
deriving script itself among them). Every exit code below was captured before
any pipe.
pnpm --workspace-concurrency=2 --filter '@objectstack/cli^...' buildpnpm --filter @objectstack/cli typechecktsc --listFilesnames it, so this is not a vacuous "typecheck is clean")pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandspnpm check:nul-bytespnpm lint(whole repo)eslint . --no-inline-config), no narrowing claimed or neededHeavy runs went through
scripts/pm/os-verify-lock.sh; verdicts are read fromits
VERDICTline, never a bare$?.NOT MEASURED — 6 of the 37, in each gate's own words
None is a finding, and none is about this diff. Five need a whole-repo build
(this container built only
@objectstack/cli's closure) and one needs GitHub APIaccess this seat does not have (repo-scoped REST returns 403
GitHub access is not enabled for this session).node scripts/check-test-completeness.mjs(exit 3) — "Nothing was measured:this gate exited before parsing a single summary line … ⛔ It is NOT a finding".
Wants a saved
turbo run testlog.node scripts/pm/check-half-states.mjs(exit 3) — "Treat this exit as anunread instrument, never as a quiet board".
pnpm check:dual-build-cjs-loads(exit 3) — "PREREQUISITE NOT MET — this gatereads built output, and some package has no dist/ … ⛔ This is NOT a pass:
nothing was measured."
pnpm check:i18n(exit 1) — "Nothing was checked: no bundle was compared andno config was parsed".
pnpm check:i18n-coverage(exit 1) — "Nothing was measured: no config waslinted and no count was compared".
pnpm check:type-check-debt(exit 3) — "⛔ This is NOT a pass and NOT afinding: nothing was measured".
CI runs the whole farm on this PR with a full build, which is where these six get
their real verdict.
Deviations and notes for review
declared above and on the issue before the first edit, with the exemption's
four conditions spelled out. Trimmable in one slice if the reviewer prefers.
tree; it carries no changes of its own.
instruction, which specifies an exact
Co-Authored-Byline. That line names amodel, which the dispatch asked to avoid; the conflict is recorded here rather
than resolved silently.
🤖 Generated with Claude Code
https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Generated by Claude Code