Skip to content

Commit 53573b0

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-12016-cli-command-id-coupling
2 parents 59066bd + 20b0fdb commit 53573b0

119 files changed

Lines changed: 2454 additions & 311 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): the generated docs root sidebar lists categories from the same declared page list `meta.json` and the category index already agree on (#11482)
6+
7+
`build-docs.ts` writes the docs tree from what should be one answer to "which
8+
categories/pages exist", but the ROOT `content/docs/references/meta.json` (§3
9+
— the sidebar's top-level category list) was still answering it a third way:
10+
11+
- a category's own `meta.json` (§2) is built from the pages the run
12+
**emitted**;
13+
- that category's `index.mdx` card grid (§2.5) reads the SAME declared list
14+
(#11260) — no longer a second, independently-derived enumeration;
15+
- the root `meta.json` (§3), until now, filtered on `categoryZodFiles` — the
16+
`.zod.ts` files found **on disk** — a third, independent enumeration.
17+
18+
A category whose published pages all come from plain `.ts` files rather than
19+
`.zod.ts` ones (the `misc` catch-all class `security/misc` proves is real) has
20+
zero `.zod.ts` files while still publishing a page, a `meta.json` and an
21+
`index.mdx`. The old filter would drop such a category from the sidebar even
22+
though it is fully generated and routed everywhere else — a folder complete on
23+
disk and unreachable from the nav.
24+
25+
**No category is in that state today** — all 14 have at least one `.zod.ts`
26+
file — so this was a latent defect with no live instance, and the regenerated
27+
root `meta.json` is byte-identical. The filter now reads `categoryMetaPages`,
28+
the same map §2.5 already reads, so all three files answer from one list
29+
instead of three that happen to agree today. The rule moved into
30+
`scripts/lib/root-meta.ts` (`rootCategoryDirs`), pinned directly with the
31+
all-`misc`-category shape that has no instance in the repo — the same move
32+
#11260 made for the category card grid, for the same reason: the defect's
33+
output is an ABSENT sidebar entry, which `check:docs` cannot see any more than
34+
it could see an absent card, and the edge that has no live instance cannot be
35+
pinned from emitted output at all.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
liveness gate: a citation must name the property it is evidence for
6+
7+
Two checks already bounded a `live` entry's citation, and both bounded it from the
8+
outside — the cited file must exist (#5623), and a cited line must be inside it
9+
(#11210). Between them sat a gap neither could see: a consumer that moves *within*
10+
the file it is cited to, or a citation written with no line at all, leaves the file
11+
present and every named line in range. The pointer is wrong and the gate is green.
12+
13+
Measured over the whole ledger before anything was switched on: 403 (entry, cited
14+
local file) pairs, **11** where the cited file never mentions the property's own key,
15+
and **7 of those 11 were real rot** — repaired here:
16+
17+
- `permission.objects.allowExport``annotateEffectiveApiOperations` moved to
18+
`current-user-endpoints.ts`; the same repos-internal movement that had already
19+
rotted `permission.systemPermissions` and `permission.tabPermissions`.
20+
- `object.tenancy.organizationField` — the resolver was promoted into
21+
`@objectstack/metadata-core`; the cited `audit-writers.ts` says so itself, in the
22+
re-export comment left behind.
23+
- `action.target` / `action.requiredPermissions` — the actions domain was extracted
24+
out of `http-dispatcher.ts`, which retains 0 occurrences of either key.
25+
- `action.bodyShape` / `action.bodyExtra` — client-dispatched keys whose only
26+
consumer has always been the renderer; the in-repo citation could not have been
27+
right at any point. Now attributed to `objectui` with the commit pinned.
28+
- `field.requiredWhen` — cited its *sibling* `record-validator.ts`, which enforces the
29+
static `required` contract; the CEL predicate is evaluated one file over in
30+
`rule-validator.ts`. Both files exist, so nothing could see it.
31+
32+
The remaining 4 are the `camelCase``snake_case` convention this platform mandates
33+
(Prime Directive #3): a property persisted as a column is read as `body_html`,
34+
`managed_by`, never as the authoring key. Three are handled **structurally** — the
35+
matcher folds the key across the naming convention rather than exempting them — and
36+
the match is word-bounded so a prefix cannot satisfy the key (`required` is not
37+
`requiredWhen`, which is precisely how that rot stayed hidden). The one residual is a
38+
compound *child*-key remap (`fromOverride.address``from_address`) that no fold of
39+
the parent key reaches, and it is a single explicit row in the shrink-only
40+
`scripts/liveness/key-mention.baseline.json`, which fails in **both** directions: a
41+
row whose pair later anchors must be deleted.
42+
43+
So the check ships red-capable at zero unexplained hits, which is the whole reason the
44+
census came first — `evidence.mts`'s header records what the alternative costs, when
45+
48 of 227 entries were flagged, every one was a false positive, and the single genuine
46+
rot inside that list sat unread.
47+
48+
The check asks `evidence` only, never `producer`: a producer cites *who supplies a
49+
second input* (#4837), which is by definition a call site and need not name the key at
50+
all.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
The #11566 `maxLength` narrowing (shipped in 17.x: `z.number().int().min(1)`, refused outside `BOUNDED_STRING_FIELD_TYPES`) is now registered in the ADR-0087 migration ledger (#11950) — the enforcement PR deliberately deferred the entry because the registry file was serialized behind an in-flight change. Following the #8321 `scale`/`precision` template, the major-18 semantic entry carries both halves: the mechanical one (delete the key where it was misplaced — inert by construction outside the write-time validator's bounded-string branch) and the judgment one (a malformed value on a bounded-string type WAS consumed by the validator's raw comparison — `maxLength: 0` accepted only empty strings, a negative value refused every write — so only the author knows the bound they meant; the entry tells them to re-declare it). `objectstack migrate meta`, `spec-changes.json` and the upgrade guide surface the entry at the major boundary; no accept/reject behaviour changes in this release.
6+
7+
<!-- adr-0087: registered field-max-length-malformed-or-misplaced-refused -->
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`FieldSchema.minLength` tightens on both axes (#11949, maintainer ruling 2026-08-25) — `maxLength`'s twin defect pair (#11566), closed with the same template. Shape: the key is now `z.number().int().min(1)`, so `minLength: 0`, negative and non-integer declarations are refused at parse. The lower bound is 1 by ruling: "no minimum" is expressed by omitting the key, not by declaring a vacuous truth — `minLength: 0` can never fail, and a permanently-true declaration is exactly the noise an AI metadata author mass-produces, so it is refused loudly at authoring time. Applicability: the key sat on the base schema and was authorable on every field type; it is now refused on any type that does not store a bounded string, and accepted on exactly the `BOUNDED_STRING_FIELD_TYPES` set — `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, `signature`, `qrcode` (twelve members since #11875) — the same set `maxLength` converged on.
6+
7+
What newly gets rejected: `minLength: 0` / negative / non-integer on any type, and `minLength` with any value on every non-bounded-string type (`boolean`, `number`, `date`, `select`, `lookup`, `autonumber`, `formula`, `json`, `secret`, …). Both rejections are prescriptive — the message names the legal shape, the legal type set, and the fix. The two authoring forms converge on the same set (`field.form.ts` previously showed the key for three types; `object.form.ts` for nine). Already-legal declarations (a positive-integer `minLength` on a bounded-string type) round-trip byte-identically, and absence stays absence — no default materializes.
8+
9+
<!-- adr-0087: registered field-min-length-malformed-or-misplaced-refused -->
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): declare `editMode?: 'modal' | 'page'` on the object document (#11408)
6+
7+
Accept-set **widening** — no existing document changes meaning and nothing is
8+
removed. Maintainer ruling 2026-08-24 (declare, the #10144 declare-or-rule-out
9+
family): objectui's shipped runtime reads `objectDef.editMode` (record-edit
10+
routing: modal form vs a dedicated `/record/:id/edit` route) and its CHANGELOG
11+
announces the key to authors, while the spec's strict parse rejected it with
12+
`unrecognized_keys` — so an author following objectui's documentation was
13+
refused by every spec-validating path and the key only worked through data
14+
sources that skip validation.
15+
16+
The object document now declares it beside the other display hints
17+
(`nameField`, `highlightFields`, `stageField`): an optional cross-renderer
18+
edit-interaction intent — `'modal'` opens the edit form as a dialog over the
19+
current view, `'page'` navigates to a dedicated full-page edit route, absent
20+
lets the renderer pick its own default (objectui defaults to modal). Values
21+
outside the enum are rejected as a located value error at `editMode`.
22+
23+
Consumer-side follow-up (not in this change): objectui retires its
24+
`ObjectSchemaClientExtensions.editMode` client-extension member and lets the
25+
spec derivation carry the key — its pinned rejection tests flip by design.
26+
That retirement is **release-gated** on the `@objectstack/spec` release
27+
containing this change (per the recorded ruling), not merely on this merge.

.github/workflows/ci.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,13 @@ jobs:
556556
echo "No packages on this shard — nothing to test."
557557
exit 0
558558
fi
559+
# Bound vitest's INNER worker pool (#11958). A no-op on a runner with
560+
# <= 5 cores (it only ever LOWERS vitest's own `cores - 1` default);
561+
# it exists so a larger runner cannot multiply turbo's outer
562+
# --concurrency by a host-sized inner pool. Empty on failure, which is
563+
# vitest's own "use the default" signal. Needs turbo.json's
564+
# globalPassThroughEnv entry or turbo strips it — see the script header.
565+
export VITEST_MAX_WORKERS="$(node scripts/vitest-worker-cap.mjs)"
559566
FILTERS=$(sed 's/^/--filter=/' "$RUNNER_TEMP/shard-packages.txt" | tr '\n' ' ')
560567
mkdir -p "$RUNNER_TEMP/stall-reports"
561568
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/test-core.log" --stall-minutes 10 \
@@ -1129,6 +1136,13 @@ jobs:
11291136
env:
11301137
NODE_OPTIONS: --report-on-signal --report-signal=SIGUSR2 --report-directory=${{ runner.temp }}/stall-reports
11311138
run: |
1139+
# Bound vitest's INNER worker pool (#11958). A no-op on a runner with
1140+
# <= 5 cores (it only ever LOWERS vitest's own `cores - 1` default);
1141+
# it exists so a larger runner cannot multiply turbo's outer
1142+
# --concurrency by a host-sized inner pool. Empty on failure, which is
1143+
# vitest's own "use the default" signal. Needs turbo.json's
1144+
# globalPassThroughEnv entry or turbo strips it — see the script header.
1145+
export VITEST_MAX_WORKERS="$(node scripts/vitest-worker-cap.mjs)"
11321146
mkdir -p "$RUNNER_TEMP/stall-reports"
11331147
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/dogfood.log" --stall-minutes 10 \
11341148
--report-dir "$RUNNER_TEMP/stall-reports" -- \

.github/workflows/docs-drift-check.yml

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,47 @@ jobs:
259259
if (crossCutting.length) limits.push(`**${crossCutting.length}** cross-cutting symbol(s) contributed no route anchor: \`${crossCutting.join('`, `')}\``);
260260
if (overbroad.length) limits.push(`**${overbroad.length}** anchor(s) matched too much of the corpus to be a work list: \`${overbroad.join('`, `')}\``);
261261
if (weak.length) limits.push(`**${weak.length}** name(s) were too generic to anchor anything (single lowercase words)`);
262-
if (bridge && bridge.measured && bridge.unreachable > 0) limits.push(`the SDK route bridge reached **${bridge.reachable}** of **${bridge.clientRows}** client-bound route-ledger rows — the other **${bridge.unreachable}** have no registrar \`path:\` tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: \`node scripts/docs-audit/affected-docs.mjs --bridge-coverage\``);
262+
// ── WHY THOSE ROWS ARE UNREACHABLE, NOT JUST HOW MANY (#11867) ────────
263+
//
264+
// The line above used to end at the count, and a count is one population.
265+
// The census says there are three, and the difference is the whole reason
266+
// the split exists: the auth ledger's `56 of 56` and the rest ledger's
267+
// `46 of 87` print identically and are NOT the same finding — the first has
268+
// no in-repo registration site at all, so the discovery widening the second
269+
// one wants moves it by zero rows. That conflation already aimed one card
270+
// (#11178) at widening a recognizer that was never the constraint, and this
271+
// comment is the surface where a human meets the number.
272+
//
273+
// ⛔ SAME NAMES, ONE DERIVATION — the rule stated three lines up for the
274+
// `anchorless` pair, and it binds here for the same reason. `bridge.causes`
275+
// is a PARTITION of `bridge.unreachable`, computed ONCE inside
276+
// `bridgeCoverageFrom` and published whole; the parts are READ off the same
277+
// object as the total and never recomputed here. Re-deriving them from
278+
// `bridge.ledgers` would be a second derivation that agrees today, drifts
279+
// silently tomorrow, and renders a breakdown that sums to something the
280+
// headline beside it denies.
281+
//
282+
// And on numbers that do NOT partition that total, the split is WITHHELD and
283+
// the run says the census is broken, rather than printing three figures
284+
// beside a fourth they contradict. That state cannot arise from
285+
// `bridgeCoverageFrom` — `affected-docs.mjs --self-test` pins the partition
286+
// — which is what makes it a verdict here and not a fallback.
287+
//
288+
// `measured: false` keeps its honest arm: a run that supplied no ceiling
289+
// reports that WHY was not measured. It says so in words rather than
290+
// rendering three nulls or, worse, three zeroes — "nobody looked" is not
291+
// "none found", the same distinction `computedOn.dirty`'s null arm draws.
292+
const causes = (bridge && bridge.causes) || null;
293+
let bridgeCauses = '';
294+
if (causes && causes.measured === true) {
295+
const parts = causes.remediable + causes.structural + causes.undecided;
296+
bridgeCauses = parts === bridge.unreachable
297+
? ` Of those **${bridge.unreachable}**: **${causes.remediable}** are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); **${causes.structural}** are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; **${causes.undecided}** are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here).`
298+
: ` ⛔ Its cause census is BROKEN — **${causes.remediable}** + **${causes.structural}** + **${causes.undecided}** is not the **${bridge.unreachable}** it claims to break down, so the split is withheld.`;
299+
} else if (causes && causes.measured === false) {
300+
bridgeCauses = ` ⚠️ Cause NOT measured on this run: ${causes.reason}. No cause may be read into the count above — "nobody looked" is not "none found".`;
301+
}
302+
if (bridge && bridge.measured && bridge.unreachable > 0) limits.push(`the SDK route bridge reached **${bridge.reachable}** of **${bridge.clientRows}** client-bound route-ledger rows — the other **${bridge.unreachable}** have no registrar \`path:\` tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run.${bridgeCauses} The rows themselves: \`node scripts/docs-audit/affected-docs.mjs --bridge-coverage\``);
263303
// ── THE RULE-CARRYING PAGE AN EMITTER DIFF CANNOT REACH (#11434) ──────
264304
//
265305
// Every line above is a REPORT about this run: a count this run produced, a

.github/workflows/rerun-safety-nightly.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ jobs:
8989
# frozen while healthy. The guard refuses a turbo run without it.
9090
- name: Test suite — pass 1
9191
run: |
92+
# Bound vitest's INNER worker pool (#11958). A no-op on a runner with
93+
# <= 5 cores (it only ever LOWERS vitest's own `cores - 1` default);
94+
# it exists so a larger runner cannot multiply turbo's outer
95+
# --concurrency by a host-sized inner pool. Empty on failure, which is
96+
# vitest's own "use the default" signal. Needs turbo.json's
97+
# globalPassThroughEnv entry or turbo strips it — see the script header.
98+
export VITEST_MAX_WORKERS="$(node scripts/vitest-worker-cap.mjs)"
9299
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/rerun-pass1.log" --stall-minutes 15 -- \
93100
pnpm turbo run test --concurrency=4 --force --log-order=stream
94101
@@ -112,6 +119,13 @@ jobs:
112119
# there. Keep the two verdicts apart.
113120
- name: Test suite — pass 2 (same working tree)
114121
run: |
122+
# Bound vitest's INNER worker pool (#11958). A no-op on a runner with
123+
# <= 5 cores (it only ever LOWERS vitest's own `cores - 1` default);
124+
# it exists so a larger runner cannot multiply turbo's outer
125+
# --concurrency by a host-sized inner pool. Empty on failure, which is
126+
# vitest's own "use the default" signal. Needs turbo.json's
127+
# globalPassThroughEnv entry or turbo strips it — see the script header.
128+
export VITEST_MAX_WORKERS="$(node scripts/vitest-worker-cap.mjs)"
115129
status=0
116130
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/rerun-pass2.log" --stall-minutes 15 -- \
117131
pnpm turbo run test --concurrency=4 --force --log-order=stream || status=$?

0 commit comments

Comments
 (0)