Skip to content

Commit c0d87c0

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16608-insert-check-post-image
2 parents db0edee + d958b34 commit c0d87c0

18 files changed

Lines changed: 1439 additions & 10 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
`os lint --eval --generator ""` no longer prints a double space in its refusal.
6+
7+
`bundle-require` composes its own refusal as `<filepath> is not a valid JS file`, so an
8+
empty filepath contributes no characters and that fragment arrives with a leading space —
9+
which landed against the space in our own `": "` separator:
10+
11+
```
12+
Failed to load generator "": is not a valid JS file # before, both faces
13+
Failed to load generator "": is not a valid JS file # after
14+
```
15+
16+
The composed message now drops leading spaces from the detail, so the separator carries
17+
exactly one. The empty string still answers through the same door an unresolvable path
18+
answers through — same `catch`, same exit code 1, same one-key `{error}` document on the
19+
`--json` face — and every refusal whose detail does not open with a space is byte-identical,
20+
the unresolvable-path case included.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os i18n extract --source-hashes` no longer writes a provenance companion with no bundle module beside it, and names the sections it commits from the payloads those modules hold instead of from two literals.
6+
7+
The command narrows the provenance table to "the sections this run commits" before writing `<locale>.source-hashes.generated.ts`. The half that decided WHICH modules were emitted already read the emitted set; the half that named them pushed the string `'objects'` or `'metadataForms'`.
8+
9+
- **A zero-record orphan is no longer written.** With no module emitted for a locale — a stack whose only surface is apps, under the default `--objects-only` with `--no-metadata-forms` — the committed-section list is empty, `narrowToCommittedSections` returns `{}`, and `{}` is truthy at the emit gate. The run therefore wrote one file holding an empty table, describing nothing, with no bundle module beside it for it to be about. Because `--check` compares the companion by bytes like any other emitted file, that orphan once committed is a file the gate demands forever: deleting it made `--check` report `missing` and exit 1. Such a run now writes nothing, and reports `Generated 0 file(s)`.
10+
- **The section list is derived.** `translationModuleSections(bundle, kind)` sits beside `translationModulePayload` and is switched on the same `kind`, so what a module holds and which sections it commits are one decision rather than two. Under `kind: 'stack'` the module holds every group the stack authors and the caller now names all of them; a group added later needs no edit, and a further aggregate kind fails to compile at that one site rather than silently committing its own name as a section.
11+
12+
**No provenance record changes in this repository, and none is restored.** The generated tables only ever carry the two sections `collectFilledFromHashes` walks (`objects` and `metadataForms`), so `'objects'` was the right name for both stack sub-tree modes — the old list was correct by coincidence, not by construction. In particular an `apps.*` record is not restored by this change: no such record is built, so none was being filtered out.
13+
14+
**Already committed an empty companion?** Nothing needs doing and nothing is deleted. `--check` compares only the files a run writes and reports `missing` / `stale` over that set, so a leftover empty companion is in neither category — it is tolerated where it sits, and is inert to the next extract, which reads it back as an empty record set exactly as it would read its absence. Delete it at your convenience.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/core": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
The `Server is ready` line now reports the degraded boot it is standing on, instead of printing a green `` over it.
7+
8+
`✓ Server is ready` and the kernel's `System started with degraded capabilities. Missing core services: …` were two statements about one boot, produced by two packages — the banner in `@objectstack/cli`, the conclusion in `@objectstack/core` — with **no data path between them**. So the ready signal did not depend on the thing that broke, and therefore could not report it. Measured twice within a day, from unrelated causes: an objectui CI boot where the auth plugin failed and not one `sys_*` table existed, and this repo's own weekly registry canary on the published `npx create-objectstack@latest` on-ramp, where the tick printed directly **above** four boot warnings. In the second case the ready line carried no weight in the job's verdict at all — it was present, green, wrong, and believed by nobody.
9+
10+
- **The data path.** `ObjectKernel.validateSystemRequirements()` now publishes the list it had already computed — the same array behind its own warning — on the kernel's service registry, which is the seam boot facts already cross to reach the banner (`serve` reads `auth` and `seed-summary` off it the same way). No member and no type is added to `@objectstack/core`'s public surface, and nothing re-derives which services count as `core`: that judgement stays in `ServiceRequirementDef` alone.
11+
- **The line.** On a degraded boot the banner prints `⚠ Server is ready — DEGRADED: missing core services: <names>`, naming exactly what the kernel found missing. On a healthy boot the ready block is byte-for-byte unchanged, so an ordinary boot's output does not move.
12+
- **Readiness is NOT made strict.** Nothing about what boots, binds, or exits changes. A machine deliberately running without auth still starts, still prints ready, and still exits 0 — the line just says what state it is ready in.

.changeset/stack-refusal-envelopes.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,4 @@ Not narrowed: `defineStack` accepts and refuses exactly the inputs it did before
2525

2626
**Why `minor`, not the `patch` the #14552 precedent took.** Six new `STACK_*` spellings ship in `packages/spec/dist/index.js` and `dist/index.mjs`; nothing reaches `.d.ts`, but once shipped a consumer's `catch (e) { switch (e.code) … }` depends on them and they cannot be renamed without breaking it. That is a purely additive widening of a published package's public surface, and the maintainer ruling of 2026-09-04 (decision batch #35, on #15294) requires at least `minor` for it: the commit type may raise a bump but never lower it below what the act requires, so a `fix(` that widens the surface is `minor`. The `patch` precedent is pre-rule; this level is required by that ruling, not chosen by taste.
2727

28-
`@objectstack/runtime` carries one classification row per new code in the dispatcher error-code vocabulary (`door: 'none'`, `verdict: 'boot-refusal'` — the measured verdict), which `pnpm check:dispatcher-error-vocabulary` enforces in both directions.
28+
`@objectstack/runtime` carried one classification row per new code in the dispatcher error-code vocabulary (`door: 'none'`, `verdict: 'boot-refusal'` — the measured verdict) when this changeset was written, and carries none now: the same #16449 (PR #16652) landing that registered the seven codes ratcheted all seven rows back out of `packages/runtime/src/dispatcher-error-vocabulary.ts`, because a registered code drops out of that gate's scan and the reachability each row recorded now lives on its ledger row. `pnpm check:dispatcher-error-vocabulary` still reconciles sites and rows in both directions, and under the same ruling it now refuses any verdict but `foreign-vocabulary` / `runtime-pinned` for a stamp site under `packages/spec/src/**` — so these seven cannot take a `boot-refusal` row again.

.github/workflows/ci.yml

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,70 @@ jobs:
464464
echo 'Items on this shard (a package name, or a package plus a k/n file-level slice):'
465465
cat "$RUNNER_TEMP/shard-packages.txt"
466466
467+
# ⛔ A FILE-LEVEL SLICE BUILDS ITS DEPENDENCY CLOSURE HERE, IN A RUN THAT
468+
# CARRIES NO PASSTHROUGH, so that the sharded run in the next step can be
469+
# `--only` (#16395).
470+
#
471+
# Turbo folds a run-level passthrough into the hash of EVERY task in the
472+
# run, not only the task that receives it -- and `-- "--shard=k/n"` is the
473+
# whole reason a slice gets its own invocation at all (the next step's
474+
# comment says why it cannot ride the shared run). Measured on turbo
475+
# 2.10.10, `--filter=@objectstack/cli`, `turbo run test ... --dry=json`
476+
# (60 tasks: 59 `build` + 1 `test`):
477+
#
478+
# plain vs plain 60 identical, 0 changed <- control, fires
479+
# plain vs -- --shard=1/2 0 identical, 60 changed
480+
# --shard=1/2 vs 2/2 0 identical, 60 changed
481+
#
482+
# So the sliced leg could hit NEITHER the main-seeded Turbo cache restored
483+
# above NOR the builds the shared leg ran seconds earlier in the SAME job:
484+
# it re-executed the closure every run, and shards 5/6 and 6/6 -- the two
485+
# that carry a slice -- paid that closure twice per job. Live reading,
486+
# `Test Core (5/6)` of run 34193080219 (a `packages/spec` PR, so the
487+
# affected set reaches cli): the slice leg reported
488+
# `Cached: 2 cached, 58 total` / `Time: 9m18.941s` while
489+
# `@objectstack/cli:test` itself measured `Duration 187.49s` -- six of those
490+
# nine minutes were the duplicate rebuild, on a job that was then standing
491+
# against a 30-minute wall (#16395's measurement).
492+
#
493+
# `turbo run build --filter=$PKG` is that closure and nothing more:
494+
# measured 59 build tasks, all 59 hash-IDENTICAL to the ones in the
495+
# passthrough-free test plan (0 differing, 0 extra, and 0 missing against
496+
# the test's own `^build` closure), so they REPLAY rather than re-execute.
497+
# `--filter=...^$PKG` was measured too and schedules 12 packages this
498+
# closure does not need. Locally, back-to-back invocations of this exact
499+
# command measured `57 cached, 57 total` / `Time: 153ms >>> FULL TURBO` on
500+
# the second, against `5 cached, 57 total` / `3m45.918s` on the first.
501+
#
502+
# ⚠ THIS IS ITS OWN STEP, not a second guarded run inside the step below,
503+
# because a guarded SITE is the triple (file, job, step) --
504+
# `measure-stall-guard-headroom` REFUSES to report a verdict when two
505+
# guarded runs share one, and refusing is right: the two would be judged
506+
# against the worst reading of their union. `pnpm check:stall-guard-budget`
507+
# and `pnpm check:stall-guard-headroom` both read this step, so it keeps
508+
# its own `--stall-minutes` and its own headroom row.
509+
#
510+
# A shard with no slice runs zero iterations here; every shard still
511+
# reaches the step, so its name is a stable site for those two gates.
512+
- name: Build the sliced package's dependency closure
513+
env:
514+
NODE_OPTIONS: --report-on-signal --report-signal=SIGUSR2 --report-directory=${{ runner.temp }}/stall-reports
515+
run: |
516+
if [ ! -s "$RUNNER_TEMP/shard-packages.txt" ]; then
517+
echo "No packages on this shard — nothing to build."
518+
exit 0
519+
fi
520+
mkdir -p "$RUNNER_TEMP/stall-reports"
521+
while read -r PKG SLICE; do
522+
[ -n "$PKG" ] || continue
523+
[ -n "$SLICE" ] || continue
524+
echo "Slice $PKG=$SLICE — building its dependency closure with no passthrough."
525+
LOG="$RUNNER_TEMP/test-core-slice-build-$(printf '%s' "$PKG" | tr -c 'A-Za-z0-9' '-').log"
526+
node scripts/run-with-stall-guard.mjs --log "$LOG" --stall-minutes 10 \
527+
--report-dir "$RUNNER_TEMP/stall-reports" -- \
528+
pnpm turbo run build "--filter=$PKG" --concurrency=4 --log-order=stream
529+
done < "$RUNNER_TEMP/shard-packages.txt"
530+
467531
# --concurrency=4: turbo's default (10) oversubscribes the 4-vCPU
468532
# hosted runner; matching the core count bounds peak memory and the
469533
# job is CPU-bound anyway.
@@ -571,7 +635,15 @@ jobs:
571635
PKG="${LEG%%=*}"
572636
SLICE="${LEG#*=}"
573637
LOG="$RUNNER_TEMP/test-core-slice-$(printf '%s' "$PKG" | tr -c 'A-Za-z0-9' '-').log"
574-
set -- pnpm turbo run test "--filter=$PKG" --concurrency=4 --summarize --log-order=stream -- "--shard=$SLICE"
638+
# `--only` (#16395): the step above already built this slice's
639+
# dependency closure in a passthrough-free run, so this run must
640+
# schedule the ONE task the passthrough is for. Without it turbo
641+
# re-hashes the whole `^build` closure under `--shard=k/n` and
642+
# rebuilds it -- that comment carries the measurement. ⚠ The build
643+
# step is load-bearing for this flag: a sliced package whose build
644+
# never ran fails LOUDLY here (its imports resolve to a missing
645+
# dist), never as a silent green.
646+
set -- pnpm turbo run test "--filter=$PKG" --only --concurrency=4 --summarize --log-order=stream -- "--shard=$SLICE"
575647
fi
576648
LOGS="$LOGS $LOG"
577649
node scripts/run-with-stall-guard.mjs --log "$LOG" --stall-minutes 10 \

packages/cli/src/commands/i18n/extract.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
parseSourceHashModule,
2525
narrowToCommittedSections,
2626
translationModulePayload,
27+
translationModuleSections,
2728
countTranslationLeaves,
2829
type FillStrategy,
2930
type TranslationModuleKind,
@@ -448,15 +449,44 @@ export default class I18nExtract extends Command {
448449
* bundle files — {@link emittedModules} — never by a second rule. A set that commits both —
449450
* `platform-objects` is the one today — keeps every record it had. The
450451
* narrowing itself is `narrowToCommittedSections`, a pure function in the
451-
* extractor's utils so it can be pinned without driving oclif; this layer
452-
* contributes only the two booleans it alone knows.
452+
* extractor's utils so it can be pinned without driving oclif.
453+
*
454+
* ⭐ And the sections are read off the PAYLOADS those modules hold
455+
* (`translationModuleSections`), not written here as literals. This layer
456+
* used to push `'objects'` and `'metadataForms'` — the emitted-module half
457+
* already read `emittedModules`, but what it pushed was a hand-copied
458+
* name, so under `kind: 'stack'` it named one of the several groups the
459+
* module actually commits. Nothing in this repository's provenance tables
460+
* is filtered by that mismatch today, because the tables only ever carry
461+
* the two GENERATED sections (`GENERATED_SECTIONS` in
462+
* `@objectstack/platform-objects`), and `'objects'` is the right name for
463+
* both stack kinds — the list was correct by COINCIDENCE, not by
464+
* construction, and a third generated section would have broken it
465+
* silently. It is now derived.
466+
*
467+
* ⭐ Returning `undefined` when nothing is committed is the second half,
468+
* and it is a file-set decision rather than a narrowing one:
469+
* `narrowToCommittedSections` returns `{}` for an empty section set, `{}`
470+
* is truthy at the emit site, and the run therefore wrote a zero-record
471+
* companion with NO bundle module beside it for it to be about. `--check`
472+
* compares the companion by bytes like any other emitted file, so that
473+
* orphan, once committed, is a file the gate demands forever.
453474
*/
454475
const committedSourceHashes = (locale: string): Record<string, string> | undefined => {
455476
const table = result.sourceHashes[locale];
456477
if (!table) return undefined;
457-
const committed: string[] = [];
458-
if (emittedModules(locale).some((m) => m.kind !== 'metadataForms')) committed.push('objects');
459-
if (emittedModules(locale).some((m) => m.kind === 'metadataForms')) committed.push('metadataForms');
478+
const committed = new Set<string>();
479+
for (const mod of emittedModules(locale)) {
480+
for (const section of translationModuleSections(result.bundles[locale], mod.kind)) {
481+
committed.add(section);
482+
}
483+
}
484+
// No module is committed for this locale, so there is nothing beside a
485+
// companion for it to be ABOUT — and an orphan is worse than nothing:
486+
// `--check` compares by bytes against the emitted list, so a zero-record
487+
// companion written once is a file the gate demands forever. `{}` is
488+
// truthy, so returning the narrowed table here wrote exactly that.
489+
if (committed.size === 0) return undefined;
460490
return narrowToCommittedSections(table, committed);
461491
};
462492

packages/cli/src/commands/lint.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1022,7 +1022,34 @@ export default class Lint extends Command {
10221022
}
10231023
generate = fn;
10241024
} catch (error: any) {
1025-
const msg = `Failed to load generator "${flags.generator}": ${error?.message || error}`;
1025+
// [#16359] Our separator `": "` already carries the ONE space between
1026+
// the quoted value and the reason; the detail must not bring a second.
1027+
// `bundle-require` composes its own refusal as
1028+
// `${filepath} is not a valid JS file`, so an EMPTY filepath
1029+
// contributes no characters and that fragment arrives with a LEADING
1030+
// space, which lands against ours. Re-driven at 923caede80 through
1031+
// `od -c`, both faces, `bin/run-dev.js`, `NO_COLOR=1`:
1032+
//
1033+
// os lint --eval --generator "" -> `generator "": is not a valid JS file`
1034+
// os lint --eval --json --generator "" -> the same two spaces inside `{error}`
1035+
// os lint --eval [--json] --generator <unresolvable path> -> ONE space
1036+
//
1037+
// ⛔ This is NOT a branch on the empty value. #16161 ruled that the
1038+
// empty string must answer through the door an unresolvable path
1039+
// already answers through, and a bespoke message for it would be the
1040+
// second refusal shape that card exists to avoid. The normalisation
1041+
// below reads the SEAM and never `flags.generator`, and applies to
1042+
// every detail alike — so both inputs still reach this one `catch`,
1043+
// this one composition, this one envelope and this one exit code, and
1044+
// every detail that does not open with a space is byte-identical.
1045+
//
1046+
// Leading SPACES only, deliberately not `trimStart()`: a detail that
1047+
// opens with a newline is a different shape (our space then a line
1048+
// break), not a doubled separator, and stays exactly as it prints
1049+
// today. `test/lint-eval-generator-refusal-separator.test.ts` pins the
1050+
// bytes on both faces, with the unresolvable path as the control.
1051+
const detail = `${error?.message || error}`.replace(/^ +/, '');
1052+
const msg = `Failed to load generator "${flags.generator}": ${detail}`;
10261053
// [#15549] The ADR-0112 carriers, spread from the SAME helper the
10271054
// project-lint catch-all in `run()` uses — not a second shape invented
10281055
// here. Before this, the `catch` built `msg` and DISCARDED `error`, so

0 commit comments

Comments
 (0)