Skip to content

test(cli): derive the build/validate gate roster from what the commands call, not from how a gate is spelled - #18675

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-18491-gate-parity-detector-naming-family
Sep 17, 2026
Merged

os-support-ai merged 3 commits into
mainfrom
claude/issue-18491-gate-parity-detector-naming-family

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes #18491

packages/cli/test/validate-build-gate-parity.test.ts holds one invariant: every
artifact-level gate is wired into both os build and os validate, or into neither —
"There is no third option — that is the whole point of the file." Its drift detector
extracted call sites by spelling (the two prefixes lint and validate followed by a
capital), so it saw 2 of the 47 bare-identifier call sites compile.ts has, and 2 of
39
in validate.ts. A gate named anything else was invisible, and the failure direction
is FALSE CLEAN: a gate wired into one command only passed, which is the file's entire
reason to exist.

Premises checked at the tree, not taken from the card

Premise Verdict Evidence
findNavGroupDiagnostics is really wired into both commands holds compile.ts:51 import, compile.ts:496 call; validate.ts:46 import, validate.ts:401 call
find* is the only invisible family FALSIFIED checkProtocolVersionGap — check* — is a second one, live on the tree: compile.ts:822, validate.ts:514, and in zero roster rows. It is a non-registry gate by this file's own definition: it resolves the @objectstack/spec installed in the APP's node_modules, which is the filesystem, not the stack
the roster held the gates it lists holds, but for a narrower reason than it reads the by-name it.each rows were held; the drift scan that was supposed to catch unlisted ones compared two 2-element sets that are identical in both commands, so it could not fail

Both lists, measured on objectstack-ai/objectstack at 62d830e54: what the two commands
actually call is 47 and 39 names; what the old detector could see is
lintUnknownStackKeys and lintUnknownAuthoringKeys. The gap is 45 and 37 names.

The choice: invert the direction, do not widen the pattern

Widening the regex to cover find* was the obvious move and the wrong stopping point —
two families were invisible at once, which is the evidence that widening moves the blind
spot to whatever the repo names a gate next rather than closing it.

So the scan now asks the opposite question. It extracts every bare-identifier call site
from comment- and string-blanked source and requires each one to land in exactly one of
three ledgers:

  • SHARED_NON_REGISTRY_GATES — hand-wired artifact-level gates, asserted present in BOTH commands;
  • BUILD_ONLY_GATES — gates os build may legitimately run alone, each with a reason;
  • NOT_A_GATE (new) — everything else, keyed by the reason it is not a gate.

It reads no names, so no naming choice defeats it, and a name nobody classified fails its
own PR. findNavGroupDiagnostics and checkProtocolVersionGap join the roster because
the scan demands a row for them
— not by hand.

Also closed, same file and same class

calls() matched raw source text, so a comment or a string writing a gate name with a
parenthesis after it satisfied the roster's "both commands run it" assertion for a command
that had stopped calling it. It now reads the blanked source. Pinned by
a comment or a string literal is not a wiring. The inline-conversion-notice assertion at
the foot of the file deliberately keeps reading RAW source — an inline copy of that
sentence IS a string literal, and blanking strings there would make that assertion pass on
exactly the file it exists to reject.

What the scan still cannot see — asserted absent, not written down and hoped for

the command sources use no call shape this scan cannot read refuses each shape that would
hide a gate, so introducing one is a RED rather than a silent return to the blind spot:
namespace imports, dynamic import(...), require(...), an import statement the parse
skipped (the consumed count is asserted equal to the statement count: 22 of 22 and 18 of
18), and a value import handed on rather than called — the shape formatUnknownAuthoringKey
has today.

Evidence: two ablations, on the committed tree, each restored by blob hash

Positive control (the card's). Delete the findNavGroupDiagnostics roster row; on-disk
occurrences 1 to 0, verified before the run:

LEG A1 new-detector exit=1 (expect 1)
AssertionError: compile.ts / validate.ts call 1 name(s) this file has not classified: findNavGroupDiagnostics.
      Tests  1 failed | 18 passed (19)

So the row is demanded by the detector, not hand-added.

The defect direction, two legs on one mutation. A find*-named gate wired into
compile.ts only (findArtifactSomethingNew, on-disk occurrences 0 to 1, verified), with
the detector from the base commit 99fcb4ac1 restored beside the new one:

LEG A2 OLD-detector exit=0 (expect 0 — the blind spot)
 Test Files  1 passed (1)
      Tests  13 passed (13)

LEG A2 NEW-detector exit=1 (expect 1)
AssertionError: compile.ts / validate.ts call 1 name(s) this file has not classified: findArtifactSomethingNew.
AssertionError: os build runs 1 gate(s) that os validate does not: findArtifactSomethingNew.
      Tests  2 failed | 18 passed (20)

Restore verified by blob hash, not by exit code: test 0d676ed9ef… and compile
32b23f5fc5… both match HEAD, git diff HEAD empty. The ablation script carried a
trap ... EXIT INT TERM restoring with git checkout HEAD -- path against an absolute
repo root.

Verification (all at 02f3bfadb)

  • pnpm --filter @objectstack/cli exec vitest run test/validate-build-gate-parity.test.ts — 20 passed (was 13).
  • pnpm --filter @objectstack/cli exec vitest run --project unit — 211 files / 3026 tests, 29 skipped. Two suites first failed packages/cli is not built (PREREQUISITE NOT MET, not a red); after pnpm --filter @objectstack/cli build both pass — published-subpath-console.pin and published-subpath-hook-body.pin, 29 tests. The integration tier is declared to CI: no path in this diff touches it, bin/, or a spawn entry point.
  • pnpm --filter @objectstack/cli typecheck — exit 0. pnpm --filter @objectstack/cli check:test-typecheck — OK, 3 files / 28 errors / 6 pinned signatures, unmoved.
  • pnpm --filter '@objectstack/cli^...' build — exit 0 (the dependency closure).
  • Derived gate families — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, reconciled with --ran: 47 derived, 45 run green, 2 NOT MEASURED, 0 UNRUN. The two are check:type-check-debt and check:dual-build-cjs-loads, both exit 3 = PREREQUISITE NOT MET: each needs a repo-wide build, and check:type-check-debt's own internal build was OOM-killed (exit 137) on this shared container. Declared to CI; neither is a finding and neither is a pass.
  • pnpm lint repo-wide (eslint . --no-inline-config) — exit 0. Run whole, so no narrowing to justify.

Clause-②: no

Three readings on the measured diff: no new exports (a test file; it exports nothing),
ERROR_CODE_LEDGER / StandardErrorCode untouched, no accept-set change (no schema
widened or narrowed; the roster is a test ledger, not an accept set). skip-changeset
measured rather than assumed: @objectstack/cli ships files: ["dist","README.md","CHANGELOG.md"],
test/ is not among them, a symbol unique to this diff (NOT_A_GATE) has 0 hits across
dist and bin after a build, and the positive control (findNavGroupDiagnostics, which
does ship) has 4. Nothing published moves.

Acceptance notes

To file (class (a)/(b)) — os build runs a per-package rule walk os validate does not.
compile.ts runs a SECOND runAuthoringRules('build', ...) pass over each entry of
artifactPackages(...) with packageBodyAsStack(...) resolution context, de-duplicated
against the union run; validate.ts has one runAuthoringRules('validate', ...) call and no
package walk. compile.ts's own comment says what survives that de-duplication is "exactly
the set the union could not see" — so os build reports findings os validate structurally
cannot, in the false-clean direction, one layer past the gate roster this PR repairs. Not
wired up here: wiring a real gate into the other door is a decision, not a test fix. It is
recorded in the NOT_A_GATE ledger next to the two names, so it is visible rather than
silent. Dedupe words: per-package authoring rules · artifactPackages ·
packageBodyAsStack · runAuthoringRules per package · os validate package walk.

noted, not filed — os lint hand-wires gates of its own (checkHookBodyLowering,
computeI18nCoverage, scoreMetadata, runMetadataEval) and is outside the parity
question by design: it emits no artifact. The closed-ledger mechanism added here would
extend to it unchanged if that ever becomes a parity question. Carrier: the next PR that
touches this file.

noted, not filed — local build-state only, no carrier: check:dts-closure went red
mid-verification naming driver-mongodb, plugin-reports and service-messaging as
missing .d.ts. Cause was the OOM-killed repo-wide build inside check:type-check-debt on
this shared container leaving three packages with JS and no declarations. Rebuilding the
three restored it (61 packages swept, 153/153 declaration files present). CI checks out
fresh, so there is nothing to carry.


Generated by Claude Code

…ds call, not from how a gate is spelled

`validate-build-gate-parity.test.ts` claims every artifact-level gate is wired
into both `os build` and `os validate` or into neither — "There is no third
option — that is the whole point of the file". Its drift detector extracted
`lintFoo(` / `validateFoo(` call sites, so it saw 2 of the 45 names `compile.ts`
calls and an entire naming family was invisible: a `find*`-named gate wired into
one command only passed, which is the false-clean direction the file exists to
stop.

Two such gates were live on the tree, in both commands and in no roster:
`findNavGroupDiagnostics` (since #14553) and `checkProtocolVersionGap` — the
second falsifies "`find*` is the only invisible family" and is why widening the
pattern would have moved the blind spot rather than closed it.

The scan is inverted instead. It now extracts every bare-identifier call site
from comment- and string-blanked source and requires each to land in exactly one
of three ledgers: the shared-gate roster, the build-only roster, or a new
`NOT_A_GATE` ledger that states why the name is not a gate. It reads no names,
so no naming choice defeats it. The two gates above join the roster because the
scan demands a row for them.

Also closed, same file and same class: `calls()` matched raw source text, so a
comment writing a gate name with a parenthesis satisfied the roster's "both
commands run it" assertion for a command that had stopped calling it.

What the scan still cannot see is asserted absent rather than written down:
namespace imports, dynamic imports, `require()`, an import statement the parse
skipped, and a value import that is handed on rather than called.

Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3
Co-authored-by: Claude <noreply@anthropic.com>
The two counts the docblocks cite were taken before the extractor's spread-call
and declaration-site fixes landed, so they read 45 where the scan now finds 47.
Corrected, and each now names the repository and commit it was measured on, as a
reading without its tree is not a reading.

Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3
Co-authored-by: Claude <noreply@anthropic.com>
@os-support-ai os-support-ai added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 17, 2026 — with Claude
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7f7b8557dfb459719e1eb473cd9ea5f10a7322b9 → packageMentionDocs.

@os-support-ai
os-support-ai marked this pull request as ready for review September 17, 2026 14:14
@os-support-ai
os-support-ai added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit 6dfa3ea Sep 17, 2026
40 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-18491-gate-parity-detector-naming-family branch September 17, 2026 14:38
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…er instead of carrying a second copy (objectstack-ai#18710)

Fixes objectstack-ai#18490

## What changed

`packages/cli/src/utils/nav-contribution-groups.ts` no longer carries
its own copy of the artifact package-id rule. `artifactPackagesOf` is
**deleted**; `artifactPackages` — the owner, in
`packages/cli/src/utils/artifact-packages.ts` — is imported and used as
`findNavGroupDiagnostics`' default package walk. This is the same move
`permission-set-name-collisions.ts` already made, for the reason that
module states in its own header.

`CompiledPackage` stays: it is a structural parameter type naming the
two facts this module reads, not a second spelling of the id rule.

## The half the card left open, measured before anything was edited

The card is explicit that importing the owner **is a decision, not a
merge**: the owner answers `''` for a package whose id keys are empty,
the deleted copy answered the positional spelling `packages[0]`. The
dispatch asked which is right for the nav path, and required it measured
rather than reasoned.

**M1 — the divergent input is REACHABLE.** `ManifestSchema` requires
`id` and `name` as strings and constrains neither to be non-empty, so `{
manifest: { id: '', name: '', … } }` parses green through the very
`normalizeStackInput` + `ObjectStackDefinitionSchema` chain both
commands run. It narrows the card's framing: **both** keys have to be
empty. With `id` absent the parse refuses (`packages.0.manifest.id:
expected string, received undefined`); with `name` absent, likewise.
With `id: 'ok'` and `name: ''` the two rules agreed already.

**M2 — behaviour does change, and only there.** On the reachable input
the two rules produce different diagnostics; on every other input,
byte-identical output (control legs: `id` ok + `name` empty, and both
ok).

| package identity | copy (deleted) | owner (imported) |
|:--|:--|:--|
| `id: ''`, `name: ''` | `packageId: 'packages[0]'` | `packageId: ''` |
| `id: 'com.example…'`, `name: ''` | `com.example…` | `com.example…`
(identical) |
| `id: 'com.example…'`, `name: 'Orders'` | `com.example…` |
`com.example…` (identical) |

**M3 — the runtime is the judge, and it answers `''`.**
`nav-contribution-groups.ts`' own header says the id it carries is "the
string the runtime registers a contribution under, so a command names a
package the same way the fold does". Asked of a real `ObjectQL`:
`registerApp` derives `manifest.id || manifest.name`, which has **no
positional fallback at all**, so the fold registers that package under
`''` and prints:

```
[Registry] [nav_contribution_group_missing] Package "" contributes 1 navigation item [nav_orders] into group "sales_grp" of app "multi_crm", …
```

The deleted copy made `os build` print `Package "packages[0]"` for that
same artifact. ⇒ **the owner's `''` is not merely different, it is the
one that matches the runtime**, and `packages[0]` is a name the runtime
cannot produce. The STOP-AND-REPORT condition in the dispatch is
therefore **not** triggered — the measurement came out in favour of the
direction triage settled, and it could have come out the other way: had
`registerApp` carried a positional fallback, or dropped a contribution
whose id is empty, the copy would have been the runtime-matching side.

**M2b — the id is carried and printed, never keyed on.** Two packages
that both resolve to `''` still produce **two** findings under both
rules; nothing on this path uses the id as a map key, a dedupe key, a
route segment or a sort key. Downstream it is spread into the `warnings`
array of both commands' JSON payloads, unkeyed.

**M4/M5 — the one robustness delta, and why it cannot be reached.** The
owner does not re-check entry shape (its header declares that
precondition). A `null` element of `packages[]` throws under the owner
where the copy returned a positional id. Every malformed element —
`null`, a string, a non-object `manifest`, a missing one — is refused by
`ArtifactPackageSchema` before either command's
`findNavGroupDiagnostics(result.data)` sees it; measured, all five
refused. The remaining shapes (`manifest` a string, `manifest` absent)
produce identical output under both rules anyway.

⚠️ **Two different claims, kept different**: this PR shows no *current*
shipped artifact with empty id keys — it does not look for one, and the
card says the blast radius is unmeasured. What it shows is that the
input is **accepted by the schema both commands run**, so the divergence
was reachable rather than latent-by-construction.

## Tests

`packages/cli/src/utils/nav-contribution-groups.package-id.test.ts` —
new, four pins:

1. the empty-`id`-and-`name` artifact **parses** (the floor: if a spec
change starts refusing it, this reds first and says the pins under it
now measure nothing);
2. **the build names that package exactly as the runtime fold does** —
build side from the shipped `findNavGroupDiagnostics`, runtime side from
a real `ObjectQL`, neither rule re-spelled, asserting the two
`packageId` strings and the two messages are equal;
3. and that shared name is **not** the deleted copy's positional
spelling (stated separately, because pin 2 would also pass if *both*
doors moved to `packages[0]`);
4. two empty-id packages still produce two findings.

**Why a separate file.** `new ObjectQL(` is a KERNEL signal in
`packages/cli/vitest-tiers.ts`, so a file carrying it is integration
tier *by derivation*. Measured: adding the pin to
`nav-contribution-groups.test.ts` moved that whole file — and its nine
existing objectstack-ai#14553 pins — out of the unit tier (unit 212 → 211, integration
47 → 48). Splitting confines the tier change to the cases that actually
boot a registry. The unit file now differs only by the import repoint
and two comments, and `unitTestFiles()` places it back in `unit`.

**Ablation** — the fix reverted to its pre-change blob, the new pins
re-run, then restored:

```
pre-mutation : fe0c8f3   (== HEAD blob)
post-mutation: d4b626b   (== the BASE blob)
  artifactPackagesOf occurrences: 0 -> 2
  'artifactPackages(parsed)'    : 1 -> 0
ablated run: 3 failed | 1 passed (4)
  AssertionError: expected 'packages[0]' to be ''
post-restore : fe0c8f3 == HEAD blob; git diff HEAD empty
```

The one pin that stays green under ablation is the reachability floor,
which reads the owner directly — the in-run control that the harness is
not simply broken.

## Verification, at `6a886b8a4`

| what | result |
|:--|:--|
| `pnpm lint` — **repo-wide**, `eslint . --no-inline-config`, full
population, no narrowing | exit 0 |
| `pnpm --filter @objectstack/cli typecheck` (incl.
`check:test-typecheck`) | exit 0 |
| `pnpm --filter @objectstack/cli exec vitest run --project unit` | 212
files, 3026 tests, exit 0 |
| `pnpm --filter @objectstack/cli exec vitest run --project integration`
| 48 files, 413 tests, exit 0 |
| `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`,
every family run, reconciled with `--ran` carrying each exit code | **60
derived, 60 run, 0 NOT-MEASURED, 0 UNRUN** |

The integration tier was run locally because this diff **adds** an
integration-tier file; it touches no existing one, and no spawn entry.

An earlier reconciliation at an intermediate commit recorded `exit 3`
for `check:dual-build-cjs-loads` and `check:i18n-coverage` (PREREQUISITE
NOT MET — unbuilt `dist`). Both were re-run at the final commit and
returned real verdicts (`104 published require entry point(s) … load`;
`13 config(s), 621 baselined untranslated string(s), none new`).

## Changeset

Clause-②: no

`patch` for `@objectstack/cli`. The declaration above is **measured, not
assumed**. Published bytes move: `files[]` is `["dist", …]` and
`artifactPackagesOf` appears in `dist/utils/nav-contribution-groups.js`
and its `.d.ts` (positive control `findNavGroupDiagnostics` also hits).
⇒ `skip-changeset` does not apply. The diff adds no key, arm, export or
registration; it removes one, and that removal reaches no consumer — the
package's `exports` map publishes `.`, `./console`, `./hook-body` and
`./package.json`, and none of the three entry `.d.ts` files names the
symbol.

## In-flight fence — verified rather than trusted

PR objectstack-ai#18675 (card objectstack-ai#18491) is the only in-flight claim whose roster names
`findNavGroupDiagnostics`, which is defined in the file edited here.
Read at its head: its diff is one file,
`packages/cli/test/validate-build-gate-parity.test.ts`, and its
closed-ledger scan extracts **bare-identifier call sites in `compile.ts`
and `validate.ts`**. This change touches neither command, and moves that
function's name, signature (two parameters, same names, same types —
only the default argument's expression changes) and export not at all. ⇒
the seat's benign judgement holds. The other claim, card objectstack-ai#18402, faces
`packages/rest/src/` and is disjoint.

## Acceptance notes

- **Docs-drift sweep — NOT FALSIFIED.** Predicate stated before reading:
*hand-written docs describe the artifact package-id fallback, or show a
`nav_contribution_group_missing` package id, such that this change makes
that text wrong.* Swept by symbol (`artifactPackagesOf`,
`artifactPackages`, `nav_contribution_group_missing`) and by input shape
(`packages[0]`, `packages[i]`, the bracketed-index spelling, `Package
""`) over `content/docs/`, `skills/`, `docs/adr/`. Controls both ways:
positive `navigationContributions` hit 6 files, negative nonsense token
hit 0. One hand-written hit, `content/docs/ui/setup-app.mdx`, says the
diagnostic names "the contributing package" generically and shows no id
spelling — still true, and marginally *more* true, since its "the same
finding" claim about the two doors was what the divergence quietly
falsified. The other two hits are under `content/docs/references/`,
auto-generated, and reference the error code rather than the id rule.
- `collectNavGroupInputs`' single-package branch (no `packages[]`)
derives its own `packageId` from the top-level manifest — `manifest.id`
then `manifest.name`, both string-guarded, with no positional fallback.
It is not the artifact package-id rule and cannot be: with no
`packages[]` there is no index. Noted, not filed; no PR or seat is
routed to that expression by this change.
- A cross-door pin now exists on the CLI side only. The runtime half of
objectstack-ai#14553 is pinned in
`packages/objectql/src/registry-nav-contribution-group-semantics.test.ts`,
and nothing there covers the empty-id identity. Noted, not filed — out
of the declared face (`packages/cli/src/utils/`), and the new pin reads
the real runtime, so the fact is guarded from one side. Would-be
carrier: the next card touching that objectql suite.


---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants