Skip to content

refactor(analyzer): single provider registry — collapse 3 hand-synced dispatch tables#649

Merged
coseto6125 merged 5 commits into
mainfrom
chore/provider-registry
Jul 24, 2026
Merged

refactor(analyzer): single provider registry — collapse 3 hand-synced dispatch tables#649
coseto6125 merged 5 commits into
mainfrom
chore/provider-registry

Conversation

@coseto6125

Copy link
Copy Markdown
Owner

What / why

The same extension→language/provider mapping existed in three places, kept in sync only by comments — this repo has shipped two real bugs from that drift: one dispatch site missed in PR #141 (fixed by #142), and .yaml never scanned by the cold-index path until PR #595.

  1. Canonical: AnalyzerPipeline::provider_name_for_path (crates/ecp-core/src/analyzer/pipeline.rs) — path-based special cases (Dockerfile, docker-compose, GitHub Actions, YAML content-sniff routing) plus a plain extension table.
  2. Copy: detect_needed_providers (crates/ecp-cli/src/commands/admin/index.rs) — re-listed the same extension table into a NeededProviders bool-flags struct.
  3. Copy: make_provider / ALL_PROVIDER_NAMES (crates/ecp-cli/src/reanalyze.rs) — name string → provider constructor, a deliberate subset.

Design

  • AnalyzerPipeline::EXTENSION_TABLE (new, ecp-core) is now the single data-driven source for the plain extension→provider-name mapping. provider_name_for_path derives from it via a linear scan and keeps ownership of the path-based special cases that aren't extension-derivable.
  • provider_registry::PROVIDER_CONSTRUCTORS (new, crates/ecp-cli/src/provider_registry.rs) is the single name→constructor table (fn() -> anyhow::Result<Box<dyn LanguageProvider>>). reanalyze::make_provider and admin/index's cold-index provider construction both resolve against it instead of re-listing their own match arms. reanalyze::ALL_PROVIDER_NAMES stays as an explicit filter over registry names (a deliberate subset — crystal/zig/sql aren't wired into the incremental path yet), unchanged in intent, now sourced from the shared constructor table.
  • detect_needed_providers now resolves each path through the canonical provider_name_for_path and a small set_needed_flag name→flag table, instead of re-deriving its own extension match. md/txt/rst (markdown, which has no path-dispatch entry) stay as a documented pre-check.
  • Crate boundary: constructors live in ecp-cli (all *Provider types are defined in ecp-analyzer, which ecp-cli already depends on) keyed by name; the extension/name half of the mapping stays in ecp-core since provider_name_for_path is ecp-core's existing public API. A test pins that the two halves agree.

Zero behavior change

  • Every path that mapped to provider X before this change still maps to X — EXTENSION_TABLE is a byte-for-byte transcription of the old match arms.
  • The reanalyze subset (ALL_PROVIDER_NAMES) is unchanged; make_pipeline's markdown/yaml direct registration is unchanged (same .unwrap() semantics, same registration order).
  • NeededProviders's bool-flag struct and its downstream if needed.X ergonomics are unchanged; construction is now table-driven.

Tests

  • provider_registry::tests — every EXTENSION_TABLE name (plus the 3 path-special names) resolves to a constructible provider; registry names are unique; every constructor succeeds.
  • admin::index::tests — every EXTENSION_TABLE entry sets its NeededProviders flag; every path special case (Dockerfile/docker-compose/GitHub Actions/generic yaml→kubernetes) sets its flag; markdown extensions set their flag; NeededProviders::is_set agrees with set_needed_flag for every registry name (with the yaml/kubernetes dual-flag case as a documented, tested exception — no path ever resolves to the bare name "yaml").
  • reanalyze::tests (pre-existing, still pass unmodified) — ALL_PROVIDER_NAMES entries are constructible; make_pipeline holds the named subset plus markdown + yaml.

Verify

  • cargo build -p egent-code-plexus --bin ecp — clean.
  • cargo test -p egent-code-plexus --tests — 205/205 test-result blocks green, 0 failures.
  • cargo test -p ecp-analyzer — green (unaffected by this change; run per the 14-language coverage rule).
  • cargo clippy -p egent-code-plexus --tests — clean, no warnings.
  • rustfmt --edition 2021 on all touched files.

Deviations from the brief

  • Added anyhow = "1.0.82" as a direct dependency of ecp-cli (previously only reached transitively via ecp-analyzer/ecp-core) — needed because provider_registry::PROVIDER_CONSTRUCTORS's constructor signature is anyhow::Result<...>, matching every *Provider::new() in ecp-analyzer (all anyhow::Result<Self> except MarkdownProvider::new() -> Result<Self, String>, mapped with .map_err(|e| anyhow::anyhow!(e))).

… dispatch tables

