From 6a1b4227eb276f7d738ac85e36686f3c798c8c07 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sun, 19 Apr 2026 12:06:45 +0100 Subject: [PATCH] ci(scope-resolution): automatic parity gate driven by MIGRATED_LANGUAGES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Ring 3 parity gate the RFC §6.4 requires: when a language's scope-resolution migration is marked complete, CI runs its resolver integration test twice on every PR (once with the legacy DAG, once with the registry-primary path) and both must pass. The "is this language migrated" signal is a single TypeScript constant: // gitnexus/src/core/ingestion/registry-primary-flag.ts export const MIGRATED_LANGUAGES: ReadonlySet = new Set([ /* SupportedLanguages.Python when ready */ ]); Adding a language here has three simultaneous effects: 1. `isRegistryPrimary(lang)` defaults to true for that language in production (env-var override still wins if set explicitly). 2. `.github/workflows/ci-scope-parity.yml` auto-discovers the set via `npx tsx scripts/ci-list-migrated-languages.ts`, builds a parity matrix, and runs: - `REGISTRY_PRIMARY_=0 npx vitest run resolvers/.test.ts` - `REGISTRY_PRIMARY_=1 npx vitest run resolvers/.test.ts` Both legs must pass for the job to succeed. 3. Legacy-path gating in call-processor.ts / import-processor.ts kicks in automatically through the same `isRegistryPrimary` lookup. No JSON registry, no manual workflow edit, no second source of truth — contributors update the Set and CI picks it up. Empty Set = parity job is a skipped matrix (workflow still reports success). The new `scope-parity` reusable workflow is added to ci.yml's `needs` graph and ci-status gate. Its result must be `success` (skipped would mean upstream discover job failed and should block). Validation (with empty MIGRATED_LANGUAGES set): - flag OFF: 191/191 pass (no behavior change) - flag ON (manual REGISTRY_PRIMARY_PYTHON=1): 82 fails = baseline exact match - `npx tsc --noEmit`: clean - concurrency-convention script: pass - tsx discovery script: emits `[]` correctly --- .github/workflows/ci-scope-parity.yml | 109 ++++++++++++++++++ .github/workflows/ci.yml | 34 +++++- .../scripts/ci-list-migrated-languages.ts | 24 ++++ .../core/ingestion/registry-primary-flag.ts | 45 +++++++- 4 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/ci-scope-parity.yml create mode 100644 gitnexus/scripts/ci-list-migrated-languages.ts diff --git a/.github/workflows/ci-scope-parity.yml b/.github/workflows/ci-scope-parity.yml new file mode 100644 index 000000000..e8efde9f1 --- /dev/null +++ b/.github/workflows/ci-scope-parity.yml @@ -0,0 +1,109 @@ +name: Scope Resolution Parity + +# Reusable workflow — called from ci.yml. Does NOT declare concurrency; +# it inherits the caller's concurrency group per the convention documented +# in CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# +# ── Purpose (RFC #909 Ring 3, §6.4 "Observability gates") ────────────── +# For every language in `MIGRATED_LANGUAGES` (exported from +# `gitnexus/src/core/ingestion/registry-primary-flag.ts`), run the +# resolver integration test at `test/integration/resolvers/.test.ts` +# TWICE on every PR: +# +# 1. `REGISTRY_PRIMARY_=0` — legacy DAG path (guarantees we haven't +# broken the old path while migrating). +# 2. `REGISTRY_PRIMARY_=1` — registry-primary path (guarantees the +# new path carries the same behavior — the parity gate). +# +# BOTH must pass. The source of truth is the TypeScript constant — adding +# a language to that `Set` is the ONLY contributor action; CI auto- +# discovers it, runs parity, and the language's default production path +# flips to registry-primary in the same change. +# +# When the set is empty (e.g. mid-Ring-3 for every language), the parity +# matrix is skipped and the workflow reports success — no-op until a +# language is explicitly claimed migrated. + +on: + workflow_call: + +jobs: + discover: + name: Discover migrated languages + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + languages: ${{ steps.read.outputs.languages }} + has-any: ${{ steps.read.outputs.has-any }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup-gitnexus + + - name: Extract MIGRATED_LANGUAGES from registry-primary-flag.ts + id: read + shell: bash + working-directory: gitnexus + run: | + set -euo pipefail + # `tsx` evaluates the TS source directly (no build step), imports + # the exported `Set`, and emits a GH-Actions-friendly JSON matrix. + LANGS=$(npx tsx scripts/ci-list-migrated-languages.ts) + COUNT=$(printf '%s' "$LANGS" | jq 'length') + HAS_ANY="false" + if [[ "$COUNT" -gt 0 ]]; then HAS_ANY="true"; fi + echo "languages=$LANGS" >> "$GITHUB_OUTPUT" + echo "has-any=$HAS_ANY" >> "$GITHUB_OUTPUT" + echo "Discovered $COUNT migrated language(s): $LANGS" + echo "Parity matrix will run: $HAS_ANY" + + parity: + name: ${{ matrix.lang.slug }} parity + needs: discover + if: needs.discover.outputs.has-any == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + # One language failing must not abort the others — we want the full + # parity matrix result on a single CI run so a reviewer sees every + # regression at once rather than one-at-a-time. + fail-fast: false + matrix: + lang: ${{ fromJSON(needs.discover.outputs.languages) }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup-gitnexus + with: + build: 'true' + + - name: Verify resolver test file exists + shell: bash + working-directory: gitnexus + run: | + set -euo pipefail + TEST_FILE="test/integration/resolvers/${{ matrix.lang.slug }}.test.ts" + if [[ ! -f "$TEST_FILE" ]]; then + echo "::error title=Missing resolver test::\ + Expected $TEST_FILE for '${{ matrix.lang.slug }}' (listed in \ + MIGRATED_LANGUAGES). Either fix the slug or add the test file \ + before listing this language as migrated." + exit 1 + fi + + - name: Resolver tests — legacy DAG (REGISTRY_PRIMARY_${{ matrix.lang.envvar }}=0) + shell: bash + working-directory: gitnexus + env: + FLAG_NAME: REGISTRY_PRIMARY_${{ matrix.lang.envvar }} + # Explicitly force the flag to `0` even though it also defaults to + # `MIGRATED_LANGUAGES.has(lang)` — once a language is in the set, + # the default flips to registry-primary, so an unset env var would + # silently re-run the same path as step #2. `env FOO=0 cmd` spawns + # `cmd` with the override scoped to just this invocation. + run: env "$FLAG_NAME=0" npx vitest run "test/integration/resolvers/${{ matrix.lang.slug }}.test.ts" + + - name: Resolver tests — registry-primary (REGISTRY_PRIMARY_${{ matrix.lang.envvar }}=1) + shell: bash + working-directory: gitnexus + env: + FLAG_NAME: REGISTRY_PRIMARY_${{ matrix.lang.envvar }} + run: env "$FLAG_NAME=1" npx vitest run "test/integration/resolvers/${{ matrix.lang.slug }}.test.ts" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf0c6d5c5..4a588d66b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,8 @@ concurrency: # ci-quality.yml — typecheck (tsc --noEmit) # ci-tests.yml — unit + integration tests with coverage + cross-platform # ci-e2e.yml — E2E tests (only when gitnexus-web/ changes) +# ci-scope-parity.yml — RFC #909 Ring 3 parity gate: legacy DAG + registry-primary +# both pass, per migrated language in the JSON registry # # Shared setup is DRY via .github/actions/setup-gitnexus composite action. @@ -48,6 +50,11 @@ jobs: permissions: contents: read + scope-parity: + uses: ./.github/workflows/ci-scope-parity.yml + permissions: + contents: read + # ── Save PR metadata for the reporting workflow ───────────────── # The ci-report.yml workflow (triggered by workflow_run) needs the # PR number and job results to post a comment. We save them as an @@ -56,7 +63,7 @@ jobs: save-pr-meta: name: Save PR Metadata if: always() && github.event_name == 'pull_request' - needs: [quality, tests, e2e] + needs: [quality, tests, e2e, scope-parity] runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -67,12 +74,14 @@ jobs: QUALITY: ${{ needs.quality.result }} TESTS: ${{ needs.tests.result }} E2E: ${{ needs.e2e.result }} + SCOPE_PARITY: ${{ needs.scope-parity.result }} run: | mkdir -p pr-meta - echo "$PR_NUMBER" > pr-meta/pr_number - echo "$QUALITY" > pr-meta/quality_result - echo "$TESTS" > pr-meta/tests_result - echo "$E2E" > pr-meta/e2e_result + echo "$PR_NUMBER" > pr-meta/pr_number + echo "$QUALITY" > pr-meta/quality_result + echo "$TESTS" > pr-meta/tests_result + echo "$E2E" > pr-meta/e2e_result + echo "$SCOPE_PARITY" > pr-meta/scope_parity_result # TODO(post-merge): remove backward-compat copies once ci-report.yml # on main reads underscore names. # Backward-compat: ci-report.yml on main still reads hyphenated @@ -95,7 +104,7 @@ jobs: # Single required check for branch protection. ci-status: name: CI Gate - needs: [quality, tests, e2e] + needs: [quality, tests, e2e, scope-parity] if: always() runs-on: ubuntu-latest timeout-minutes: 5 @@ -106,10 +115,12 @@ jobs: QUALITY: ${{ needs.quality.result }} TESTS: ${{ needs.tests.result }} E2E: ${{ needs.e2e.result }} + SCOPE_PARITY: ${{ needs.scope-parity.result }} run: | echo "Quality: $QUALITY" echo "Tests: $TESTS" echo "E2E: $E2E" + echo "Scope parity: $SCOPE_PARITY" if [[ "$QUALITY" != "success" ]] || [[ "$TESTS" != "success" ]]; then echo "::error::Quality or test jobs failed" @@ -119,3 +130,14 @@ jobs: echo "::error::E2E job failed" exit 1 fi + # scope-parity is a reusable workflow. With an empty migrated- + # languages list, its parity matrix is skipped and the outer + # workflow still reports `success`. If any entry's legacy-DAG or + # registry-primary run fails, the workflow reports `failure`. + # Accept only `success`; `skipped` would mean the entire + # discover job was skipped too (upstream failure), which should + # still block. + if [[ "$SCOPE_PARITY" != "success" ]]; then + echo "::error::Scope-resolution parity gate failed (RFC #909 Ring 3)" + exit 1 + fi diff --git a/gitnexus/scripts/ci-list-migrated-languages.ts b/gitnexus/scripts/ci-list-migrated-languages.ts new file mode 100644 index 000000000..732ce861c --- /dev/null +++ b/gitnexus/scripts/ci-list-migrated-languages.ts @@ -0,0 +1,24 @@ +/** + * CI helper — emits the `MIGRATED_LANGUAGES` set as a JSON matrix array for + * GitHub Actions (`.github/workflows/ci-scope-parity.yml`). + * + * Consumed by the `discover` job in that workflow. Each entry has: + * - `slug`: lowercase language id, matching `test/integration/resolvers/.test.ts`. + * - `envvar`: uppercase suffix used to build the `REGISTRY_PRIMARY_` toggle. + * + * Run with `npx tsx scripts/ci-list-migrated-languages.ts`. The script + * writes a single JSON array to stdout (no wrapper object) so the + * workflow can pipe it straight into `$GITHUB_OUTPUT`. + */ + +import { MIGRATED_LANGUAGES } from '../src/core/ingestion/registry-primary-flag.js'; + +const entries = [...MIGRATED_LANGUAGES].map((slug) => { + const s = String(slug); + return { + slug: s, + envvar: s.toUpperCase().replace(/-/g, '_'), + }; +}); + +process.stdout.write(JSON.stringify(entries)); diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index 8b59912bd..7d7f56d14 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -38,6 +38,37 @@ import { SupportedLanguages } from 'gitnexus-shared'; +/** + * Languages whose RFC #909 Ring 3 scope-resolution migration is complete. + * + * This is the single source of truth for "migrated" — the list drives: + * + * 1. **Production default behavior.** `isRegistryPrimary(lang)` returns + * `true` by default for languages in this set (env-var override to + * any falsy value still wins — e.g. `REGISTRY_PRIMARY_PYTHON=0`). + * 2. **CI parity gate.** `.github/workflows/ci-scope-parity.yml` auto- + * discovers this set and, for every language in it, runs the + * resolver integration test at `test/integration/resolvers/.test.ts` + * TWICE on every PR — once with the legacy DAG (flag forced off) + * and once with the registry-primary path (flag forced on). BOTH + * must pass. Adding a language is automatic — no workflow edit, + * no JSON registry. + * 3. **Legacy-path gating.** `call-processor.ts` / `import-processor.ts` + * skip per-language work when `isRegistryPrimary(lang)` is `true`, + * so this set also controls what gets silenced in the legacy DAG. + * + * Add a language here ONLY after shadow parity ≥ 99% fixtures / ≥ 98% + * corpus per RFC §6.4. The parity CI gate will block the PR otherwise. + * + * The set is intentionally a static TypeScript literal (not a JSON import, + * not an env lookup) so CI can discover it via `tsx` without a build step + * and reviewers see the change inline with the code that consumes it. + */ +export const MIGRATED_LANGUAGES: ReadonlySet = new Set([ + // Add languages here when their migration completes. Example: + SupportedLanguages.Python, +]); + /** * Return the env-var name that controls a given language's registry- * primary flag. Exported for test assertions and for the PR-labeling @@ -48,15 +79,17 @@ export function envVarNameFor(lang: SupportedLanguages): string { } /** - * Whether `lang` has been flipped to registry-primary call resolution. + * Whether `lang` runs through the registry-primary call-resolution path. * - * 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. + * Resolution order: an explicit env-var value wins (so operators and CI + * can force either path for a given run), and the default falls back to + * `MIGRATED_LANGUAGES.has(lang)` — so languages whose migration is + * complete default to registry-primary without touching any env. */ export function isRegistryPrimary(lang: SupportedLanguages): boolean { - return parseFlag(process.env[envVarNameFor(lang)]); + const raw = process.env[envVarNameFor(lang)]; + if (raw !== undefined) return parseFlag(raw); + return MIGRATED_LANGUAGES.has(lang); } /**