Skip to content

Commit 42339e2

Browse files
os-warrenclaude
andauthored
feat(scripts): refuse a non-factory *.object.ts declaration by name (ruling item 2 + census) (#19720)
Part of #17418 — this is the **spec/scripts half** of the ruled change. It deliberately does not close the card; ruling item 1 (`packages/cli`) is a sibling PR by the `domain:cli` seat and is listed below with its real file surface. Clause-②: no Ruling: comment 5644350230 (director seat, decision batch #122 item 1, maintainer 「同意」 2026-09-12). ## The lane split, and what is NOT here | ruling item | this PR | |:--|:--| | 1. `TEMPLATES` in `packages/cli/src/commands/init.ts` emit the factory shape; `content/docs/deployment/cli.mdx:1323` describes it | **no** — `domain:cli` sibling; surface measured below | | 2. a `*.object.ts` not using the factory is refused **by name** — "use `ObjectSchema.create`" | **yes** | | 3. census in-repo literal-shaped object files and convert them | **yes** — census result: zero to convert | No file under `packages/cli` is touched. `content/docs/deployment/cli.mdx` is not touched. `packages/platform-objects/src/identity/sys-api-key.object.ts` (held by #19618) is not touched — the census did not name it, so no serialization was needed. ## Landing order — measured, not assumed The hazard to rule out: if the gate refuses the annotated-literal shape while `os init` still emits it, a freshly scaffolded project would be born refused. **It does not arise. Landing item 2 alone turns nothing red that only item 1 can fix.** Four readings, all on `origin/main` at `4fba5036f2`: 1. **The gate's population is a filename suffix, not a shape.** `walkObjectFiles` collects files whose name ends `.object.ts`, repo-wide minus `SKIP_DIRS`. Measured: **112 files, zero of them under `packages/cli`** (`git ls-files | grep '\.object\.ts$'` also returns 112, so nothing untracked is hiding either). 2. **`TEMPLATES` are string literals inside `packages/cli/src/commands/init.ts`** — a `.ts` file, not a `*.object.ts` file. The walk matches by filename, so the template strings are never read by this gate, in any shape. 3. **Every CLI scaffold test writes into `os.tmpdir()`**, never into the repo tree (`mkdtempSync(join(tmpdir(), …))` throughout `packages/cli/test/`). No test run can transiently materialise a literal-shaped `*.object.ts` inside the walk, and the tree carries no ignored one either. 4. **`os init` emits into a user's project**, which does not carry this repo's `scripts/`. So the seat's reading holds, and it is now a measurement rather than a reading. Items 2 and 3 land independently; item 1 follows on its own card. ## Item 2 — the rule, and the hole it actually closes The gate found object declarations by `CREATE_CALL` (`ObjectSchema.create(`) and nothing else. One backstop existed: a file yielding zero declarations **and** zero refusals is refused. That backstop is conditioned on `objects.length === 0`, so a file holding a factory declaration **and** a literal one read as complete, and the literal one was judged by nothing at all. Measured on a two-file control tree, with the pre-change gate: ``` === BASE GATE over the control tree === files walked : 2 objects PARSED : 1 -> ctrl_factory refusals : 1 packages/ctrl/src/objects/pure_literal.object.ts:1 no object declaration recognised in a `.object.ts` file. ``` `mixed.object.ts` produced nothing — and the literal declaration inside it (`ctrl_hidden`) keys a **UNIQUE index on an unbounded `text` column**, which is the exact defect this gate exists to catch. That is the "never silently unprotected again" the ruling names. The authoring-shape scan now runs **independently of** the factory parse, over every `*.object.ts`. Its refusal, verbatim from the real gate binary: ``` check:keyed-text-bounds: 2 object declaration(s) not written with `ObjectSchema.create` packages/platform-objects/src/__shape_demo__/mixed.object.ts:10 `ctrlHidden` is declared as a plain object literal — use `ObjectSchema.create({ ... })`. Recognised as a declaration by a `ServiceObject` type annotation, and a literal `name:` beside a `fields:`, the two keys the factory parser requires. `ObjectSchema.create` is the one authorised shape for a `.object.ts` declaration: it parses the declaration against ObjectSchema when the file is evaluated, so an error surfaces where it was written. A typed literal defers every check to a build the author may never run — and this gate cannot read it at all, so every keyed text column in it goes unjudged. The conversion is mechanical: wrap the literal in `ObjectSchema.create( ... )`. ⛔ Do not teach this scan the literal shape instead — the shape is refused, not unknown. ``` Four signals, published in the file header because a source scan sees only the spellings it knows: a `ServiceObject` annotation (any indentation), a `satisfies ServiceObject` (any indentation), the file's default export (top level), and a literal `name:` beside a `fields:` (top level only — held to column 0 so a helper literal built inside a function and handed to the factory is not accused). The zero-declarations backstop now steps aside when the shape scan already named the reason, so a literal-shaped file gets **one** finding prescribing the factory rather than a second one inviting the parser to be widened. ## Item 3 — the census: instrument, control, reach The card's "112 parsed, all factory" and the seat's "112 files on main" are **not the same fact**, and a literal-shaped file the gate cannot parse is invisible to exactly the instrument the card used. So the census was taken with a separate instrument. **Instrument** — over-inclusive and shape-agnostic: for every `*.object.ts` in the same walk, list every top-level binding of an object literal or of a call, plus every `export default`, then subtract the ones whose initializer is `ObjectSchema.create(`. Whatever is left is a candidate for hand triage. It does not depend on knowing the spelling `Data.ServiceObject`. **Result:** ``` *.object.ts files walked: 112 top-level bindings/defaults seen: 120 initialized by ObjectSchema.create(: 117 initialized by a bare OBJECT LITERAL: 0 <= CANDIDATES for item 3 initialized by something else: 3 CANDIDATES: none. ``` The 117 matches the gate's own parsed count exactly, from a different reader. The 3 non-literal bindings were printed rather than counted, and are plainly not declarations: a regex literal, a template string and `'sys_http_delivery' as const`. **Control (lit):** the same instrument over a two-file control tree returns 2 candidates — the pure literal file and the one hidden behind a factory declaration in a mixed file — so a zero from it is a reading, not an empty sweep. **Reach, stated:** the population is `**/*.object.ts`, which is the ruled population — the `object` row of `DEFAULT_METADATA_TYPE_REGISTRY` (`packages/spec/src/kernel/metadata-plugin.zod.ts:725`) declares `filePatterns: ['**/*.object.ts', '**/*.object.yml', '**/*.object.json']`. Blind to: declarations in files not carrying that suffix, the `.yml`/`.json` patterns (not TypeScript, the factory does not apply), and declarations assembled at runtime rather than written as a literal. Cross-checks run against the whole tree and reported separately: zero `export default` and zero `satisfies` occurrences in any of the 112 files outside comments and strings; zero indented object-literal bindings. **So: zero to convert — and that is a different answer from "the gate saw none".** ## The rule can fail — ablation The detector was neutered on the committed tree and the mixed control re-run. `scripts/ablation-replace.mjs` carried the mutation, so the anchor hit and the blob move are its own verdict rather than a remembered claim: ``` ablation-replace: anchor "literalShapeDeclarations(struct, masked).map" x1 (before) ablation-replace: ok mutation landed: anchor 1 -> 0, blob bce8e23 -> 71718fb1874b with the detector present: GATE EXIT=2 the gate NAMED ctrlHidden with the detector ablated: GATE EXIT=0 ctrlHidden UNNAMED, silently unprotected ablation-replace: ok restored: blob == HEAD (bce8e23) and `git diff HEAD` is empty ``` The ablation also found a defect in the first draft of this change: the ablated run still printed `Authoring shape: 0 literal-shaped declarations ... every declaration is ObjectSchema.create`, over a tree holding an unbounded keyed text column judged by nothing. That zero has no floor under it, so a dead detector printed the identical line. The pass line now reports what was **scanned** and names `--self-test` as the liveness proof (commit 2). A new `--self-test` battery, `the authoring shape: the factory is the one authorised declaration`, registers 15 cases and is pinned at 15 — its true registered count, measured at this head — and `SELF_TEST_BATTERY_FLOOR` moves 9 to 10. Pinning it below its count would have reproduced this PR's own defect class one level up, in the ratchet: at a pin of 12 any three cases could be deleted with the floor still green, including both MIXED cases, which are the only ones that exercise the hole the rule closes. Every other battery in the roster pins at exactly its registered count (3/15/8/11/10/5/7/4/4), measured in one pass by over-pinning each entry to a sentinel and reading the floor's own `registered N case(s)` line — a RUNTIME count, because one battery registers through a loop and a literal source count is not a general method. The regression case is `MIXED`: a whole-file fixture alone would pass identically with the detector deleted, because the whole-file case was already refused by the old backstop. ## Changeset — measured, and it is owed by the other half **This PR publishes nothing.** `scripts/check-keyed-text-bounds.mjs` sits inside no workspace package directory (checked against every tracked `*/package.json` directory), and npm packs relative to the package directory, so it cannot be packed. The repo-root manifest is `private: true`. No non-private package lists a `scripts` directory in `files[]`. Positive control: `packages/spec` is non-private and ships `["dist","json-schema","liveness","prompts","llms.txt","README.md","src/**/*.zod.ts","CHANGELOG.md","api-surface","spec-changes.json"]` — a real `files[]` exists and does not reach repo-root `scripts/`. The new symbol `literalShapeDeclarations` occurs in exactly one file, that one. `skip-changeset` was applied **by the seat**, with its own measurement recorded at comment 5775287713 (route 1: the one changed file is repo-root `scripts/`, inside no package directory; root manifest `private: true`; 77 manifests censused for `files[]` escape hatches, zero found, with a lit control). The stale `Check Changeset` red was re-run in that same act — ⛔ not a flake re-run: the gate's input changed after it ran. Worth the seat's attention: the ruling asks the changeset to state the one mechanical user rewrite (wrap the literal). The rewrite is something a **user** experiences, and the change a user experiences is item 1 — `os init` / `os g` emitting the factory shape from a published package. So the ruling's changeset obligation attaches to the half that publishes, i.e. the `domain:cli` sibling PR, not to this one. ## What item 1 actually requires — real files The seat files the `domain:cli` card from this list. Measured, not guessed: **Emitters (behaviour change):** - `packages/cli/src/commands/init.ts:649` and `:744` — the two `TEMPLATES` entries for `src/objects/__name___item.object.ts`, both emitting `const ${toCamelCase(namespace)}Item: Data.ServiceObject = {`. - `packages/cli/src/commands/generate.ts:99` — `os generate object` emits `const ${toCamelCase(name)}: Data.ServiceObject = {`. **The ruling's item 1 does not name this file, and it must.** Its own docblock declares the coupling: "#9666 took it once for the `os init` templates, and this emits the SAME value with the same explanation, so the two doors an author can arrive through agree. If that template's value ever moves, this one moves with it." **Pins that move with them:** - `packages/cli/test/generate-refuses-unparseable-name.test.ts:255` — `expect(scaffold).toContain('const orderLine: Data.ServiceObject = {')`. - `packages/cli/test/generate-emission-parses.test.ts:148` — `expect(scaffold.source).toContain('const foo.bar: Data.ServiceObject = {')`, plus its docblock at `:14`. - `packages/cli/test/scaffold-emission-typechecks.test.ts:26` — docblock states "The repair is `Data.ServiceObject`". - `packages/cli/test/init.test.ts:493` — reads the scaffolded `my_app_item.object.ts`; re-check its assertions against the new bytes. - `packages/cli/test/init-template-comments-self-contained.test.ts` — the templates carry a long authored OWD comment block that must survive the rewrite. **Docblock only, no behaviour change:** - `packages/cli/src/utils/emitted-source-parses.ts:14` — the utility itself is shape-agnostic (it asks TypeScript's own parser whether the emitted bytes parse); only its worked example names the literal shape. **Coupled, probably no edit:** `scripts/sync-scaffold-emission-policy.mjs` keeps `create-objectstack`'s bundled template's `pnpm`/`typescript` ranges in lockstep with `packages/cli/src/commands/init.ts` (`POLICY_SOURCE`). It syncs version ranges, not declaration shape — but the sibling should re-run it, because `create-objectstack`'s bundled `note.object.ts` is already the factory shape and the two scaffolders would finally agree. **Docs:** `content/docs/deployment/cli.mdx:1323`. ## Acceptance notes Two further emitters of the outlawed shape exist **outside `packages/cli`**, which the ruling names nowhere and which the lane split therefore routes to neither seat. Both are reported rather than changed: neither is a `*.object.ts` file, so item 3's population does not include them, and both sit in published packages, so converting either would change a published payload and re-open the `Clause-②: no` reading this PR carries. - `packages/services/service-datasource/src/external-datasource-service.ts:931` emits `const ${definition.name}: ServiceObject = {` as the object draft that `os datasource introspect --out objects/x.object.ts` writes into a user's project (ADR-0015). A third scaffolder door, server-side. Pinned at `packages/services/service-datasource/src/__tests__/external-object-draft-os-build.test.ts:138`. - `packages/metadata/src/serializers/typescript-serializer.ts:23` emits `export const metadata: ServiceObject = ${jsonStr};` for the `typescript` metadata format. Noted, not filed: `provenanceLine`'s record still reads `-1/-1/-5/-2/-4` against `MEASURED.ref` `fa5d137ab0`, which is information and not a verdict per that file's own header — no action, and no PR or person is due to touch it. Carrier: none. ## Verification - `node scripts/check-keyed-text-bounds.mjs` :: exit 0 — counts unchanged from base, `112/117/250/592/147`, identical before and after. ⚠️ This bare invocation is **NOT** a liveness reading: with the detector ablated it still exits 0 and still prints its pass line. What commit 2 closed is the LIE in that line — it no longer claims every declaration is the factory, only what was scanned — and the CI false green, because `--self-test` and the gate are wired as one pair (`package.json:183`, `.github/workflows/lint.yml:731-732`, inside the required `Lint & Repo Gates` job, all three verified at this head). The paired self-test is the liveness proof; the bare run is not. - `node scripts/check-keyed-text-bounds.mjs --self-test` :: exit 0 — 0 failures, all 15 cases in the battery listed by name in the log. - All 31 gate families derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` were run at `beef275c08` and again in full at `4d034cd2f9`, `--ran` reconciling both as **31 derived, 31 run, 0 NOT-MEASURED, 0 UNRUN**, every row carrying its exit code, all 0. ⚠️ Those two are readings at EARLIER heads. The round that produced `7adf1e0a6c` reports re-running all 31 there with the same reconciliation, and reports that its first sweep read **5 at exit 3 — PREREQUISITE NOT MET, a missing `node_modules` in a fresh worktree — which were NOT MEASURED and never green until `pnpm install` and a re-run**. That last reading is the delivering round's, ⛔ not re-derived by the seat; CI at this head is the seat's own reading, below. - Lint, as a declared narrowing rather than a repo-wide scan: `eslint --no-inline-config --format json scripts/check-keyed-text-bounds.mjs` :: exit 0, **1 file** linted (count read from the JSON output), 0 errors, 0 warnings. The narrowing excludes nothing: this repo runs one `eslint.config.mjs`, which per its own comment at line 327 "never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file", measured there with a positive control — so a one-file diff cannot move the verdict on any file it does not touch. The repo-wide `pnpm lint` was NOT MEASURED in that round — `scripts/pm/os-verify-lock.sh` returned `queue-timeout (exit 99)` after 9m00s, never acquiring, holder pid 19251 — neither green nor red. ⭐ That NOT MEASURED is now superseded by the delivering round at `7adf1e0a6c`, which reports `eslint . --no-inline-config --format json` :: exit 0 over **7,005 files, 0 errors, 0 warnings**. ⚠️ Attributed to that round; ⛔ not re-derived by the seat. - **CI at the current head `7adf1e0a6c`, read by the SEAT by job conclusions, latest run per check NAME — ⛔ never an aggregate roll-up: 31 names, 23 `success`, 8 `skipped`, 0 `failure`, 0 `in_progress`.** All five required checks `success`: `Lint & Repo Gates`, `TypeScript Type Check`, `Test Core`, `Dogfood Regression Gate`, `Governed Surface Queue Guard`. The 8 skips are the paths filter on a diff that touches no package. - The `insideFunction` case now pins the guard it is named for. Ablating the column-0 guard (`m[1].length === 0, false` → `true, false`): at `4d034cd2f9` the self-test was exit 0 / PASS / 0 failures; at `7adf1e0a6c` it is exit 1 / FAIL / 1 failure, the failing case being exactly that one. Both legs restored, proven by an empty `git status --porcelain` and an empty `git diff HEAD` rather than by a step's exit code. No label, assignee, ready-flip, auto-merge or enqueue was performed, per the dispatch. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7e0bfce commit 42339e2

1 file changed

Lines changed: 323 additions & 6 deletions

File tree

0 commit comments

Comments
 (0)