The same extension→language/provider mapping existed in three places kept in
sync only by comments: AnalyzerPipeline::provider_name_for_path (ecp-core,
canonical), detect_needed_providers (ecp-cli admin index), and
make_provider/ALL_PROVIDER_NAMES (ecp-cli reanalyze). Drift between them has
twice shipped silent bugs (PR #141/#142 missed dispatch site, PR #595 never
scanned .yaml).

- AnalyzerPipeline::EXTENSION_TABLE (ecp-core) is now the single data-driven
  source for the plain extension→name mapping; provider_name_for_path derives
  from it and keeps ownership of the path-based special cases (Dockerfile,
  docker-compose, GitHub Actions, YAML content-sniff routing).
- provider_registry::PROVIDER_CONSTRUCTORS (ecp-cli) is the single
  name→constructor table; reanalyze::make_provider and admin/index's
  provider-factory construction both resolve against it instead of each
  re-listing their own match arms.
- detect_needed_providers now resolves paths through the canonical
  provider_name_for_path instead of re-deriving its own extension match.
- Regression tests pin the sync invariants: every EXTENSION_TABLE name is
  constructible, every path special-case sets its NeededProviders flag, and
  NeededProviders::is_set agrees with set_needed_flag for every registry name
  (the yaml/kubernetes dual-flag case is a documented, tested exception).
@coseto6125
coseto6125 enabled auto-merge (squash) July 22, 2026 17:32
@coseto6125 coseto6125 added the merge-queue Opt-in to Mergify merge queue label Jul 22, 2026
@coseto6125
coseto6125 disabled auto-merge July 22, 2026 17:35
…DER_NAMES

Since PROVIDER_CONSTRUCTORS now backs make_provider with the full registry
(~36 names) instead of reanalyze.rs's old locally-scoped match arms,
reanalyze_files's unfiltered provider_name_for_path lookup started resolving
names outside ALL_PROVIDER_NAMES (e.g. "bash" for .sh) that the full
pipeline deliberately excludes. That gave the incremental path more
coverage than make_pipeline(), breaking incremental/full parity — a .sh
file yielded 0 graphs via pipeline().analyze() but 1 via reanalyze_files().

Filter the needed-provider set through ALL_PROVIDER_NAMES before building
the scoped pipeline, restoring the pre-refactor exclusion. Added a
regression test pinning parity for a name outside the subset.
@coseto6125
coseto6125 enabled auto-merge (squash) July 22, 2026 17:46
…for_names

The reanalyze_files-only filter added in the prior commit missed a second
caller: impact_with_baseline (crates/ecp-cli/src/commands/impact.rs:1312)
builds its scoped diff pipeline through the same make_pipeline_for_names
helper, unfiltered. Since make_provider now resolves the full
provider_registry instead of the old locally-scoped match arms, a name like
"bash" (outside ALL_PROVIDER_NAMES, not yet wired into these scoped paths)
would register there too — giving impact --baseline more provider coverage
than the full pipeline for diffs touching e.g. .sh files.

Move the ALL_PROVIDER_NAMES constraint into make_pipeline_for_names itself
so every current and future caller inherits it, and drop the now-redundant
filter at the reanalyze_files call site. Added
test_make_pipeline_for_names_nonsubset_name_skipped, a direct unit test of
the helper (verified to fail without the fix).
make_pipeline_for_names's `if let Some(Ok(provider)) = make_provider(name)`
silently dropped the Err case, so a broken tree-sitter query would silently
skip that language in incremental reanalyze / impact --baseline instead of
surfacing the failure. Pre-refactor, every constructor call was `.unwrap()`'d
inline and panicked immediately on the same failure.

Panic via `.unwrap_or_else` at the point make_provider returns Some(Err(_)),
restoring the original fail-fast behavior. Chose this over threading Result
through make_pipeline_for_names's signature because its three callers
(make_pipeline() via OnceLock::get_or_init, reanalyze_files, impact_with_
baseline) mix Result-free and Result-returning shapes — panicking in the
helper matches every one of them without touching any call site.

A provider-constructor failure isn't feasible to fixture without either
corrupting a real language's compiled-in .scm query (destructive to every
other test using that provider) or reworking PROVIDER_CONSTRUCTORS into
injectable closures per test — out of scope for this fix. No test added for
this specific change; the existing full suite staying green confirms no
call site needed updating.
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor
ecp impact cache (0 symbols) — internal, used by ecp dev pr-analyze

[]

@github-actions github-actions Bot added the ecp:risk-high ecp signal label Jul 22, 2026
@github-actions github-actions Bot added ecp:risk-low ecp signal and removed ecp:risk-high ecp signal labels Jul 24, 2026
@coseto6125
coseto6125 merged commit 1ad4bdf into main Jul 24, 2026
18 checks passed
@coseto6125
coseto6125 deleted the chore/provider-registry branch July 24, 2026 02:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ecp:risk-low ecp signal merge-queue Opt-in to Mergify merge queue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant