Skip to content

Commit 2e586a1

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15521-audit-stamp-nullability-and-default
2 parents c5bae68 + fc3fb7c commit 2e586a1

12 files changed

Lines changed: 1532 additions & 93 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os i18n extract` reports key counts that describe the bytes it emitted, and its summary is a partition of the skeleton rather than a sum over it.
6+
7+
`extractTranslations` returned `counts[locale]` as a WALK counter — `count += 1` once per expected entry, unconditionally — and the command spent it as the number of keys in the file it had just written. Under the default `--objects-only` the module holds only the `objects` sub-tree, so the two are different numbers. Driven on a one-object, one-app stack with `i18n.defaultLocale: 'zh-CN'`:
8+
9+
```
10+
Skeleton summary
11+
zh-CN 776 key(s) (of 776 expected) + 773 metadataForms key(s)
12+
Wrote OUT/zh-CN.objects.generated.ts (776 keys)
13+
```
14+
15+
The file that run wrote holds **2** leaves. The true split of the 776 is 2 objects + 1 app + 773 metadata-form baseline, so the summary appended a number the 776 already contained and read as 1549 out of 776 — an operator could not derive the truth from it, and the `(776 keys)` described no file the run produced. Both lines now read off the emitted tree:
16+
17+
```
18+
Skeleton summary
19+
zh-CN 775 of 776 key(s) emitted objects 2 · metadataForms 773
20+
Wrote OUT/zh-CN.objects.generated.ts (2 keys)
21+
Wrote OUT/zh-CN.metadata-forms.generated.ts (773 keys)
22+
```
23+
24+
**What each number now means.** `ExtractResult.counts[locale]` is a leaf count of `bundles[locale]` — the whole skeleton built for that locale, taken off the tree instead of off the walk that built it. It is explicitly not the size of any one file: which sections of the skeleton become committed modules is the caller's decision. The command therefore takes every count it reports off that module's own payload, selected with `translationModulePayload` — the same function `renderTranslationModule` renders from, so the number and the bytes cannot drift apart, including for a sub-tree mode added later. Nothing subtracts one count from another at a print site: that would repair today's two modes and leave the third wrong in the same way.
25+
26+
**The summary line's shape changed** from `N key(s) (of N expected) + M metadataForms key(s)` to `E of S key(s) emitted` with a per-module breakdown. `E` is what this run's modules hold together and `S` is what the locale's skeleton holds, so `E ≤ S` always and the gap is exactly the keys a flag excluded — one app label under the default `--objects-only`, and nothing at all under `--no-objects-only`. A module a flag SUPPRESSED is named in the breakdown too, with its size and the words `not emitted` that keep it out of `E`: under `--no-metadata-forms` the row reads `2 of 776 key(s) emitted objects 2 · metadataForms 773 not emitted`, so the operator still sees how big the baseline they switched off is — which the old, double-counting line did tell them.
27+
28+
**A module with no leaves is no longer written.** The emit gate was `counts[locale] > 0`, a property of the skeleton: on a stack whose only surface is apps, the default `--objects-only` wrote a `<locale>.objects.generated.ts` holding `{}` and announced it as 774 keys. The gate is now the module's own leaf count.
29+
30+
**`--json`**: `counts` is now the leaf count of the `bundles` payload printed beside it, instead of the extractor's skeleton size. The skeleton total is unchanged and still reported, under its own name, as `totalExpected`.
31+
32+
⚠️ That is **not** the relationship `metadataFormsCounts` has to `metadataForms`, and nothing here changes the latter. `metadataFormsCounts` reports the baseline as BUILT, emitted or not: under `--no-metadata-forms` the payload carries `metadataFormsCounts: { 'zh-CN': 773 }` beside an empty `metadataForms`, deliberately, and a pin holds it there. So the payload carries two count semantics — `counts` is what was emitted, `metadataFormsCounts` is what was built. Both faces are unchanged by this note; it exists because an earlier draft of it claimed a symmetry that does not hold.
33+
34+
**No committed bundle moves.** All nine extract configs in this repository run under the default `--objects-only` on stacks that do author objects, and every emitted module is byte-for-byte unchanged; `pnpm check:i18n` stays green on the committed tree. What changed is stdout, the `--json` counts, and the emission of a module that would have been empty.
35+
36+
The regression pin spawns the real CLI in four flag states and compares each printed count against a structural leaf count of the module it wrote, parsed back off disk. That comparison is the thing the defect precluded: a walk counter cannot disagree with the walk, so no assertion over `ExtractResult` could have failed while the printed number was wrong by two orders of magnitude. Its `--json` case drives `--metadata-forms` in both states, because a case that drives one state of a flag cannot see what that flag does — driving it ON only is exactly how the symmetry claim above survived unmeasured into a first draft.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
The registry's three conflict refusals now publish their error `code` as an importable constant.
6+
7+
`SchemaRegistry`'s install-time and registration refusals each already told the reader, in their own docblocks, to identify them by `code` rather than `instanceof` — and offered nothing to import. `NAMESPACE_CONFLICT`, `DUPLICATE_ARTIFACT_OBJECT_NAME` and `OBJECT_OWNERSHIP_CONFLICT` were inline string literals, so the only way to follow that instruction was to re-spell the string in the consumer's own package, which acquires a `check:error-code-provenance` stamp site there and can then drift from what the engine throws with no compile error to say so.
8+
9+
Three new exports from `@objectstack/objectql`:
10+
11+
- `NAMESPACE_CONFLICT_CODE` — the ADR-0048 Phase 1 install-time namespace gate's refusal.
12+
- `DUPLICATE_ARTIFACT_OBJECT_NAME_CODE` — the ADR-0130 D3 one-artifact object-name refusal.
13+
- `OBJECT_OWNERSHIP_CONFLICT_CODE` — the ADR-0029 D3 single-owner-per-object-name refusal.
14+
15+
**Why `code` and not `instanceof`.** This package declares both realms in its own `exports` (`import` reaches `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding the other realm's copy of a class gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives crossing that boundary.
16+
17+
**Nothing about the wire changed.** Each constant holds text byte-identical to the literal it replaces; the refusals throw the same `code`, the same `status: 422` and the same message as before. Existing consumers that spell the string themselves keep working unchanged — this adds an affordance, it removes nothing.
18+
19+
**The error classes stay unexported, deliberately.** Publishing them would publish the `instanceof` route this convention exists to replace.

content/docs/kernel/contracts/metadata-service.mdx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface IMetadataService {
3131
registerInMemory?(type: string, name: string, data: unknown): void;
3232
get(type: string, name: string): Promise<unknown | undefined>;
3333
list(type: string): Promise<unknown[]>;
34+
listDiagnosed?(type: string): Promise<{ items: unknown[]; degraded: boolean; errors: string[] }>;
3435
unregister(type: string, name: string): Promise<void>;
3536
exists(type: string, name: string): Promise<boolean>;
3637
listNames(type: string): Promise<string[]>;
@@ -131,6 +132,51 @@ if (degraded) {
131132
}
132133
```
133134

135+
### list / listNames
136+
137+
The plural reads' failure posture. Both read a **set** through the same
138+
registered loaders, and — unlike the singular reads above, which collapse every
139+
fault into one `null` — they answer two different kinds of fault differently.
140+
141+
| condition | outcome |
142+
|:---|:---|
143+
| A loader cannot be read — a storage outage, an unreachable `sys_metadata`, any other throw | **Degrade** — that loader is reported once and skipped; the read resolves with what the reachable loaders hold |
144+
| One metadata name is derived from more than one file — `twin.json` beside `twin.yaml` in one type directory | **Refuse**`AmbiguousMetadataStemError` propagates out of both reads |
145+
146+
**Degrade** is the older of the two postures and the one nothing announces to
147+
the caller: `list` and `listNames` still resolve, the caller still gets an
148+
array, nothing 500s, and the set is quietly short. `listDiagnosed` is what
149+
tells a short set apart from a complete one — it returns the same items plus
150+
`degraded` and `errors`, and `degraded` is true when at least one loader could
151+
not be read while the set was assembled. It says the set is **known-partial**,
152+
never that it is empty and never that it is wrong: a reason to withhold a claim
153+
of *completeness*, never a reason to withhold the items.
154+
155+
`listNames` has **no diagnosed counterpart**. A short name set is not
156+
distinguishable by its caller at all — the lost loader is reported at `error`
157+
in the server log and nowhere else.
158+
159+
**Refuse** is an authoring error rather than an outage, so it is deliberately
160+
not absorbed by the degrade seam above. The filesystem loader derives a
161+
metadata name by stripping the extension from a flat file's basename, so two
162+
files under one type directory sharing a stem produce one name that is listed
163+
twice while only one of them is reachable under that name. Instead of picking a
164+
winner by extension precedence, the loader throws, and both plural reads
165+
re-raise it. The error carries the ADR-0112 envelope — code
166+
`AMBIGUOUS_METADATA_STEM`, status `500` (the request is well formed and no
167+
caller can fix it by sending something else; only deleting or renaming a file
168+
does), plus the metadata `type`, the `stem`, and **every** colliding path,
169+
sorted — never just the precedence winner. Catch it with
170+
`isAmbiguousMetadataStemError` from `@objectstack/metadata` wherever you need
171+
to tell it apart from an outage.
172+
173+
<Callout type="info">
174+
Only stems the loader would actually resolve collide: the comparison is
175+
case-sensitive, it covers just the extensions whose serializers are registered
176+
(`.js` is not in the default set), and a nested file sharing a flat file's
177+
basename is not a collision.
178+
</Callout>
179+
134180
### register / unregister
135181

136182
`register` saves (creates or replaces) the full definition for a `(type, name)`.

0 commit comments

Comments
 (0)