diff --git a/.changeset/liveness-producer-and-evidence-scope.md b/.changeset/liveness-producer-and-evidence-scope.md new file mode 100644 index 0000000000..af4a9fab17 --- /dev/null +++ b/.changeset/liveness-producer-and-evidence-scope.md @@ -0,0 +1,69 @@ +--- +"@objectstack/spec": patch +--- + +feat(spec): a liveness `live` verdict can now cite its PRODUCER, and an entry can declare how wide its last look was (#4837, #4895) + +Two fields on liveness ledger entries, both optional, both validated, each +closing a way a `live` verdict has been measurably wrong. + +**`producer` — a consumer is only half the call graph (#4837).** `seed.json` +marked `Seed.env` **live**, evidence `seed-loader.ts:91`, note *"filterByEnv +drops datasets whose env list excludes the running environment."* Every word was +checkable against the file and the verdict was still false: line 91 really did +call `filterByEnv(request.seeds, config.env)`, but none of the **six** call sites +that build a `SeedLoaderRequest` passed `env` — so `config.env` was permanently +`undefined`, the filter returned its input on its first line, and `dataset.env` +was never read at all. The evidence pointed at the consumer; the property was +dead at the producer. `seed-loader.test.ts` passed throughout, because the test +supplies `config.env` itself: it exercised a mechanism nothing fed. + +So the criterion, now written in `liveness/README.md`: when a property's runtime +effect depends on a **second input somebody must supply**, `live` requires +evidence on the producer side too. `producer` carries it, and resolves through +the same resolver as `evidence` — a repo-local path that does not exist fails +CI, because a call-site claim nothing can falsify is exactly what the field +exists to remove. Absence never fails (the ledger predates the field); +`check:liveness --producer-gap` prints the worklist, and the README's table says +which shapes actually need one — the risk class is optional config with a +default, which always "has a value" in the type system and can still be +`undefined` at runtime. + +**`evidenceScope` — how wide the last look was (#4895).** Four measured verdicts +were reached by searching this repo alone and published as if they covered every +consumer: `app.homePageId` ("no shell ever read it" — objectui's +`resolveLandingRoute()` had been reading it all along), `flow.…position` (marked +live on a designer that wrote its own `ui:{x,y}` — a false *live*, the opposite +direction), `HttpMethod` (a scan matching only `import … from`), and +`Notification` (objectui re-exported it with `export … from` and the real +consumers imported from `@object-ui/types` — two hops, which no specifier match +can see). `"evidenceScope": "in-repo" | "cross-repo"` records what was actually +done. Absent is a worklist row; a value outside the vocabulary fails, the same +asymmetry `verifiedAt` uses. + +**Entries re-verified while landing this** (not a mass re-grade — six entries +whose call graph was closed by hand): + +- `seed.env` — the specimen. Evidence restamped to the live line and a `producer` + added: since #4704, `load()` resolves the comparison environment itself at the + one funnel every seeding path goes through, so call site seven cannot reopen + the hole. +- `job.timeout`, `hook.retryPolicy` / `timeout` / `onError` — the same + "consumer reads it out of an options object built elsewhere" shape, checked + and **holding**: the job scheduler threads `{ retryPolicy, timeout }` into + `svc.schedule`, and the hook binder hands the authored hook straight to + `wrapDeclarativeHook`. Cited, not assumed. +- `app.homePageId` and `book.groups[].translations` — the two surviving + tombstones from the #4667 retirement batch, re-verified **cross-repo** against + objectui `@c2fd1223` and confirmed. objectui now rejects `homePageId` in its + own schema with a pin test, and its book-spine interfaces declare no + `translations`. (The other four keys in that batch were strict-removed, so + they have no ledger row to date; their re-verification is recorded in the PR.) + +`cloud` is not reachable from an open-source checkout, so `cross-repo` means "the +realms named in the evidence", never "everywhere" — the README says so where the +value is defined. + +`producer.mts` is pure and unit-tested for the reason `orphans.mts` and +`drill.mts` are: on the shipped ledgers these checks are almost entirely quiet, +so a green gate proves nothing about whether they can fire. diff --git a/.changeset/stack-collection-maps-answerable.md b/.changeset/stack-collection-maps-answerable.md new file mode 100644 index 0000000000..025e3379b6 --- /dev/null +++ b/.changeset/stack-collection-maps-answerable.md @@ -0,0 +1,72 @@ +--- +"@objectstack/metadata": patch +--- + +fix(metadata,repo): every enumeration of the stack-collection set is now answerable to `stack.zod.ts`, and the artifact map stops aiming `data:` at the analytics kind (#6242) + +`ObjectStackDefinitionSchema` decides which collections a stack may declare — 32 +of them today. **Seven** other places re-enumerate that same set by hand (eight +enumerations in all, because ObjectQL declares its list twice), and nothing +compared any of them to the schema or to each other: + +| Enumeration | Site | +|---|---| +| `MAP_SUPPORTED_FIELDS` / `PLURAL_TO_SINGULAR` | `packages/spec/src/shared/metadata-collection.zod.ts` | +| `MetadataCategoryEnum` | `packages/spec/src/kernel/package-artifact.zod.ts` | +| `metadataArrayKeys` ×2 | `packages/objectql/src/engine.ts` | +| `ARTIFACT_FIELD_TO_TYPE` | `packages/metadata/src/plugin.ts` | +| `APP_CATEGORY_KEYS` | `packages/runtime/src/app-plugin.ts` | +| `STACK_COLLECTION_COVERAGE` | `examples/app-showcase/src/coverage.ts` | + +They had drifted independently: `ragPipelines` mapped in three of them though no +schema declares it; `workflows` / `approvals` / `roles` / `profiles` / `policies` +still iterated by both ObjectQL loops after ADR-0019 / ADR-0020 / ADR-0088 / +ADR-0090 retired them; `triggers` + `workflows` still legal artifact categories; +19 of 32 collections absent from that enum. + +Every row looks like a one-line typo in isolation, and each **has** been fixed +one line at a time before — `docs` in `ARTIFACT_FIELD_TO_TYPE`, `roles` → +`positions` in the same map, `capabilities` in `metadataArrayKeys` — each still +carrying its "this key was missing and it silently dropped X" comment. The cause +is structural: `KIND_COVERAGE` is answerable to the metadata-type registry and +fails CI when a kind is added without an entry, and the liveness ledger is +answerable to the same registry. The collection maps were answerable to nothing. + +**The gate.** `pnpm check:stack-collection-maps` (root +`scripts/check-stack-collection-maps.mjs`, wired into the lint job) derives the +collection set from `ObjectStackDefinitionSchema` — top-level keys whose value is +`z.array(Schema)`, a mechanical rule rather than a second hand-kept list — and +reconciles all eight enumerations against it in **both** directions. Deriving them +is not possible today (they disagree on purpose as often as by accident: `views` +has no `name`, `data` seeds key by `object`, `translations` is a record), so each +deviation must instead be a waiver row **carrying its reason**, and the list is a +ratchet: a waiver that no longer applies fails, like a stale ledger row. An +enumeration whose symbol cannot be extracted fails too — an empty list would +reconcile against everything. + +Writing it immediately found a **seventh** site the hand-audit had missed +(`APP_CATEGORY_KEYS`) and one divergence *between* the two ObjectQL copies that +neither list shows alone: `jobs`, `emailTemplates`, `tools` and `skills` are +registered from a manifest and **not** from a nested plugin, so a package +shipping them from a nested plugin registers nothing and stamps no ADR-0010 +provenance. `capabilities` was added to that copy for exactly this reason +(#5870); nobody then asked what else the two lists disagreed about. Recorded as +a waiver with the measurement, not fixed here — closing it changes what a nested +plugin registers at boot. + +**The one code change**: `ARTIFACT_FIELD_TO_TYPE` no longer maps `data:` (the +SEED collection) to `'dataset'` (the ADR-0021 analytics kind) — the exact name +collision `metadata-plugin.zod.ts` warns about in prose. The entry was provably +inert (`SeedSchema` declares no `name`, and the ingest loop skips nameless +items), so nothing changes at runtime; what changes is that a dead pointer aimed +at the wrong kind is gone, instead of waiting for either side to move. Not +repointed at `'seed'`: seeds are applied by `SeedLoaderService` off the bundle, +never registered as metadata items, so that would be new behaviour rather than a +corrected name. The absence is now pinned by the gate. + +Everything else the gate reports is recorded as a waiver with its reason and left +alone, deliberately — three of the drift rows sit on **acceptance faces** +(`MetadataCategoryEnum` decides what a published artifact may declare) and the +rest are `engine-core` behaviour changes owing their own verification. The value +landing today is that all eight enumerations now have a checked relationship to +the schema rather than an assumed one. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c8dde1bab0..1e57745244 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -133,6 +133,18 @@ jobs: - name: Raw control-byte guard run: pnpm check:nul-bytes + # Stack-collection enumerations vs the schema (#6242). `stack.zod.ts` + # decides which collections a stack may declare; eight other enumerations + # of that same set are hand-maintained (the map-format list, the + # plural→singular map, the artifact category enum, ObjectQL's two + # registration loops, the artifact-ingest field map, the runtime's + # app-payload probe, the showcase coverage manifest) and nothing compared + # any of them to the schema. Each drift instance has been fixed one line at + # a time before, always after a missing key silently dropped a collection. + # A deviation is legal here only as a waiver row carrying its reason. + - name: Stack-collection enumerations answerable to stack.zod.ts + run: pnpm check:stack-collection-maps + # Docs/skills authoring guard (#2035 / ADR-0059): TS code blocks in # Markdown/MDX are not type-checked or ESLinted, so skills/ and # content/docs/ can drift back to teaching the bare `: Page = {}` literal diff --git a/package.json b/package.json index 2f78a79ed9..8476b134ca 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "check:i18n-coverage": "node scripts/check-i18n-coverage.mjs --self-test && node scripts/check-i18n-coverage.mjs", "check:app-nav-i18n": "pnpm --filter @objectstack/cli run check:app-nav-i18n", "check:nul-bytes": "node scripts/check-nul-bytes.mjs --self-test && node scripts/check-nul-bytes.mjs", + "check:stack-collection-maps": "node scripts/check-stack-collection-maps.mjs --self-test && node scripts/check-stack-collection-maps.mjs", "check:doc-authoring": "node scripts/check-doc-authoring.mjs --self-test && node scripts/check-doc-authoring.mjs", "check:docs-audit-scope": "node scripts/docs-audit/affected-docs.mjs --self-test && node scripts/docs-audit/check-audit-scope.mjs --self-test && node scripts/docs-audit/check-audit-scope.mjs", "check:role-word": "node scripts/check-role-word.mjs", diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index 1897793fa8..cc5d562c44 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -56,7 +56,14 @@ const queryableMetadataObjects = [ // source code (which the legacy FilesystemLoader still scans). const REPO_SUBDIR = '.objectstack/metadata'; -// Map from ObjectStackDefinition field name to MetadataType name +// Map from ObjectStackDefinition field name to MetadataType name. +// +// PINNED against the schema: `scripts/check-stack-collection-maps.mjs` reconciles +// this map with `ObjectStackDefinitionSchema` in both directions, and every +// deviation carries a reason there (#6242). It is one of seven hand-maintained +// enumerations of the same set, and the reason this one has a gate is its own +// history — `docs` and `roles → positions` were each fixed here one line at a +// time, after a missing key silently dropped a whole collection. const ARTIFACT_FIELD_TO_TYPE: Record = { objects: 'object', objectExtensions: 'object_extension', @@ -89,7 +96,15 @@ const ARTIFACT_FIELD_TO_TYPE: Record = { emailTemplates: 'email_template', docs: 'doc', books: 'book', - data: 'dataset', + // `data:` (the SEED collection) is deliberately absent — #6242 row 4(a). + // It used to map to `'dataset'`, the ADR-0021 analytics kind: the exact name + // collision `metadata-plugin.zod.ts` warns about in prose. The entry never + // registered anything (SeedSchema declares no `name`, and the loop below + // skips nameless items) — a dead pointer aimed at the wrong kind, which + // would have begun mis-registering the day either side moved. Removed rather + // than repointed at `'seed'`: seeds are APPLIED by SeedLoaderService off the + // bundle, never registered as metadata items, so a `seed` mapping would be + // new behaviour rather than a corrected name. }; // ─────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index df1129ff97..8310051314 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -143,6 +143,101 @@ re-verify rather than back-filling guesses. **For objectui-side evidence, pin the commit** (`objectui @732b1bf`) — `action.undoable`'s reader line numbers had already drifted by 28 lines one day after the issue citing them was filed. +### `producer` — a consumer is only HALF the call graph (#4837) + +`live` means **authoring the property changes runtime behaviour**. A cited +consumer proves something *reads* the key. It does not prove the read has an +effect, because a read can depend on a second input that nobody supplies. + +`seed.json` marked `Seed.env` **live**, evidence `seed-loader.ts` line 91, note +*"filterByEnv drops datasets whose env list excludes the running +environment."* Every word of that was checkable against the file, and the +verdict was still false: + +> Line 91 really did call `filterByEnv(request.seeds, config.env)`. But none of +> the **six** call sites that build a `SeedLoaderRequest` — app boot, per-org +> replay, hot reload, package apply, draft publish, marketplace install — ever +> passed `env`. So `config.env` was permanently `undefined`, `filterByEnv` +> returned its input on its first line, and `dataset.env` was **never read at +> all**. The evidence pointed at the consumer; the property was dead at the +> **producer**. + +The unit test made it worse rather than better: `seed-loader.test.ts` has always +had a passing `should handle environment filtering` case — because the test +supplies `config.env` itself. The mechanism was correct throughout; only the +wiring was missing, and the ledger and the test both looked exclusively at the +mechanism. This is the shape Prime Directive #10 already names in another +context: **a `case` label is not enforcement; check the call site** (#3106). + +Hence the criterion: + +> When a property's runtime effect depends on a **second input that some +> producer must supply**, a `live` verdict requires evidence on the producer +> side too. Cite it in `producer`. + +```jsonc +"env": { + "status": "live", + "evidence": "packages/metadata-protocol/src/seed-loader.ts:191 (filterByEnv drops …)", + "producer": "packages/metadata-protocol/src/seed-loader.ts:174 — load() resolves the comparison environment itself (resolveEnvConfig), rather than trusting a caller to pass it" +} +``` + +`producer` resolves through **the same resolver as `evidence`** — repo-rooted +paths must exist or CI fails; cross-repo paths are attributed and counted. A +call-site claim nothing can falsify is precisely what this field exists to +remove, so it does not get a weaker standard than the pointer it completes. + +Which entries need one. The risk is highest for **optional config with a +default**: those always "have a value" in the type system and can still be +`undefined` at runtime. + +| Shape | Needs `producer`? | +|---|---| +| The consumer reads the authored value directly (`hook.priority` orders hooks) | no — the author IS the producer | +| The consumer compares the authored value against something a caller supplies (`seed.env`) | **yes** — cite who supplies it | +| The consumer reads it out of an options/config object built elsewhere (`job.timeout`) | **yes** — cite the threading site | +| The property is `dead` | no — there is nothing to produce | + +Absence never fails CI (most of the ledger predates the field, and back-filling +guesses is the sin this records). `pnpm check:liveness --producer-gap` prints +every `live` entry citing a consumer only — an upper bound on the debt, to be +triaged with the table above rather than read as a defect list. A **malformed** +value does fail. + +### `evidenceScope` — how wide the last look actually was (#4895) + +Four measured verdicts were reached by searching **this repo only**, and +published as though they covered every consumer: + +| # | Verdict | What the search missed | +|---|---|---| +| 1 | `app.homePageId` tombstoned "no shell ever read it" | objectui's `AppContent.resolveLandingRoute()` had been reading it all along (corrected in #4709) | +| 2 | `flow.nodes.children.position` marked live, "designer canvas layout" | the designer wrote its own `ui:{x,y}` and **nothing** read `position` — a false *live*, the opposite direction | +| 3 | `HttpMethod` reported unused | the scan matched only `import … from` | +| 4 | `Notification` / `NotificationConfig` removed on "zero importers" | objectui re-exported them with `export … from`, and the real consumers imported from `@object-ui/types` — **two hops**, so even a scan covering `export … from` misses it while it matches on the spec specifier | + +Case 4 is the one that decides the method: **no amount of text or specifier +matching is sufficient**. A negative cross-repo claim has to follow the resolved +symbol graph through re-export chains, or it is a guess with a citation. Every +barrel package adds a blind spot, and the renderer repo is all barrels. + +`evidenceScope` records what was actually done, as data: + +| Value | Means | +|---|---| +| `in-repo` | the call graph was closed inside this repo only | +| `cross-repo` | a named foreign realm was walked too — say **which**, in the evidence, and **pin the commit** (`objectui @c2fd1223`): `action.undoable`'s reader line numbers drifted 28 lines in one day (#3714) | + +Absent = scope undeclared, a worklist row rather than a failure; the field is +younger than nearly every entry. A value outside the vocabulary FAILS, the same +asymmetry as `verifiedAt` — a value the parser cannot read would silently exempt +that entry from every future sweep. + +⚠️ Neither `cross-repo` value in the tree today covers **`cloud`**: the closed +runtime is not reachable from an open-source checkout, so a `cross-repo` claim +means "the realms named in the evidence", never "everywhere". + ### ⚠️ An authoring/preview renderer is NOT a runtime consumer `live` means **authoring the property changes runtime behaviour**. A Studio @@ -522,10 +617,15 @@ over-share. silently. Same "pure + unit-tested" reasoning as `orphans.mts`, for the same reason. - `../scripts/liveness/check-empty-state.mts` — the empty-state gate (above); `empty-state-registry.mts` is its source of truth. +- `../scripts/liveness/producer.mts` — the `producer` / `evidenceScope` fold + (#4837 / #4895). Pure + unit-tested for the same reason as `orphans.mts`: on + the shipped ledgers these checks are almost entirely quiet, so a green gate + proves nothing about whether they can fire. ```bash pnpm --filter @objectstack/spec check:liveness # run the gate tsx packages/spec/scripts/liveness/check-liveness.mts --dump field # inventory a type (seeding aid) +tsx packages/spec/scripts/liveness/check-liveness.mts --producer-gap # live entries citing a consumer only ``` CI: `.github/workflows/spec-liveness-check.yml` runs on PRs touching `packages/spec/**`. diff --git a/packages/spec/liveness/app.json b/packages/spec/liveness/app.json index f4ba408af5..2b1600f1b3 100644 --- a/packages/spec/liveness/app.json +++ b/packages/spec/liveness/app.json @@ -227,8 +227,9 @@ }, "homePageId": { "status": "dead", - "verifiedAt": "2026-08-02", - "note": "REMOVED 2026-08-02 (#4667) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). PREMISE CORRECTED 2026-08-03 (#4709), removal upheld: #4667 justified this row with \"no shell ever read it\", which was FALSE and contradicted this repo's own 2026-06 AppSchema liveness audit (docs/audits/2026-06-appschema-property-liveness.md), which listed homePageId LIVE. objectui's console DID read it — resolveLandingRoute(), packages/app-shell/src/console/AppContent.tsx @785b8a5d — and it was the only thing deciding where an app opened. The key is retired for its SHAPE: an ID cross-reference into navigation with no referential integrity, silently falling back to the first item when it dangled. Post-v17 the landing IS the first navigation item (by order), and the ROOT landing follows isDefault routing (objectui RootLandingRedirect). Reorder navigation, or set isDefault, instead; if the capability returns it belongs on the navigation item itself (navigation[].landing), enforce-first. The three aliases that routed here (home / homepage / landingpage) were removed from AppSchema aliases and now carry the same prescription as guidance. Process lesson: a retirement citing liveness MUST reconcile against existing audit records — this contradiction went unnoticed for two months." + "verifiedAt": "2026-08-09", + "evidenceScope": "cross-repo", + "note": "CROSS-REPO RE-VERIFIED 2026-08-09 against objectui @c2fd1223 (#4895 task 1): the retirement holds, and the renderer side now says so in code rather than by absence — OuiAppSchema REJECTS the key (with a pin test asserting it is not accepted again), AppPreview ignores it (pin test), AppContent.resolveLandingRoute() documents the removal, and RootLandingRedirect routes on isDefault. No reader remains. The verdict below was first reached by searching this repo alone and was wrong for two months on exactly that account, so this row now records how wide the last look was. REMOVED 2026-08-02 (#4667) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). PREMISE CORRECTED 2026-08-03 (#4709), removal upheld: #4667 justified this row with \"no shell ever read it\", which was FALSE and contradicted this repo's own 2026-06 AppSchema liveness audit (docs/audits/2026-06-appschema-property-liveness.md), which listed homePageId LIVE. objectui's console DID read it — resolveLandingRoute(), packages/app-shell/src/console/AppContent.tsx @785b8a5d — and it was the only thing deciding where an app opened. The key is retired for its SHAPE: an ID cross-reference into navigation with no referential integrity, silently falling back to the first item when it dangled. Post-v17 the landing IS the first navigation item (by order), and the ROOT landing follows isDefault routing (objectui RootLandingRedirect). Reorder navigation, or set isDefault, instead; if the capability returns it belongs on the navigation item itself (navigation[].landing), enforce-first. The three aliases that routed here (home / homepage / landingpage) were removed from AppSchema aliases and now carry the same prescription as guidance. Process lesson: a retirement citing liveness MUST reconcile against existing audit records — this contradiction went unnoticed for two months." }, "requiredPermissions": { "status": "live", diff --git a/packages/spec/liveness/book.json b/packages/spec/liveness/book.json index 9f439a22b2..0d200c6e05 100644 --- a/packages/spec/liveness/book.json +++ b/packages/spec/liveness/book.json @@ -60,8 +60,9 @@ }, "translations": { "status": "dead", - "verifiedAt": "2026-08-02", - "note": "REMOVED 2026-08-02 (#4667) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). TOMBSTONE rather than delete because BookGroupSchema is a plain z.object with NO .strict() — a bare delete would have zod silently strip the key, trading one silent no-op for another. No resolver read inline book/group translations; group labels rendered verbatim in every locale. Localize the DOCS instead: doc.translations is live on every doc render path." + "verifiedAt": "2026-08-09", + "evidenceScope": "cross-repo", + "note": "CROSS-REPO RE-VERIFIED 2026-08-09 against objectui @c2fd1223 (#4895 task 1): confirmed. objectui models the book spine independently (apps/console/src/pages/book-nav.ts `Book` / `BookGroup`) and neither interface declares `translations`, so no renderer ever consumed the inline maps. REMOVED 2026-08-02 (#4667) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). TOMBSTONE rather than delete because BookGroupSchema is a plain z.object with NO .strict() — a bare delete would have zod silently strip the key, trading one silent no-op for another. No resolver read inline book/group translations; group labels rendered verbatim in every locale. Localize the DOCS instead: doc.translations is live on every doc render path." }, "order": { "status": "live", diff --git a/packages/spec/liveness/hook.json b/packages/spec/liveness/hook.json index d1dd157d58..f04c71e621 100644 --- a/packages/spec/liveness/hook.json +++ b/packages/spec/liveness/hook.json @@ -44,17 +44,26 @@ }, "retryPolicy": { "status": "live", + "verifiedAt": "2026-08-09", + "evidenceScope": "in-repo", "evidence": "packages/objectql/src/hook-wrappers.ts", - "note": "{maxRetries,backoffMs} linear backoff." + "producer": "packages/objectql/src/hook-binder.ts:221 — the binder hands the AUTHORED hook to wrapDeclarativeHook, so the wrapper reads the author's block rather than a caller-built options object", + "note": "{maxRetries,backoffMs} linear backoff. Producer-side re-verified 2026-08-09 (#4837 slice): the whole declarative group (condition / async / retryPolicy / timeout / onError) is fed from one call site, so one producer pointer covers it — this is the shape `Seed.env` failed, checked and passing here." }, "timeout": { "status": "live", + "verifiedAt": "2026-08-09", + "evidenceScope": "in-repo", "evidence": "packages/objectql/src/hook-wrappers.ts", + "producer": "packages/objectql/src/hook-binder.ts:221 (same wrapDeclarativeHook call as retryPolicy)", "note": "wall-clock abort, independent of body.timeoutMs." }, "onError": { "status": "live", + "verifiedAt": "2026-08-09", + "evidenceScope": "in-repo", "evidence": "packages/objectql/src/hook-wrappers.ts", + "producer": "packages/objectql/src/hook-binder.ts:221 (same wrapDeclarativeHook call as retryPolicy)", "note": "'log' suppresses+continues; 'abort' rethrows." }, "label": { diff --git a/packages/spec/liveness/job.json b/packages/spec/liveness/job.json index 6e743eacb6..02e63c09f4 100644 --- a/packages/spec/liveness/job.json +++ b/packages/spec/liveness/job.json @@ -38,9 +38,11 @@ }, "timeout": { "status": "live", - "verifiedAt": "2026-08-02", + "verifiedAt": "2026-08-09", + "evidenceScope": "in-repo", "evidence": "packages/services/service-job/src/run-with-policy.ts:25-33", - "note": "per-attempt limit; an over-limit run records execution status 'timeout' (JobTimeoutError). The in-flight handler is abandoned, not cancelled — as documented." + "producer": "packages/runtime/src/app-plugin.ts:875-876 — the scheduler threads `{ retryPolicy: job.retryPolicy, timeout: job.timeout }` into svc.schedule, and only when the author set one of them", + "note": "per-attempt limit; an over-limit run records execution status 'timeout' (JobTimeoutError). The in-flight handler is abandoned, not cancelled — as documented. Producer side re-verified 2026-08-09 (#4837 slice): the seeded row cited the consumer only, which is the shape that carried `Seed.env` — here it holds, because #3494 wired the threading at the same time as the enforcement." }, "enabled": { "status": "live", diff --git a/packages/spec/liveness/seed.json b/packages/spec/liveness/seed.json index 9e24dfc113..c0a99d973c 100644 --- a/packages/spec/liveness/seed.json +++ b/packages/spec/liveness/seed.json @@ -22,9 +22,11 @@ }, "env": { "status": "live", - "verifiedAt": "2026-08-01", - "evidence": "packages/metadata-protocol/src/seed-loader.ts:91", - "note": "filterByEnv drops datasets whose env list excludes the running environment." + "verifiedAt": "2026-08-09", + "evidenceScope": "in-repo", + "evidence": "packages/metadata-protocol/src/seed-loader.ts:191 (filterByEnv drops datasets whose env list excludes the running environment)", + "producer": "packages/metadata-protocol/src/seed-loader.ts:174 — `load()` resolves the comparison environment ITSELF (resolveEnvConfig, :1809, off NODE_ENV) before anything reads config, rather than trusting a caller to pass it", + "note": "THE SPECIMEN THIS FIELD EXISTS FOR (#4837). Until #4704 this row was `live` on the consumer pointer alone and the verdict was FALSE: the cited line really did call filterByEnv, but none of the SIX call sites that build a SeedLoaderRequest (app boot, per-org replay, hot reload, package apply, draft publish, marketplace install) passed `env` — so `config.env` was permanently undefined, filterByEnv returned its input on its first line, and `dataset.env` was never read at all. `seed-loader.test.ts` passed throughout, because it supplies `config.env` itself: it exercised a mechanism nothing fed. #4704 fixed the wiring INSIDE `load()`, the one funnel every seeding path goes through, so call site seven cannot reopen the hole. Re-verified 2026-08-09 with both sides cited." }, "records": { "status": "live", diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 6a59c2af0f..20dd444d1f 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -52,6 +52,22 @@ // flagged 48 of 227 entries with a ~100% false-positive rate, so failing on it // would have failed every build. The parse fix is what turned a hit into signal. // +// PRODUCER-SIDE EVIDENCE (`producer`, #4837): `live` means AUTHORING the +// property changes runtime behaviour. A consumer that reads the property is +// necessary and not sufficient — when the effect also depends on a second input +// somebody must supply, the verdict needs evidence that somebody does. +// `Seed.env` was `live` on a correct consumer pointer (`filterByEnv`) while not +// one of six call sites passed `env`, so the filter returned its input on line +// one and the authored value was never read. An optional `"producer"` cites the +// call site; its paths resolve exactly like `evidence`. +// +// EVIDENCE SCOPE (`evidenceScope`, #4895): four measured verdicts have been +// reached by searching this repo alone and published as if they covered every +// consumer (`app.homePageId`, `flow.…position`, `HttpMethod`, +// `Notification`). An optional `"evidenceScope": "in-repo" | "cross-repo"` +// records how wide the last look actually was. Absent = unverified scope (a +// worklist row); malformed = FAIL, same asymmetry as `verifiedAt`. +// // RE-VERIFICATION CLOCK (`verifiedAt`): a ledger entry is a claim with a // timestamp, and code moves under it in BOTH directions — `flow.status` (#3711) // and `action.undoable` (#3714) were both understated by entries that were @@ -67,6 +83,7 @@ // tsx check-liveness.mts --stale-verification # print the re-verification worklist // tsx check-liveness.mts --stale-verification=90 # ...with a custom staleness threshold // tsx check-liveness.mts --undrilled # print the undrilled-container worklist +// tsx check-liveness.mts --producer-gap # print the producer-evidence worklist (#4837) // tsx check-liveness.mts --ledger-root= # read the ledgers from instead of // # packages/spec/liveness — how the self-test // # runs the REAL gate against a mutated copy @@ -96,6 +113,7 @@ import { type VerificationReport, } from './verification.mts'; import { checkEvidence } from './evidence.mts'; +import { buildProducerReport, type ProducerEntry, type ProducerReport } from './producer.mts'; import { ORPHAN_GUIDANCE, findOrphanEntries, type Orphan } from './orphans.mts'; import { STALE_UNDRILLED_GUIDANCE, @@ -329,6 +347,8 @@ const report: any = { deferredContainers: [] as string[], // containers whose subtree IS classified elsewhere — resolved, not believed deferredChildKeys: 0, // how many child keys those resolved deferrals actually cover verification: null as VerificationReport | null, // `verifiedAt` ages — the re-verification worklist + producers: null as ProducerReport | null, // `producer` / `evidenceScope` — the #4837 / #4895 worklists + producerMissing: [] as string[], // a `producer` pointer into thin air — FAILS, like a rotted `evidence` // The three evidence counters, and the distinction between the first two is // the whole point: `evidenceLocal` is how many repo-rooted paths `live` entries // DECLARE, `evidenceMissing` is how many of those do not exist here. The @@ -344,6 +364,9 @@ const report: any = { // walk so the age report sees exactly the set the gate itself classified. const verificationEntries: VerificationEntry[] = []; +// The same set, folded for the producer / evidence-scope report below. +const producerEntries: ProducerEntry[] = []; + const proofFs = { existsSync, readFileSync }; function classify(type: string, path: string, status: string, led: any, cat: any) { @@ -351,7 +374,23 @@ function classify(type: string, path: string, status: string, led: any, cat: any cat.byStatus[status] = (cat.byStatus[status] || 0) + 1; report.totals.byStatus[status] = (report.totals.byStatus[status] || 0) + 1; // Framework-auto entries (`led === null`) have no ledger row to date-stamp. - if (led !== null) verificationEntries.push({ key: `${type}/${path}`, status, verifiedAt: led?.verifiedAt }); + if (led !== null) { + verificationEntries.push({ key: `${type}/${path}`, status, verifiedAt: led?.verifiedAt }); + producerEntries.push({ + key: `${type}/${path}`, + status, + producer: led?.producer, + evidenceScope: led?.evidenceScope, + }); + } + // A `producer` pointer resolves through the SAME resolver as `evidence`: a + // call-site claim nothing can falsify is the failure mode this field exists to + // remove, so it must not get a weaker standard than the consumer pointer it + // completes (#4837). + if (typeof led?.producer === 'string') { + const pv = checkEvidence(led.producer, (p) => existsSync(join(repoRoot, p))); + for (const miss of pv.missing) report.producerMissing.push(`${type}/${path} → ${miss}`); + } if (status === 'live' && led?.evidence) { // Extract every repo-rooted path the evidence claims and resolve the ones // attributed to THIS repo. Cross-repo pointers (objectui / cloud) are @@ -523,6 +562,13 @@ const showWorklist = staleDaysArg !== undefined; const showUndrilled = args.includes('--undrilled'); report.verification = buildVerificationReport(verificationEntries, { staleDays }); +// ── producer-side evidence + verification scope (#4837 / #4895) ── +// Same asymmetry as the clock above: an ABSENT field is a worklist row (the +// ledger predates both fields, and back-filling guesses is exactly the sin these +// record), a MALFORMED one fails. +const showProducerGap = args.includes('--producer-gap'); +report.producers = buildProducerReport(producerEntries); + // ── coverage: is every REGISTERED metadata type accounted for? ── // The gate's own blind spot until #4487. Everything above asks "is every // property of a governed type classified?" — nothing asked "is every authorable @@ -551,6 +597,8 @@ const failed = report.staleEvidence.length > 0 || report.orphanEntries.length > 0 || report.verification.errors.length > 0 || + report.producers.errors.length > 0 || + report.producerMissing.length > 0 || report.ungoverned.length > 0 || report.stalePending.length > 0 || report.undrilledNew.length > 0 || @@ -698,6 +746,36 @@ if (asJson) { } else if (v.stale.length || v.unverified.length) { console.log(' run with --stale-verification[=days] for the worklist.'); } + + // ── producer-side evidence + verification scope ── + const pr: ProducerReport = report.producers!; + if (pr.errors.length) { + console.log(`\n✗ ${pr.errors.length} malformed \`producer\` / \`evidenceScope\` value(s):`); + pr.errors.forEach((s: string) => console.log(` ${s}`)); + } + if (report.producerMissing.length) { + console.log( + `\n✗ ${report.producerMissing.length} \`producer\` pointer(s) that do not resolve — a call-site claim` + + ` nothing can falsify is what this field exists to remove:`, + ); + report.producerMissing.forEach((s: string) => console.log(` ${s}`)); + } + const scopes = Object.entries(pr.byScope).map(([s, n]) => `${n} ${s}`).join(', ') || 'none'; + console.log( + `\nevidence quality: ${pr.withProducer} live entr(ies) cite a PRODUCER (${pr.withoutProducer.length} do not); ` + + `verification scope declared on ${scopes} (${pr.unscoped} undeclared).`, + ); + if (showProducerGap) { + if (pr.withoutProducer.length) { + console.log( + `\n live without producer-side evidence (${pr.withoutProducer.length}) — a consumer pointer is half` + + ` the call graph; the half that killed \`Seed.env\` is the other one:`, + ); + pr.withoutProducer.forEach((k: string) => console.log(` ${k}`)); + } + } else if (pr.withoutProducer.length) { + console.log(' run with --producer-gap for the producer-evidence worklist.'); + } // ── container coverage: how much rides on inheritance? ── // Printed every run, pass or fail. The gate used to say "all properties are // classified" while hundreds of child keys had never been asked about; a diff --git a/packages/spec/scripts/liveness/producer.mts b/packages/spec/scripts/liveness/producer.mts new file mode 100644 index 0000000000..5605456380 --- /dev/null +++ b/packages/spec/scripts/liveness/producer.mts @@ -0,0 +1,158 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `producer` and `evidenceScope` — the two things a liveness `evidence` pointer +// never said, and the two ways a `live` verdict has been wrong in production. +// +// ## 1. `producer` — a consumer is only half the call graph (#4837) +// +// `seed.json` marked `Seed.env` **live**, evidence +// `packages/metadata-protocol/src/seed-loader.ts:91`, note "filterByEnv drops +// datasets whose env list excludes the running environment". Every word of that +// was checkable and the verdict was still false: line 91 really does call +// `filterByEnv(request.seeds, config.env)`, but not one of the six call sites +// that built a `SeedLoaderRequest` passed `env`. `config.env` was permanently +// `undefined`, `filterByEnv` returned its input on the first line, and +// `dataset.env` was never read. The evidence pointed at the CONSUMER; the +// property was dead at the PRODUCER. +// +// The ledger's own README already names this shape in a different context — +// "a `case` label is not enforcement; check the call site" (#3106) — and the +// unit test made it worse rather than better: `seed-loader.test.ts` has always +// had a passing `should handle environment filtering` case, because the test +// passes `config.env` itself. The mechanism was correct all along. Only the +// wiring was missing, and both the ledger and the test looked exclusively at the +// mechanism. +// +// So: for a property whose runtime effect depends on a SECOND input that some +// producer must supply, `live` requires evidence on the producer side too. +// `producer` is that field. It resolves exactly like `evidence` — repo-rooted +// paths must exist, cross-repo paths are attributed and counted — because a +// producer pointer into thin air is the same unfalsifiable claim. +// +// ## 2. `evidenceScope` — how wide the last look was (#4895) +// +// Four measured cases, all one blind spot: a verdict reached by searching THIS +// repo, published as if it covered every consumer. +// +// 1. `app.homePageId` — tombstoned "no shell ever read it"; objectui's +// `AppContent.resolveLandingRoute()` had been reading it all along +// (corrected in #4709). +// 2. `flow.nodes.children.position` — marked live, "designer canvas layout"; +// the designer wrote its own `ui:{x,y}` and NOTHING read `position`. +// 3. `HttpMethod` — a scan that matched only `import … from` reported a false +// negative. +// 4. `Notification`/`NotificationConfig` — removed on "zero importers"; objectui +// re-exported them with `export … from` and consumed them from +// `@object-ui/types`, so even a scan covering `export … from` would have +// missed it if it matched on the spec specifier. +// +// Case 4 is why this field records a SCOPE rather than a boolean "checked": the +// honest answers differ in kind. `in-repo` says the call graph was closed inside +// this repo only. `cross-repo` says a named foreign realm was walked too, and +// the entry is expected to say WHERE in its evidence (pin the commit — an +// objectui reader's line numbers drifted 28 lines in one day, #3714). +// +// Neither field fails CI for being ABSENT. Both fail for being MALFORMED, the +// same asymmetry `verifiedAt` uses: an absent field reports as unverified and +// joins a worklist, while a value the parser cannot read would silently exempt +// the entry from every future sweep — which is the silent-no-op shape this whole +// ledger exists to remove. + +/** The vocabulary for `evidenceScope`. Anything else is a malformed value. */ +export const EVIDENCE_SCOPES = ['in-repo', 'cross-repo'] as const; +export type EvidenceScope = (typeof EVIDENCE_SCOPES)[number]; + +export type FieldCheck = { ok: true } | { ok: false; error: string }; + +/** + * Validate an `evidenceScope` value. Present-and-unknown fails; absent is not + * this function's business (the caller skips it). + */ +export function parseEvidenceScope(value: unknown): FieldCheck { + if (typeof value !== 'string') { + return { ok: false, error: `evidenceScope must be a string, got ${typeof value}` }; + } + if (!(EVIDENCE_SCOPES as readonly string[]).includes(value)) { + return { + ok: false, + error: `evidenceScope "${value}" is not one of ${EVIDENCE_SCOPES.join(' | ')}`, + }; + } + return { ok: true }; +} + +/** + * Validate a `producer` value's SHAPE. Its paths are resolved by the caller + * through the same `checkEvidence` used for `evidence` — one resolver, so a + * producer pointer cannot rot in a way an evidence pointer could not. + * + * A bare file path with no prose is accepted but discouraged: the point of the + * field is to say WHO supplies the value, and `seed-loader.ts:143` alone does not + * (the failing `Seed.env` entry was exactly that shape on the consumer side). + */ +export function parseProducer(value: unknown): FieldCheck { + if (typeof value !== 'string') { + return { ok: false, error: `producer must be a string, got ${typeof value}` }; + } + if (value.trim() === '') { + return { ok: false, error: 'producer is empty — either cite the call site or drop the field' }; + } + return { ok: true }; +} + +export interface ProducerEntry { + /** `/` — the ledger coordinate. */ + key: string; + status: string; + producer?: unknown; + evidenceScope?: unknown; +} + +export interface ProducerReport { + /** Malformed values — these FAIL the gate. */ + errors: string[]; + /** `live` entries carrying producer-side evidence. */ + withProducer: number; + /** `live` entries that do not — the #4837 worklist, never a failure. */ + withoutProducer: string[]; + /** Entries whose verification scope is declared, by scope. */ + byScope: Record; + /** Classified entries with no declared scope — the #4895 worklist. */ + unscoped: number; +} + +/** + * Fold ledger entries into the producer/scope report. Pure, for the same reason + * `verification.mts` and `drill.mts` are: the tree is nearly all-defaults today, + * so a green run proves nothing about whether the checks can fire. + */ +export function buildProducerReport(entries: ProducerEntry[]): ProducerReport { + const report: ProducerReport = { + errors: [], + withProducer: 0, + withoutProducer: [], + byScope: {}, + unscoped: 0, + }; + + for (const entry of entries) { + if (entry.producer !== undefined) { + const parsed = parseProducer(entry.producer); + if (!parsed.ok) report.errors.push(`${entry.key} → ${parsed.error}`); + else if (entry.status === 'live') report.withProducer++; + } else if (entry.status === 'live') { + report.withoutProducer.push(entry.key); + } + + if (entry.evidenceScope !== undefined) { + const parsed = parseEvidenceScope(entry.evidenceScope); + if (!parsed.ok) report.errors.push(`${entry.key} → ${parsed.error}`); + else report.byScope[String(entry.evidenceScope)] = (report.byScope[String(entry.evidenceScope)] || 0) + 1; + } else { + report.unscoped++; + } + } + + report.withoutProducer.sort(); + return report; +} diff --git a/packages/spec/scripts/liveness/producer.test.ts b/packages/spec/scripts/liveness/producer.test.ts new file mode 100644 index 0000000000..fc6d5212f1 --- /dev/null +++ b/packages/spec/scripts/liveness/producer.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Unit tests for the producer / evidence-scope fold. Pure and tested for the +// same reason `orphans.mts` and `drill.mts` are: on the shipped ledgers these +// checks are almost entirely quiet, so a green gate proves nothing about whether +// they CAN fire. Every assertion below drives a failing direction. + +import { describe, it, expect } from 'vitest'; +import { + EVIDENCE_SCOPES, + buildProducerReport, + parseEvidenceScope, + parseProducer, +} from './producer.mts'; + +describe('parseEvidenceScope', () => { + it('accepts the vocabulary', () => { + for (const scope of EVIDENCE_SCOPES) expect(parseEvidenceScope(scope).ok).toBe(true); + }); + + it('rejects a value outside the vocabulary', () => { + const result = parseEvidenceScope('both-repos'); + expect(result.ok).toBe(false); + // The message must name the legal values: the field exists to be filled in + // by whoever just re-verified an entry, not to send them reading source. + expect(result.ok === false && result.error).toContain('in-repo | cross-repo'); + }); + + it('rejects a non-string', () => { + expect(parseEvidenceScope(true).ok).toBe(false); + }); +}); + +describe('parseProducer', () => { + it('accepts a cited call site', () => { + expect(parseProducer('packages/metadata-protocol/src/seed-loader.ts:143 (resolveEnv)').ok).toBe(true); + }); + + it('rejects an empty string — a field present but saying nothing is worse than absent', () => { + expect(parseProducer(' ').ok).toBe(false); + expect(parseProducer(42).ok).toBe(false); + }); +}); + +describe('buildProducerReport', () => { + it('counts live entries with and without producer-side evidence', () => { + const report = buildProducerReport([ + { key: 'seed/env', status: 'live', producer: 'seed-loader.ts:143' }, + { key: 'seed/mode', status: 'live' }, + // A dead entry has nothing to produce — it must not land on the worklist. + { key: 'job/label', status: 'dead' }, + ]); + expect(report.withProducer).toBe(1); + expect(report.withoutProducer).toEqual(['seed/mode']); + expect(report.errors).toEqual([]); + }); + + it('fails a malformed value while leaving a merely absent one alone', () => { + const report = buildProducerReport([ + { key: 'a/x', status: 'live', evidenceScope: 'everywhere' }, + { key: 'a/y', status: 'live', producer: '' }, + { key: 'a/z', status: 'live' }, + ]); + expect(report.errors).toHaveLength(2); + expect(report.errors[0]).toContain('a/x'); + expect(report.errors[1]).toContain('a/y'); + // `a/z` declares neither field: a worklist row, never an error. + expect(report.withoutProducer).toContain('a/z'); + // Two, not three: `a/x` DID declare a scope, it just declared an illegal + // one. It is already failing the gate, so counting it on the "nobody has + // looked" worklist as well would double-report one entry as two problems. + expect(report.unscoped).toBe(2); + }); + + it('tallies declared scopes so the coverage of the ledger is a number, not an impression', () => { + const report = buildProducerReport([ + { key: 'app/homePageId', status: 'dead', evidenceScope: 'cross-repo' }, + { key: 'book/groups.translations', status: 'dead', evidenceScope: 'cross-repo' }, + { key: 'seed/env', status: 'live', evidenceScope: 'in-repo', producer: 'x.ts:1' }, + ]); + expect(report.byScope).toEqual({ 'cross-repo': 2, 'in-repo': 1 }); + expect(report.unscoped).toBe(0); + }); +}); diff --git a/scripts/check-stack-collection-maps.mjs b/scripts/check-stack-collection-maps.mjs new file mode 100644 index 0000000000..f2fbda7938 --- /dev/null +++ b/scripts/check-stack-collection-maps.mjs @@ -0,0 +1,832 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// check-stack-collection-maps -- every enumeration of the stack-collection set +// must be ANSWERABLE to `ObjectStackDefinitionSchema` (#6242). +// +// node scripts/check-stack-collection-maps.mjs # run the gate +// node scripts/check-stack-collection-maps.mjs --list # print every reconciliation +// node scripts/check-stack-collection-maps.mjs --self-test # verify the checker itself +// +// ## The hole this closes (#6242) +// +// `ObjectStackDefinitionSchema` (`packages/spec/src/stack.zod.ts`) decides which +// collections a stack may declare. SEVEN other places re-enumerate that same set +// by hand -- eight enumerations in all, because ObjectQL declares its list twice: +// the map-format field list, the plural->singular map, the artifact category +// enum, the ObjectQL registration loops (x2), the artifact-ingest field map, the +// runtime's app-payload probe and the showcase coverage manifest -- and until +// this gate NOTHING compared any of them to the schema or to each other. +// They drifted independently and invisibly: +// +// - `PLURAL_TO_SINGULAR` carries `ragPipelines`, which the schema does not +// declare. +// - `metadataArrayKeys` still lists `workflows` / `approvals` / `roles` / +// `profiles` / `policies` -- kinds retired across ADR-0019, ADR-0020, +// ADR-0088 and ADR-0090. +// - `ARTIFACT_FIELD_TO_TYPE` mapped the SEED collection (`data:`) onto the +// ANALYTICS kind name (`dataset`) -- the exact collision the registry entry +// warns about in prose -- and omits five live collections. +// - `MetadataCategoryEnum` lists retired `triggers`/`workflows` and omits most +// of the live set. +// +// Every row above looks like a one-line typo in isolation, and each HAS been +// fixed one line at a time before (`docs` in `ARTIFACT_FIELD_TO_TYPE`, `roles` +// -> `positions` in the same map, `capabilities` in `metadataArrayKeys`), each +// carrying a "this key was missing and it silently dropped X" comment today. The +// cause is structural: `KIND_COVERAGE` is answerable to the metadata-type +// registry and fails CI when a kind is added without an entry, and the liveness +// ledger is answerable to the same registry. The collection-key maps had no such +// gate, so the same defect kept being re-filed one instance at a time. +// +// ## What "answerable" means here +// +// DERIVING all seven from the schema is not possible today: they disagree on +// purpose as often as by accident (`views` has no `name`, `data` seeds key by +// `object`, `translations` is a record, an artifact category is a directory +// layout rather than a registration). So each site is PINNED instead -- the gate +// reconciles it against the schema in BOTH directions and every deviation must +// be a `WAIVERS` row carrying a reason. Same shape as the liveness ledger's +// undrilled baseline: a deviation that is deliberate is cheap to record, and one +// that is drift has nowhere to hide. +// +// The waiver list is a RATCHET. A waiver that no longer applies (the key came +// back, or the drift was fixed) FAILS, exactly like a stale ledger row -- an +// overstated debt misleads as much as an unrecorded one. + +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..'); + +// ─────────────────────────────────────────────────────────────────────────── +// Extraction -- pure, over source text +// ─────────────────────────────────────────────────────────────────────────── +// +// These read SOURCE, not a build output, deliberately: this gate runs in the +// lint job, which does not build the workspace, and four of the seven sites live +// in packages `@objectstack/spec` must not import. Every extractor returns +// `null` when its anchor is gone, and the caller turns that into a FAILURE -- a +// rename that silently emptied a site would otherwise read as "no drift", which +// is the same silent-no-op shape this gate exists to catch. + +/** + * Slice the balanced `{...}` / `[...]` body that follows `anchor` in `source` + * (the anchor's last character must be the opening bracket). Returns `null` when + * the anchor is absent. `from` lets a caller walk repeated occurrences of one + * anchor -- ObjectQL declares `metadataArrayKeys` twice. + */ +export function sliceBody(source, anchor, from = 0) { + const at = source.indexOf(anchor, from); + if (at === -1) return null; + const mask = maskLiterals(source); + const openAt = at + anchor.length - 1; + const open = source[openAt]; + const close = open === '{' ? '}' : ']'; + let depth = 0; + for (let i = openAt; i < source.length; i++) { + const ch = mask[i]; + if (ch === open) depth++; + else if (ch === close) { + depth--; + if (depth === 0) return { body: source.slice(openAt + 1, i), end: i }; + } + } + return null; +} + +/** + * Blank out everything a bracket counter must not read: comment bodies, string + * and template contents, and regex literals. Returns a string of the SAME LENGTH + * as the input, so every index still addresses the original source — callers + * count structure on the mask and slice text from the original. + * + * Length-preserving masking rather than a `strip`: the first draft of this gate + * stripped comments and counted brackets over the rest, and one unbalanced paren + * inside PROSE — `.describe('Screen Flows (ADR-0019)')` — closed the + * `ObjectStackDefinitionSchema` literal 14 collections early. The gate then + * reconciled all seven sites against a truncated source of truth and reported + * 114 deviations, every one of them its own. A parser that fails toward "less + * schema" makes every consumer look wrong, which is the loudest possible way to + * be useless. + * + * String DELIMITERS survive (their contents do not), so a quoted object key and + * an array of string literals are both still locatable by index. + */ +export function maskLiterals(source) { + const out = source.split(''); + const blank = (from, to, keepDelimiters = false) => { + for (let i = from; i < to && i < out.length; i++) { + if (keepDelimiters && (i === from || i === to - 1)) continue; + if (out[i] !== '\n') out[i] = ' '; + } + }; + // A `/` opens a regex literal only where a value may start; anywhere else it + // is division, and treating `a / b` as a regex would swallow the rest of the + // file. + const REGEX_PRECEDER = /[(,=:[!&|?{};+\-*%<>~^]$/; + + let i = 0; + while (i < source.length) { + const ch = source[i]; + if (ch === '/' && source[i + 1] === '/') { + const nl = source.indexOf('\n', i); + const stop = nl === -1 ? source.length : nl; + blank(i, stop); + i = stop; + continue; + } + if (ch === '/' && source[i + 1] === '*') { + const end = source.indexOf('*/', i + 2); + const stop = end === -1 ? source.length : end + 2; + blank(i, stop); + i = stop; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + let j = i + 1; + while (j < source.length) { + if (source[j] === '\\') { j += 2; continue; } + if (source[j] === ch) break; + j++; + } + blank(i, Math.min(j + 1, source.length), true); + i = j + 1; + continue; + } + if (ch === '/') { + const before = source.slice(0, i).replace(/\s+$/, ''); + if (before === '' || REGEX_PRECEDER.test(before)) { + let j = i + 1; + let inClass = false; + while (j < source.length && source[j] !== '\n') { + if (source[j] === '\\') { j += 2; continue; } + if (source[j] === '[') inClass = true; + else if (source[j] === ']') inClass = false; + else if (source[j] === '/' && !inClass) break; + j++; + } + if (source[j] === '/') { + blank(i, j + 1); + i = j + 1; + continue; + } + } + } + i++; + } + return out.join(''); +} + +/** + * Top-level keys of an object-literal body, each with its value's source text. + * Depth-aware: a nested literal never contributes its own keys. + */ +export function objectEntries(body) { + const mask = maskLiterals(body); + const out = []; + let depth = 0; + let i = 0; + while (i < body.length) { + const ch = mask[i]; + if (ch === '{' || ch === '[' || ch === '(') { depth++; i++; continue; } + if (ch === '}' || ch === ']' || ch === ')') { depth--; i++; continue; } + if (depth === 0 && (i === 0 || /[\s,]/.test(mask[i - 1]))) { + // Match the key on the MASK, never on the original: a comment body is + // blanked there, and `/** ObjectQL: Data Layer */` reads as a perfectly + // good `key:` in the original text. That mis-read cost the walk every + // collection declared under a section banner — it consumed the fake entry + // and skipped forward past the real one. + const window = mask.slice(i, i + 80); + const bare = /^([A-Za-z_$][\w$]*)\s*:/.exec(window); + const quoted = /^(['"])\s*\1\s*:/.test(window) + ? /^(['"])([A-Za-z_$][\w$]*)\1\s*:/.exec(body.slice(i, i + 80)) + : null; + const m = bare ? { 0: bare[0], key: bare[1] } : quoted ? { 0: quoted[0], key: quoted[2] } : null; + if (m) { + const valueStart = i + m[0].length; + let j = valueStart; + let d = 0; + while (j < body.length) { + const c = mask[j]; + if (c === '{' || c === '[' || c === '(') d++; + else if (c === '}' || c === ']' || c === ')') d--; + else if (c === ',' && d === 0) break; + j++; + } + out.push({ key: m.key, value: body.slice(valueStart, j).trim() }); + i = j; + continue; + } + } + i++; + } + return out; +} + +/** String literals at depth 0 of an array-literal body. */ +export function stringArrayItems(body) { + const mask = maskLiterals(body); + const out = []; + let depth = 0; + for (let i = 0; i < body.length; i++) { + const ch = mask[i]; + if (ch === '{' || ch === '[' || ch === '(') { depth++; continue; } + if (ch === '}' || ch === ']' || ch === ')') { depth--; continue; } + if (depth === 0 && (ch === "'" || ch === '"')) { + const end = mask.indexOf(ch, i + 1); + if (end === -1) break; + out.push(body.slice(i + 1, end)); + i = end; + } + } + return out; +} + +/** + * The stack-collection set: top-level `ObjectStackDefinitionSchema` keys whose + * value is `z.array(Schema)`. + * + * That shape is the discriminator, and it is what keeps this mechanical instead + * of a second hand-maintained list: `plugins` (`z.array(z.unknown())`), + * `requires` / `tiers` (`z.array(z.string())`) and `devPlugins` (a union) are + * arrays but not metadata collections, and no site is expected to carry them. + */ +export function stackCollections(stackSource) { + const sliced = sliceBody( + stackSource, + 'export const ObjectStackDefinitionSchema = lazySchema(() => z.object({', + ); + if (!sliced) return null; + return objectEntries(sliced.body) + .filter(({ value }) => /^z\.array\(\s*[A-Za-z_$][\w$]*Schema\s*\)/.test(value)) + .map(({ key }) => key); +} + +// ─────────────────────────────────────────────────────────────────────────── +// Reconciliation -- pure +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Reconcile one site against the schema set. + * `extra` = enumerated here, not declared by the schema (retired or invented). + * `missing` = declared by the schema, not enumerated here. + */ +export function reconcileSite(schemaKeys, siteKeys) { + const schema = new Set(schemaKeys); + const site = new Set(siteKeys); + return { + extra: siteKeys.filter((k) => !schema.has(k)), + missing: schemaKeys.filter((k) => !site.has(k)), + }; +} + +/** + * Apply a site's waiver rows to its reconciliation. Returns the deviations that + * are NOT waived, plus the waivers that no longer apply (the ratchet -- a stale + * waiver is drift in the opposite direction). + */ +export function applyWaivers(recon, waivers) { + const waived = (direction) => + new Set(waivers.filter((w) => w.direction === direction).flatMap((w) => w.keys)); + const waivedExtra = waived('extra'); + const waivedMissing = waived('missing'); + const extraSet = new Set(recon.extra); + const missingSet = new Set(recon.missing); + return { + extra: recon.extra.filter((k) => !waivedExtra.has(k)), + missing: recon.missing.filter((k) => !waivedMissing.has(k)), + staleWaivers: [ + ...[...waivedExtra] + .filter((k) => !extraSet.has(k)) + .map((k) => `${k} — waived as an EXTRA key, but the site no longer enumerates it`), + ...[...waivedMissing] + .filter((k) => !missingSet.has(k)) + .map((k) => `${k} — waived as MISSING, but the site now enumerates it`), + ], + }; +} + +// ─────────────────────────────────────────────────────────────────────────── +// The seven sites, and the waiver each deviation must carry +// ─────────────────────────────────────────────────────────────────────────── +// +// A waiver row is `{ direction, keys, reason }`. `direction: 'extra'` waives a +// key the site enumerates and the schema does not; `direction: 'missing'` waives +// a schema collection the site deliberately (or, for recorded drift, knowingly) +// omits. The reason is the whole value of the row -- "these are the deliberate +// omissions" as a bare list is exactly the unfalsifiable claim this gate +// replaces. +// +// ⛔ A waiver is NOT permission. Rows that record DRIFT rather than design name +// the issue that tracks removing them; when that issue lands, the row must go or +// the ratchet fails. + +const SITES = [ + { + id: 'MAP_SUPPORTED_FIELDS', + file: 'packages/spec/src/shared/metadata-collection.zod.ts', + what: 'collections whose map/record authoring form is normalized into an array', + extract: (src) => { + const b = sliceBody(src, 'export const MAP_SUPPORTED_FIELDS = ['); + return b && stringArrayItems(b.body); + }, + waivers: [ + { + direction: 'missing', + keys: ['views', 'objectExtensions', 'data', 'translations'], + reason: + 'the four SHAPE exclusions the declaration already documents, pinned here so the prose is checked: ' + + 'ViewSchema has no `name` (its identity is the target object), ObjectExtensionSchema keys by ' + + '`extend`, SeedSchema keys by `object`, and TranslationBundleSchema is itself a record.', + }, + { + direction: 'missing', + keys: ['datasourceMapping', 'capabilities', 'docs', 'books', 'tools', 'skills'], + reason: + 'no map form has ever been offered for these. `datasourceMapping` is a rule LIST whose precedence is ' + + 'positional, so a map would lose the ordering that decides which rule wins; the other five are ' + + 'array-only authoring surfaces. Offering a map form for any of them WIDENS what parses — an ' + + 'acceptance change, which belongs to its own reviewed diff rather than to this gate.', + }, + ], + }, + { + id: 'PLURAL_TO_SINGULAR', + file: 'packages/spec/src/shared/metadata-collection.zod.ts', + what: 'plural stack key -> singular metadata type name', + extract: (src) => { + const b = sliceBody(src, 'export const PLURAL_TO_SINGULAR: Record = {'); + return b && objectEntries(b.body).map((e) => e.key); + }, + waivers: [ + { + direction: 'extra', + keys: ['ragPipelines'], + reason: + 'DRIFT, recorded not blessed (#6242 row 2). `ragPipelines` is not declared by ' + + 'ObjectStackDefinitionSchema, so this mapping cannot be reached from a parsed stack: ' + + '`pluralToSingular` is called with keys that came OFF a stack. Inert, and removing it is a ' + + 'behavioural change to a published spec export — its own diff, tracked in #6242.', + }, + { + direction: 'missing', + keys: ['datasourceMapping', 'translations', 'objectExtensions', 'data'], + reason: + 'there is no singular metadata-type name to map to. `objectExtensions` merges into its target ' + + 'object rather than registering as a type, `datasourceMapping` is stack-level routing ' + + 'configuration, `translations` is a bundle record consumed by the i18n resolvers, and `data` ' + + 'seeds are applied by SeedLoaderService. `pluralToSingular` returns its input unchanged for all ' + + 'four, which is the correct answer rather than a gap.', + }, + ], + }, + { + id: 'MetadataCategoryEnum', + file: 'packages/spec/src/kernel/package-artifact.zod.ts', + what: 'metadata categories an artifact may carry (one subdirectory each)', + extract: (src) => { + const b = sliceBody(src, 'export const MetadataCategoryEnum = z.enum(['); + return b && stringArrayItems(b.body); + }, + waivers: [ + { + direction: 'extra', + keys: ['triggers', 'workflows'], + reason: + 'DRIFT, and NOT removable from here: this enum is an ACCEPTANCE face — dropping a member changes ' + + 'what a published artifact may declare, a protocol-breaking change owing an ADR-0087 conversion. ' + + 'Recorded so the enum stops reading as an answerable enumeration (#6242 row 5).', + }, + { + direction: 'missing', + keys: [ + 'datasources', 'datasourceMapping', 'objectExtensions', 'apps', 'jobs', 'emailTemplates', + 'docs', 'books', 'positions', 'capabilities', 'sharingRules', 'webhooks', 'tools', 'skills', + 'hooks', 'mappings', 'analyticsCubes', 'connectors', 'data', + ], + reason: + 'DRIFT in the opposite direction, under the same acceptance constraint: ADDING a member widens ' + + 'what an artifact may declare and presumes a packaging layout (one subdirectory per category) ' + + 'that does not exist for these. The omissions are inert today because this enum is not the ' + + 'artifact ingest path — `ARTIFACT_FIELD_TO_TYPE` below is — but 19 of 32 collections absent is ' + + 'the measurement that says so out loud (#6242 row 5).', + }, + ], + }, + { + id: 'metadataArrayKeys (manifest)', + file: 'packages/objectql/src/engine.ts', + what: 'collections the engine registers from a manifest (the ADR-0010 provenance seam)', + extract: (src) => { + const b = sliceBody(src, 'const metadataArrayKeys = ['); + return b && stringArrayItems(b.body); + }, + waivers: [ + { + direction: 'extra', + keys: ['workflows', 'approvals', 'roles', 'profiles', 'policies', 'ragPipelines'], + reason: + 'DRIFT — six kinds retired across ADR-0019 / ADR-0020 / ADR-0088 / ADR-0090 that this loop still ' + + 'iterates. Inert: `(manifest as any)[key]` is `undefined` for every one, because the schema ' + + 'rejects the keys long before the loop runs. Removing them is an `engine-core` source change ' + + '(#6242 row 3) and rides that lane, not this gate.', + }, + { + direction: 'missing', + keys: ['objects', 'objectExtensions', 'apps', 'translations', 'datasourceMapping', 'datasources', 'data'], + reason: + 'registered by a DIFFERENT seam rather than dropped: objects and objectExtensions go through the ' + + 'object-merge path (an extension overlays its target before registration), `apps` is registered ' + + 'by AppPlugin, `translations` and `datasourceMapping` are stack configuration, `datasources` are ' + + 'consumed as CONNECTIONS (driver handles built at boot), and `data` seeds are applied by ' + + 'SeedLoaderService. This row is why the gate reconciles both directions with reasons instead of ' + + 'demanding equality.', + }, + { + direction: 'missing', + keys: ['positions'], + reason: + 'ADR-0090 D3 positions reach the registry through the security bootstrap, which reads them off the ' + + 'stack directly; the loop\'s sibling `permissions` entry is what makes the absence look like a gap.', + }, + ], + }, + { + id: 'metadataArrayKeys (nested plugin)', + file: 'packages/objectql/src/engine.ts', + what: 'the same registration loop one level down, for a nested plugin', + extract: (src) => { + const first = sliceBody(src, 'const metadataArrayKeys = ['); + if (!first) return null; + const b = sliceBody(src, 'const metadataArrayKeys = [', first.end); + return b && stringArrayItems(b.body); + }, + waivers: [ + { + direction: 'extra', + keys: ['workflows', 'approvals', 'roles', 'profiles', 'policies', 'ragPipelines'], + reason: 'the same six retired kinds as the manifest loop above — the two copies drift in lockstep (#6242 row 3).', + }, + { + direction: 'missing', + keys: ['objects', 'objectExtensions', 'apps', 'translations', 'datasourceMapping', 'datasources', 'data', 'positions'], + reason: 'same seam split as the manifest loop above.', + }, + { + direction: 'missing', + keys: ['jobs', 'emailTemplates', 'tools', 'skills'], + reason: + 'DIVERGENCE BETWEEN THE TWO COPIES — the most valuable row in this table, and one no reading of ' + + 'either list alone produces. These four ARE registered from a manifest and are NOT registered ' + + 'from a nested plugin, so a package that ships them from a nested plugin registers nothing and ' + + 'stamps no ADR-0010 provenance. `capabilities` was added to this copy for exactly that reason ' + + '(#5870) after the hole was found on the manifest copy; nobody then asked what else the two ' + + 'lists disagreed about. Recorded rather than fixed here: closing it changes what a nested plugin ' + + 'registers at boot — `engine-core` behaviour owing its own verification (#6242 row 3).', + }, + ], + }, + { + id: 'ARTIFACT_FIELD_TO_TYPE', + file: 'packages/metadata/src/plugin.ts', + what: 'compiled-artifact field -> metadata type registered into MetadataManager', + extract: (src) => { + const b = sliceBody(src, 'const ARTIFACT_FIELD_TO_TYPE: Record = {'); + return b && objectEntries(b.body).map((e) => e.key); + }, + waivers: [ + { + direction: 'extra', + keys: ['workflows', 'policies', 'ragPipelines'], + reason: + 'DRIFT — retired kinds still mapped, inert for the same reason as the ObjectQL loops: a parsed ' + + 'artifact cannot carry the fields (#6242 row 4).', + }, + { + direction: 'missing', + keys: ['data'], + reason: + 'DELIBERATE, and the resolution of #6242 row 4(a). `data:` is the SEED collection, and this map ' + + 'used to send it to `dataset` — the ADR-0021 analytics kind, the exact collision ' + + '`metadata-plugin.zod.ts` warns about in prose. The entry was provably inert (SeedSchema declares ' + + 'no `name`; the ingest loop skips nameless items) so it registered nothing, but it was aimed at ' + + 'the wrong kind and would have begun mis-registering the day either side moved. REMOVED in the ' + + 'change that added this gate, rather than repointed at `seed`: seeds are applied by ' + + 'SeedLoaderService off the bundle, never registered as metadata items, so a `seed` mapping would ' + + 'be new behaviour needing its own verification.', + }, + { + direction: 'missing', + keys: ['datasets', 'jobs', 'datasources', 'translations', 'capabilities'], + reason: + 'DRIFT with a real, bounded consequence — #6242 row 4(b). Four of the five are consumed ' + + 'functionally by AppPlugin straight off the bundle, so boot is not broken; what they never do is ' + + 'register as METADATA ITEMS, so under `bootstrap: \'artifact-only\'` (edge / serverless / ' + + 'immutable image) `GET /meta/job`, `/meta/translation`, `/meta/datasource` and `/meta/dataset` ' + + 'answer empty for a package that ships them. Adding them changes what a sealed runtime serves ' + + 'and must be measured on a real artifact-only boot first — the filing card says so, and this ' + + 'gate does not smuggle it in.', + }, + { + direction: 'missing', + keys: ['datasourceMapping'], + reason: 'stack-level routing configuration, never a registry item.', + }, + ], + }, + { + id: 'APP_CATEGORY_KEYS', + file: 'packages/runtime/src/app-plugin.ts', + what: 'keys whose presence means "this bundle was supposed to register an app"', + extract: (src) => { + const b = sliceBody(src, 'const APP_CATEGORY_KEYS = ['); + return b && stringArrayItems(b.body); + }, + waivers: [ + { + direction: 'extra', + keys: ['workflows', 'triggers', 'ragPipelines'], + reason: + 'DRIFT — three retired kinds in a probe that can never see them. This is the SEVENTH enumeration ' + + 'of the set, found by writing this gate rather than by hand: the card that catalogued the drift ' + + 'listed six sites and did not have this one.', + }, + { + direction: 'missing', + keys: [ + 'objectExtensions', 'datasourceMapping', 'datasources', 'themes', 'jobs', 'apis', 'webhooks', + 'hooks', 'mappings', 'analyticsCubes', 'connectors', 'capabilities', 'datasets', + ], + reason: + 'DELIBERATE, and load-bearing. This is a HEURISTIC, not a registration list: it answers "did the ' + + 'author mean to ship an app here?" for a bundle with no app id, and its failure directions are ' + + 'asymmetric — a false positive throws on a brand-new empty environment, a false negative only ' + + 'degrades to a no-op plugin. So it lists the user-visible app payload, not every legal ' + + 'collection. Pinned rather than completed, so a NEW collection has to be considered here once.', + }, + ], + }, + { + id: 'STACK_COLLECTION_COVERAGE', + file: 'examples/app-showcase/src/coverage.ts', + what: 'stack collections the showcase demonstrates or waives (the non-registry-kind half)', + extract: (src) => { + const b = sliceBody(src, 'export const STACK_COLLECTION_COVERAGE: Record = {'); + return b && objectEntries(b.body).map((e) => e.key); + }, + // The sibling `KIND_COVERAGE` manifest covers every collection that IS a + // registry kind, and its own test ratchets it against + // DEFAULT_METADATA_TYPE_REGISTRY. Reconciling this half against the whole + // schema set would report those as missing, so the gate subtracts them + // first — this manifest is answerable only for the remainder. + kindCovered: true, + waivers: [ + { + direction: 'missing', + keys: ['datasourceMapping', 'translations', 'themes', 'data', 'sharingRules', 'webhooks'], + reason: + 'NOT DEMONSTRATED, and visible for the first time. This manifest tracked 3 of the non-kind ' + + 'collections and — unlike its ratcheted sibling `KIND_COVERAGE` — was answerable to nothing ' + + '(#6242 row 6). Demonstrating a collection is example-app work with its own review; this row is ' + + 'the ratchet that stops the list reading as complete in the meantime.', + }, + ], + }, +]; + +/** + * Collections already covered by the showcase's ratcheted `KIND_COVERAGE` + * manifest, resolved through the spec's own plural->singular map so the two + * halves are compared in one vocabulary. + */ +export function kindCoveredCollections(coverageSource, pluralToSingular, schemaKeys) { + const b = sliceBody(coverageSource, 'export const KIND_COVERAGE: Record = {'); + if (!b) return null; + const kinds = new Set(objectEntries(b.body).map((e) => e.key)); + return schemaKeys.filter((k) => kinds.has(pluralToSingular[k] ?? k)); +} + +// ─────────────────────────────────────────────────────────────────────────── +// Runner +// ─────────────────────────────────────────────────────────────────────────── + +function readSource(rel) { + const abs = join(repoRoot, rel); + return existsSync(abs) ? readFileSync(abs, 'utf8') : null; +} + +function pluralToSingularMap(specSource) { + const b = sliceBody(specSource, 'export const PLURAL_TO_SINGULAR: Record = {'); + if (!b) return null; + const out = {}; + for (const { key, value } of objectEntries(b.body)) { + const m = /^['"]([\w-]+)['"]/.exec(value); + if (m) out[key] = m[1]; + } + return out; +} + +function run({ list = false } = {}) { + const failures = []; + const stackSource = readSource('packages/spec/src/stack.zod.ts'); + if (!stackSource) { + console.error('✗ packages/spec/src/stack.zod.ts not found — the source of truth is gone, not clean.'); + return 1; + } + const schemaKeys = stackCollections(stackSource); + if (!schemaKeys || schemaKeys.length === 0) { + console.error( + '✗ could not extract the stack-collection set from ObjectStackDefinitionSchema.\n' + + ' The declaration was renamed or reshaped. Fix the anchor in this script — an empty set would\n' + + ' make every site below reconcile perfectly against nothing.', + ); + return 1; + } + + const collectionSource = readSource('packages/spec/src/shared/metadata-collection.zod.ts'); + const singular = collectionSource ? pluralToSingularMap(collectionSource) : null; + + console.log( + `stack-collection set: ${schemaKeys.length} collections declared by ObjectStackDefinitionSchema\n`, + ); + + for (const site of SITES) { + const src = readSource(site.file); + if (!src) { + failures.push(`${site.id} — ${site.file} does not exist`); + continue; + } + const siteKeys = site.extract(src); + if (!siteKeys || siteKeys.length === 0) { + failures.push( + `${site.id} — could not extract the enumeration from ${site.file}.\n` + + ` The symbol was renamed, moved or reshaped. An unextractable site is a FAILURE, never a pass:\n` + + ` an empty list reconciles against everything.`, + ); + continue; + } + + let expected = schemaKeys; + if (site.kindCovered) { + const covered = singular ? kindCoveredCollections(src, singular, schemaKeys) : null; + if (!covered) { + failures.push(`${site.id} — could not resolve the sibling KIND_COVERAGE manifest it splits with`); + continue; + } + const coveredSet = new Set(covered); + expected = schemaKeys.filter((k) => !coveredSet.has(k)); + } + + const recon = reconcileSite(expected, siteKeys); + const result = applyWaivers(recon, site.waivers ?? []); + + if (list) { + console.log(`── ${site.id} (${site.file})`); + console.log(` ${site.what}`); + console.log(` ${siteKeys.length} enumerated · ${recon.extra.length} extra · ${recon.missing.length} missing · ${(site.waivers ?? []).length} waiver row(s)`); + if (recon.extra.length) console.log(` extra: ${recon.extra.join(', ')}`); + if (recon.missing.length) console.log(` missing: ${recon.missing.join(', ')}`); + console.log(''); + } + + for (const k of result.extra) { + failures.push( + `${site.id} enumerates \`${k}\`, which ObjectStackDefinitionSchema does not declare (${site.file}).\n` + + ` Either remove it, or add a WAIVERS row with the reason it stays.`, + ); + } + for (const k of result.missing) { + failures.push( + `${site.id} omits \`${k}\`, which ObjectStackDefinitionSchema declares (${site.file}).\n` + + ` Either add it, or add a WAIVERS row with the reason it is deliberately absent.`, + ); + } + for (const stale of result.staleWaivers) { + failures.push( + `${site.id} carries a STALE waiver: ${stale}.\n` + + ` Delete the row — an overstated debt misleads as much as an unrecorded one.`, + ); + } + } + + if (failures.length) { + console.error(`✗ check-stack-collection-maps — ${failures.length} unreconciled deviation(s):\n`); + for (const f of failures) console.error(` • ${f}\n`); + console.error( + ' Every enumeration of the stack-collection set must be answerable to stack.zod.ts (#6242).\n' + + ' A deviation is legal only as a WAIVERS row carrying the reason, in scripts/check-stack-collection-maps.mjs.', + ); + return 1; + } + + const waiverRows = SITES.reduce((n, s) => n + (s.waivers ?? []).length, 0); + console.log( + `✓ check-stack-collection-maps: ${SITES.length} enumerations reconciled against ` + + `${schemaKeys.length} declared collections (${waiverRows} waiver rows, each with a reason).`, + ); + return 0; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Self-test -- the extractors and the ratchet, over synthetic sources +// ─────────────────────────────────────────────────────────────────────────── +// +// The gate is green on `main` by construction (the waiver table was written from +// its own output), so a green run proves nothing about whether it CAN fire. The +// assertions below drive both failure directions on synthetic input. + +function selfTest() { + const failures = []; + const eq = (label, actual, expected) => { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a !== e) failures.push(`${label}\n expected ${e}\n actual ${a}`); + }; + + const stack = ` +export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ + manifest: ManifestSchema.optional(), + objects: z.array(ObjectSchema).optional().describe('Business Objects'), + // a commented-out collection must NOT count: + // ghosts: z.array(GhostSchema).optional(), + plugins: z.array(z.unknown()).optional(), + requires: z.array(z.string()).optional(), + devPlugins: z.array(z.union([ManifestSchema, z.string()])).optional(), + api: z.object({ nested: z.array(NestedSchema) }).optional(), + data: z.array(SeedSchema).optional(), +})); +`; + eq('stackCollections() takes z.array(Schema) only', stackCollections(stack), ['objects', 'data']); + eq('stackCollections() returns null when the anchor is gone', stackCollections('export const Other = 1;'), null); + + eq( + 'stringArrayItems() ignores nested literals and comments', + stringArrayItems(`'a', /* 'x' */ 'b', ['c'], // 'd'\n 'e',`), + ['a', 'b', 'e'], + ); + eq( + 'objectEntries() reads top-level keys only', + objectEntries(`a: 'x', b: { c: 'y' }, 'd': 'z',`).map((e) => e.key), + ['a', 'b', 'd'], + ); + eq( + 'sliceBody() walks to the SECOND occurrence when asked', + (() => { + const src = `const k = ['a']; const k = ['b'];`; + const first = sliceBody(src, 'const k = ['); + return stringArrayItems(sliceBody(src, 'const k = [', first.end).body); + })(), + ['b'], + ); + + const recon = reconcileSite(['objects', 'apps'], ['objects', 'workflows']); + eq('reconcileSite() reports both directions', recon, { extra: ['workflows'], missing: ['apps'] }); + + eq( + 'applyWaivers() silences a waived deviation', + applyWaivers(recon, [ + { direction: 'extra', keys: ['workflows'], reason: 'retired' }, + { direction: 'missing', keys: ['apps'], reason: 'other seam' }, + ]), + { extra: [], missing: [], staleWaivers: [] }, + ); + eq( + 'applyWaivers() leaves an unwaived deviation to fail', + applyWaivers(recon, [{ direction: 'extra', keys: ['workflows'], reason: 'retired' }]).missing, + ['apps'], + ); + eq( + 'applyWaivers() fails a waiver that no longer applies (the ratchet)', + applyWaivers({ extra: [], missing: [] }, [{ direction: 'extra', keys: ['gone'], reason: 'x' }]).staleWaivers, + ['gone — waived as an EXTRA key, but the site no longer enumerates it'], + ); + + eq( + 'kindCoveredCollections() maps plural collections through the singular map', + kindCoveredCollections( + `export const KIND_COVERAGE: Record = { object: { status: 'demonstrated' }, api: { status: 'waived' } };`, + { objects: 'object', apis: 'api', themes: 'theme' }, + ['objects', 'apis', 'themes'], + ), + ['objects', 'apis'], + ); + + if (failures.length) { + console.error(`✗ check-stack-collection-maps --self-test — ${failures.length} failure(s)\n`); + for (const f of failures) console.error(` • ${f}\n`); + return 1; + } + console.log('✓ check-stack-collection-maps --self-test: 11 assertions over synthetic sources'); + return 0; +} + +// Run only when executed directly: the pure halves above are imported by the +// self-test and by anything else that wants to ask what a site enumerates. +if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) { + const argv = process.argv.slice(2); + if (argv.includes('--self-test')) process.exit(selfTest()); + process.exit(run({ list: argv.includes('--list') })); +}