From eece6344fc1107de383d4e87c374730b2c00b752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 19:54:54 +0100 Subject: [PATCH] feat(ingestion): REGISTRY_PRIMARY_ per-language flag reader (#924, RFC #909 Ring 2 PKG) (#968) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the per-language feature flag primitive that gates the Ring 3 registry-primary rollout. Single source of truth for whether a given language uses `Registry.lookup` (new) or the legacy DAG (current). ## Shipped ### `gitnexus/src/core/ingestion/registry-primary-flag.ts` - `isRegistryPrimary(lang): boolean` — reads `REGISTRY_PRIMARY_` from `process.env`. - `envVarNameFor(lang): string` — exposed for CI tooling that cross-references flag flips (and for test assertions). - `primaryLanguages(): ReadonlySet` — all currently-on languages; useful for startup logging + the #923 shadow dashboard which distinguishes "primary: legacy" vs "primary: registry" rows. ### Contract - Default: `false` for every language. A language must explicitly opt in by setting its env var. - Truthy: `'true'`, `'1'`, `'yes'` (case-insensitive, whitespace- trimmed). Anything else — typos, empty string, `'off'` — is `false`. Fail-safe posture: a misspelled flag doesn't accidentally flip a language. - No per-process caching. `process.env` is read per call; overhead is negligible (one lookup per file at resolution time), and test isolation is lexical (no cache-reset coordination). ### Env-var mapping Uses the enum VALUE, not the TS key, for the env-var suffix: - `SupportedLanguages.Python` → `REGISTRY_PRIMARY_PYTHON` - `SupportedLanguages.CPlusPlus` → `REGISTRY_PRIMARY_CPP` (value `'cpp'`) - `SupportedLanguages.CSharp` → `REGISTRY_PRIMARY_CSHARP` Users flip languages by their canonical name, not the TS symbol. ## Tests (16, all passing) - `envVarNameFor` (3): upper-casing · enum-VALUE-not-KEY mapping · all-languages uniqueness smoke-test - `isRegistryPrimary` (9): default false · `'true'` / `'1'` / `'yes'` truthy · mixed-case + whitespace-padded · falsy-looking values · unrecognized tokens (typo-safe) · per-language isolation · no stale cache on mid-process mutation · CPlusPlus mapping - `primaryLanguages` (3): empty · exact membership · Set instanceof Tests scrub every `REGISTRY_PRIMARY_*` env var in `beforeEach` + `afterEach` so parallel vitest runs on the same process don't bleed state. ## What's NOT in this PR (deferred by design) The actual integration in `call-processor.ts` belongs in #921 (finalize-orchestrator). Reason: the "new path" requires a populated `SemanticModel` to call `Registry.lookup` against, and the model becomes accessible only after #921 orchestrates finalize. Wiring a dead branch now would just get rewritten then. This PR ships the flag primitive in isolation so #921 has a clean, tested utility to consult — and so `#923` (shadow harness) has a stable boolean to read for its "which row is primary?" rendering. ## Closes part of #909. Unblocks - #921 finalize-orchestrator — can now consult `isRegistryPrimary` at resolution time - #923 shadow harness — can distinguish primary-flipped rows --- .../core/ingestion/registry-primary-flag.ts | 83 ++++++++++ .../test/unit/registry-primary-flag.test.ts | 152 ++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 gitnexus/src/core/ingestion/registry-primary-flag.ts create mode 100644 gitnexus/test/unit/registry-primary-flag.test.ts diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts new file mode 100644 index 000000000..8b59912bd --- /dev/null +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -0,0 +1,83 @@ +/** + * `REGISTRY_PRIMARY_` per-language feature flags for the scope-based + * resolution rollout (RFC §6.1 Ring 3; Ring 2 PKG #924). + * + * This module is the single source of truth for whether a given language + * has been flipped to registry-primary call resolution. When a language's + * flag is true, its files route through `Registry.lookup` (RFC §4) instead + * of the legacy call-resolution DAG; when false (the default), the legacy + * DAG runs unchanged. + * + * ## Contract + * + * - Env-var name per language: `REGISTRY_PRIMARY_`. + * Example: `SupportedLanguages.Python` → `REGISTRY_PRIMARY_PYTHON`; + * `SupportedLanguages.CPlusPlus` (value `'cpp'`) → `REGISTRY_PRIMARY_CPP`. + * - Truthy values: `'true'`, `'1'`, `'yes'` (case-insensitive, + * whitespace-trimmed). Anything else — including `undefined`, empty + * string, or unknown tokens — is `false`. + * - No per-process caching. `process.env` is read on every call. The + * flag is consulted once per file at call-resolution time, so the + * overhead is negligible; skipping caching keeps test isolation + * trivial (no `resetFlagCache()` coordination needed). + * + * ## Integration site + * + * `call-processor.ts` integration lands in **#921** (`finalize-orchestrator`) + * where the `SemanticModel` becomes accessible and `Registry.lookup` can + * actually be called with a populated context. This module ships the flag + * primitive in isolation so #921 has a clean, tested utility to consult. + * + * ## Shadow mode is orthogonal + * + * Shadow mode (`GITNEXUS_SHADOW_MODE=1`, introduced in #923) runs BOTH + * legacy and registry paths regardless of the per-language flag, so the + * parity dashboard has signal even for un-flipped languages. That logic + * lives in `shadow-harness.ts` (#923), not here. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; + +/** + * Return the env-var name that controls a given language's registry- + * primary flag. Exported for test assertions and for the PR-labeling + * CI job that cross-references per-language flag changes. + */ +export function envVarNameFor(lang: SupportedLanguages): string { + return `REGISTRY_PRIMARY_${lang.toUpperCase()}`; +} + +/** + * Whether `lang` has been flipped to registry-primary call resolution. + * + * Returns `false` by default — a language must explicitly set its env + * var to a truthy value to opt in. The flag is the sole control surface: + * flipping it requires no code change, and reverting it requires no code + * change. + */ +export function isRegistryPrimary(lang: SupportedLanguages): boolean { + return parseFlag(process.env[envVarNameFor(lang)]); +} + +/** + * All languages whose registry-primary flag is currently on. Useful for + * startup-time logging + the shadow-harness dashboard, which wants to + * distinguish "primary: legacy" from "primary: registry" rows. + */ +export function primaryLanguages(): ReadonlySet { + const out = new Set(); + for (const lang of Object.values(SupportedLanguages)) { + if (isRegistryPrimary(lang)) out.add(lang); + } + return out; +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +/** Accepted truthy strings (case-insensitive, trimmed). */ +const TRUTHY_VALUES: ReadonlySet = new Set(['true', '1', 'yes']); + +function parseFlag(raw: string | undefined): boolean { + if (raw === undefined) return false; + return TRUTHY_VALUES.has(raw.trim().toLowerCase()); +} diff --git a/gitnexus/test/unit/registry-primary-flag.test.ts b/gitnexus/test/unit/registry-primary-flag.test.ts new file mode 100644 index 000000000..20a547e24 --- /dev/null +++ b/gitnexus/test/unit/registry-primary-flag.test.ts @@ -0,0 +1,152 @@ +/** + * Unit tests for `registry-primary-flag` (RFC #909 Ring 2 PKG #924). + * + * Flag is `REGISTRY_PRIMARY_`. Each test manipulates + * `process.env` directly and restores it in `afterEach` — there is no + * per-process cache to invalidate, so isolation is lexical. + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { + envVarNameFor, + isRegistryPrimary, + primaryLanguages, +} from '../../src/core/ingestion/registry-primary-flag.js'; + +// ─── Test isolation ───────────────────────────────────────────────────────── +// +// Scrub every `REGISTRY_PRIMARY_*` env var before + after each test so +// parallel vitest runs on the same process don't bleed state. + +function clearAllRegistryPrimaryVars(): void { + for (const key of Object.keys(process.env)) { + if (key.startsWith('REGISTRY_PRIMARY_')) delete process.env[key]; + } +} + +beforeEach(clearAllRegistryPrimaryVars); +afterEach(clearAllRegistryPrimaryVars); + +// ─── envVarNameFor ───────────────────────────────────────────────────────── + +describe('envVarNameFor', () => { + it('produces upper-cased env-var names from the enum value', () => { + expect(envVarNameFor(SupportedLanguages.Python)).toBe('REGISTRY_PRIMARY_PYTHON'); + expect(envVarNameFor(SupportedLanguages.TypeScript)).toBe('REGISTRY_PRIMARY_TYPESCRIPT'); + expect(envVarNameFor(SupportedLanguages.JavaScript)).toBe('REGISTRY_PRIMARY_JAVASCRIPT'); + }); + + it('uses the enum VALUE, not the key, for languages whose key differs from the value', () => { + // Key 'CPlusPlus' → value 'cpp' → env var 'REGISTRY_PRIMARY_CPP'. + // Users see the language by its canonical name, not its TS symbol. + expect(envVarNameFor(SupportedLanguages.CPlusPlus)).toBe('REGISTRY_PRIMARY_CPP'); + expect(envVarNameFor(SupportedLanguages.CSharp)).toBe('REGISTRY_PRIMARY_CSHARP'); + }); + + it('covers every member of SupportedLanguages', () => { + // Build env-var names for every language and assert no duplicates — + // catches a future enum-value collision or accidental renaming. + const names = new Set(); + for (const lang of Object.values(SupportedLanguages)) { + names.add(envVarNameFor(lang)); + } + expect(names.size).toBe(Object.values(SupportedLanguages).length); + }); +}); + +// ─── isRegistryPrimary ───────────────────────────────────────────────────── + +describe('isRegistryPrimary', () => { + it('returns false by default (no env var set)', () => { + for (const lang of Object.values(SupportedLanguages)) { + expect(isRegistryPrimary(lang)).toBe(false); + } + }); + + it("returns true when the env var is 'true' (lowercase)", () => { + process.env['REGISTRY_PRIMARY_PYTHON'] = 'true'; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); + }); + + it("returns true when the env var is '1'", () => { + process.env['REGISTRY_PRIMARY_PYTHON'] = '1'; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); + }); + + it("returns true when the env var is 'yes'", () => { + process.env['REGISTRY_PRIMARY_PYTHON'] = 'yes'; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); + }); + + it('accepts mixed-case and whitespace-padded truthy values', () => { + process.env['REGISTRY_PRIMARY_PYTHON'] = ' TRUE '; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); + process.env['REGISTRY_PRIMARY_PYTHON'] = 'Yes'; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); + }); + + it("returns false for falsy-looking values ('false', '0', empty, 'off')", () => { + for (const value of ['false', '0', '', 'off', 'no', 'disabled']) { + process.env['REGISTRY_PRIMARY_PYTHON'] = value; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(false); + } + }); + + it('returns false for unrecognized tokens (fail-safe on typos)', () => { + // User meant to type 'true' but fat-fingered — conservative: treat as off. + for (const value of ['ture', 'tru', 'yeah', 'enable', 'y']) { + process.env['REGISTRY_PRIMARY_PYTHON'] = value; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(false); + } + }); + + it('isolates flags per-language (one on does not affect others)', () => { + process.env['REGISTRY_PRIMARY_PYTHON'] = 'true'; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); + expect(isRegistryPrimary(SupportedLanguages.Java)).toBe(false); + expect(isRegistryPrimary(SupportedLanguages.Go)).toBe(false); + }); + + it('respects a mid-process env-var mutation (no stale cache)', () => { + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(false); + process.env['REGISTRY_PRIMARY_PYTHON'] = 'true'; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); + delete process.env['REGISTRY_PRIMARY_PYTHON']; + expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(false); + }); + + it('handles the CPlusPlus → REGISTRY_PRIMARY_CPP mapping correctly', () => { + process.env['REGISTRY_PRIMARY_CPP'] = 'true'; + expect(isRegistryPrimary(SupportedLanguages.CPlusPlus)).toBe(true); + // Negative: the TS-key-style name is NOT read. + delete process.env['REGISTRY_PRIMARY_CPP']; + process.env['REGISTRY_PRIMARY_CPLUSPLUS'] = 'true'; + expect(isRegistryPrimary(SupportedLanguages.CPlusPlus)).toBe(false); + }); +}); + +// ─── primaryLanguages ────────────────────────────────────────────────────── + +describe('primaryLanguages', () => { + it('returns an empty set when no flags are set', () => { + expect(primaryLanguages().size).toBe(0); + }); + + it('returns exactly the flipped languages', () => { + process.env['REGISTRY_PRIMARY_PYTHON'] = 'true'; + process.env['REGISTRY_PRIMARY_GO'] = '1'; + process.env['REGISTRY_PRIMARY_JAVA'] = 'false'; // explicitly off + const enabled = primaryLanguages(); + expect(enabled.has(SupportedLanguages.Python)).toBe(true); + expect(enabled.has(SupportedLanguages.Go)).toBe(true); + expect(enabled.has(SupportedLanguages.Java)).toBe(false); + expect(enabled.size).toBe(2); + }); + + it('returns a plain Set (not a frozen proxy) — consistent shape', () => { + process.env['REGISTRY_PRIMARY_PYTHON'] = 'true'; + const enabled = primaryLanguages(); + expect(enabled).toBeInstanceOf(Set); + }); +});