Skip to content

fix: dedup card .json file rows in boxel-cli search output#5514

Merged
FadhlanR merged 3 commits into
mainfrom
cs-12052-boxel-cli-search-output-invariant
Jul 17, 2026
Merged

fix: dedup card .json file rows in boxel-cli search output#5514
FadhlanR merged 3 commits into
mainfrom
cs-12052-boxel-cli-search-output-invariant

Conversation

@FadhlanR

@FadhlanR FadhlanR commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Background and Goal

CS-11775 shipped the realm-server "mixed entry search" default: _search / _federated-search now return both an instance row and a file row for every dual-indexed card .json, unless the caller's filter discriminates. That leaves three boxel search cases returning duplicated/leaked rows: filter-less list-all, cardUrls lookups (a card .json URL matches both of its rows), and term searches on keys files also carry (_title).

This makes boxel search output invariant to that default — each card surfaces once (via its instance row), plain files stay listed, and type-anchored searches are unchanged.

Where to start

  • packages/boxel-cli/src/commands/search.ts — new composeMixedScopeDedup(query) composes the _isCardInstanceFile dedup filter ({ eq: { _isCardInstanceFile: false } }, which matches an absent key too and drops the redundant .json file row). It's applied at the top of search(), so both boxel search and BoxelCLIClient.search are covered.

Key decisions and non-obvious mechanics

  • Mirrors the host search-sheet dedup (excludeCardInstanceFileRows / scopeFilters in packages/host/app/utils/card-search/query-builder.ts). The dedup is skipped when scope already pins a single kind ('cards'/'files') or the filter carries a narrowing positive type anchor. Root refs (BaseDef/CardDef/FieldDef/FileDef) don't count as narrowing — they span kinds, so a type: baseRef query still needs the dedup — matching hasNarrowingPositiveTypeRef on the host.
  • Kept dependency-light — why the root refs are local literals, not imports. boxel-cli is a plain Node CLI (tsc --noEmit, no glint/content-tag), while @cardstack/base is the base realm's .gts content: it only resolves through the host's tsconfig paths mapping (@cardstack/base/* / https://cardstack.com/base/*packages/base/*.gts) plus the Ember/Glint toolchain that can parse <template> tags — and its runtime graph (@glimmer/component, ember-concurrency, …) doesn't execute under plain Node. The refs aren't defined there anyway; the nearest source is runtime-common/constants, but its type imports reach the runtime-common index barrel, which drags content-tag and the virtual @cardstack/base/* modules into the CLI's type-check (TS2307). So, like the existing local toItemFilter / SearchEntryDoc, the type-anchor detection is reimplemented locally: only CARD_INSTANCE_FILE_KEY (from search-doc-keys, zero imports) is imported, and the root refs are literals. The literals also cover both base-realm module spellings (URL and @cardstack/base/ alias) — CLI users author raw JSON and may write either, whereas constants carries only the alias form. These refs are part of the public query grammar, as stable as the on/type keywords themselves; if a single source of truth is wanted later, the move is extracting them into an import-clean runtime-common subpath alongside search-doc-keys, as a separate refactor.
  • Plain files preserved. A bare { eq: { _isCardInstanceFile: false } } with no explicit anchor does not restrict to card types — the engine only emits a types contains restriction from an explicit on/type, so the existence test compiles to IS NULL and plain file rows survive.

Release ordering

The _isCardInstanceFile field ships with the realm-server (CS-11775, PR #5428); an old server rejects it as unknown. This CLI release must go out with or after that realm-server deploy.

Tests

  • Unit: composeMixedScopeDedup covers no-filter, cardUrls-only, anchorless-filter AND-compose, positive type anchor (skip), root ref (dedup), negated type (dedup), scope: cards|files (skip) / scope: all (dedup), and the wire item._isCardInstanceFile translation.
  • Integration: list-all returns each card once + the plain readme.txt, no .json file-row leaks; a cardUrls .json lookup returns a single entry.

🤖 Generated with Claude Code

FadhlanR and others added 3 commits July 16, 2026 10:10
The realm-server's mixed entry-search default now returns both an
`instance` row and a `file` row for every dual-indexed card `.json`.
Compose the `_isCardInstanceFile` dedup filter into `boxel search`
queries that lack a narrowing positive type anchor (and aren't already
pinned to a single kind via `scope`), so each card surfaces once while
plain files stay listed. Mirrors the host search-sheet dedup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `@cardstack/runtime-common/constants` import pulled runtime-common's
`@cardstack/base/*` type graph into boxel-cli's `tsc --noEmit`, breaking
CI lint. Detect a positive type anchor with a local filter walk instead,
using the ticket's rule (skip dedup on any positive type/on anchor) —
`_isCardInstanceFile` still comes from the import-clean search-doc-keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root refs (BaseDef/CardDef/FieldDef/FileDef) span kinds rather than
narrowing to one — every file row carries BaseDef in its type chain, so
a type: BaseDef query matches both rows of a card .json and would leak
duplicates when it suppressed the dedup. Match the host's
hasNarrowingPositiveTypeRef rule, with the root refs as local literals
(URL and scoped-alias module spellings) so the dependency-light build
stays free of the runtime-common constants import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@FadhlanR
FadhlanR marked this pull request as ready for review July 16, 2026 04:28
@FadhlanR
FadhlanR requested a review from a team July 16, 2026 04:28

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a1420f14e9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return f.every.some((node) => hasNarrowingTypeAnchor(node, negated));
}
if (Array.isArray(f.any)) {
return f.any.some((node) => hasNarrowingTypeAnchor(node, negated));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require every OR branch to narrow before skipping dedup

When an any filter has only one narrowing branch, this some makes composeMixedScopeDedup skip the _isCardInstanceFile filter for the whole mixed search. For example, { any: [{ type: SomeCard }, { contains: { _title: "Mango" } }] } can still match both the card instance row and its .json file row through the untyped _title branch, so the duplicate/leaked file-meta row returns despite this change's goal. Only skip the dedup when the entire filter is kind-narrowing, not merely one OR alternative.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates boxel search (boxel-cli) to deduplicate “mixed-scope” entry search results so dual-indexed card .json files no longer surface twice (instance row + file row) under the realm-server’s mixed entry-search default, while still preserving plain file results.

Changes:

  • Add composeMixedScopeDedup(query) to inject an _isCardInstanceFile: false filter when the query is mixed-scope and not already kind-narrowed by a positive, non-root type anchor.
  • Apply the dedup composition at the start of the CLI’s federated search() so all callers benefit.
  • Extend unit + integration tests to cover list-all, cardUrls lookups, scope/type-anchor skip rules, and ensuring plain files still appear.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
packages/boxel-cli/src/commands/search.ts Adds mixed-scope dedup filter composition and applies it to federated search requests.
packages/boxel-cli/tests/commands/search-query.test.ts Adds unit tests for composeMixedScopeDedup behavior and wire translation of the synthetic dedup key.
packages/boxel-cli/tests/integration/search.test.ts Adds integration coverage ensuring cards are not duplicated and plain files still appear in mixed list-all; verifies cardUrls .json lookup returns one entry.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@FadhlanR
FadhlanR merged commit 48d354f into main Jul 17, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants