Files
flights_web/docs/superpowers/plans/2026-04-14-phase-1a1-skeleton.md
T
gnezim 59a94b50b9 Add Phase 1A-1 project skeleton implementation plan
TDD-granular plan with 12 tasks covering Node 24 pinning, TypeScript
strict config, baseline ESLint, Vitest setup, type-only seeds for
HostContract plan-cycle resolution, Zod-validated env reader, frozen
feature/UI barrels, and the A1 rename-pass rework stub.
2026-04-14 21:42:49 +03:00

34 KiB
Raw Blame History

Phase 1A-1 — Project Skeleton Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Bootstrap the empty Modern.js React project skeleton at the repo root with a typechecked, lintable, testable src/ tree, a Zod-validated env module, a frozen-public-barrel rule for the four feature modules, and the HostContract type — producing a baseline that every downstream Phase 1 sub-plan builds on.

Architecture: The new React project lives at the repo root, alongside the existing ASP.NET files and the Angular ClientApp/ (both untouched). The root gains a package.json, tsconfig.json, .eslintrc.cjs, vitest.config.ts, and an empty src/ tree. Fate of the ASP.NET host is Phase 0 assumption A5 (hard blocker resolved before 1A-1 starts); regardless of A5, 1A-1 never deletes or modifies ASP.NET or ClientApp/ files.

Tech Stack: Node 24, pnpm, TypeScript 5 strict, Vitest, ESLint 9 (flat config compatible), Zod for runtime env validation.

Scope boundaries (what 1A-1 does NOT do):

  • No Modern.js / Rspack / MF 2.0 config — that's 1A-2.
  • No ESLint boundary rules or restricted-imports — that's 1A-3.
  • No feature code, no routes, no middlewares — Phase 1B through 1I own those.
  • No runtime logger/metrics/analytics implementations — only type-only seeds that 1G sub-plans extend.

Assumptions resolved before this plan starts (from Phase 0 hard-blocker gate):

  • A2 CDN vendor, A3 CI provider, A5 ASP.NET host fate, A6 metrics endpoint, A8 prod URL / access logs, A9 Node 24 available on customer deploy VMs.

Deliverables (contract with downstream Phase 1 sub-plans):

  1. .nvmrc pinned to Node 24.
  2. Root package.json with scripts dev, build:standalone, build:remote, build:both, test, test:coverage, lint, typecheck. (Non-build scripts are stubs until 1A-2 wires Modern.js.)
  3. tsconfig.json — strict, noUncheckedIndexedAccess, isolatedModules, path aliases @/*src/*, @phase0/*scripts/phase-0/*.
  4. .eslintrc.cjs baseline (no boundary rules yet).
  5. vitest.config.ts wired to the @/ alias.
  6. src/ directory with:
    • src/env/index.ts — Zod-validated Env reader (and its test).
    • src/host-contract.ts — the HostContract interface.
    • src/observability/logger/types.ts — type-only Logger, LogFields, LogLevel, LogRecord, LogTransport (seeded here, owned by 1G-logger going forward).
    • src/observability/analytics/types.ts — type-only AnalyticsProviders, AnalyticsProps, AnalyticsEvent, Analytics (seeded here, owned by 1G-analytics going forward).
    • src/features/{online-board,schedule,flights-map,popular-requests}/index.ts — empty barrels.
    • src/ui/index.ts — empty barrel.
  7. docs/superpowers/phase-1/frozen-barrels.md — documents the frozen public-surface rule.
  8. docs/superpowers/phase-1/rename-pass-plan.md — stub rework plan attached to A1 resolution.

Exit gate: pnpm install && pnpm typecheck && pnpm lint && pnpm test all green. Committed to plan/react-rewrite.


File structure reference

Files created or modified by this plan, in order of appearance:

File Responsibility Task
.nvmrc Pin Node 24 1
package.json Root workspace manifest + scripts + deps 1, 2
pnpm-lock.yaml pnpm lockfile (generated) 2
tsconfig.json TypeScript strict config + path aliases 3
.eslintrc.cjs Baseline ESLint config (no boundaries yet) 4
vitest.config.ts Vitest runner config with @/ alias 5
src/observability/logger/types.ts Type-only Logger surface 6
src/observability/analytics/types.ts Type-only AnalyticsProviders + friends 7
src/host-contract.ts HostContract interface 8
src/env/index.ts Zod-validated getEnv() 9 (TDD)
src/env/env.test.ts Env module tests 9 (TDD)
src/features/online-board/index.ts Empty barrel 10
src/features/schedule/index.ts Empty barrel 10
src/features/flights-map/index.ts Empty barrel 10
src/features/popular-requests/index.ts Empty barrel 10
src/ui/index.ts Empty barrel 10
docs/superpowers/phase-1/frozen-barrels.md Frozen public-surface rule 11
docs/superpowers/phase-1/rename-pass-plan.md A1-trigger rework stub 11

Decomposition rationale. Every file has one responsibility. The env reader and its test live next to each other (src/env/). The two type-only observability files are deliberately small — they exist only to break the plan-order cycle with 1A-1's HostContract and Env, and are extended by 1G-logger / 1G-analytics later. host-contract.ts lives at src/ root because both src/routes/ and src/mf/ consume it and neither should own it.


Task 1 — Pin Node 24 and create root package.json

Files:

  • Modify: .nvmrc

  • Create: package.json

  • Step 1: Bump .nvmrc to Node 24

Replace the single line in .nvmrc (currently 16) with the pinned Node 24 LTS version:

24.2.0
  • Step 2: Verify Node 24 is available locally

Run:

node --version

Expected: v24.2.0 or newer 24.x. If older, install Node 24 via nvm/volta/fnm before continuing.

  • Step 3: Create root package.json

Create package.json at the repo root:

{
  "name": "@aeroflot/flights-web",
  "version": "0.0.0",
  "private": true,
  "description": "Aeroflot Flights — Modern.js + MF 2.0 React remote component (Phase 1 foundation)",
  "license": "UNLICENSED",
  "type": "module",
  "engines": {
    "node": ">=24.0.0",
    "pnpm": ">=9.0.0"
  },
  "packageManager": "pnpm@9.15.0",
  "scripts": {
    "dev": "echo \"dev script wired in 1A-2\" && exit 1",
    "build:standalone": "echo \"build:standalone wired in 1A-2\" && exit 1",
    "build:remote": "echo \"build:remote wired in 1A-2\" && exit 1",
    "build:both": "pnpm build:standalone && pnpm build:remote",
    "test": "vitest run",
    "test:coverage": "vitest run --coverage",
    "lint": "eslint \"src/**/*.{ts,tsx}\" --max-warnings 0",
    "typecheck": "tsc --noEmit"
  }
}

Rationale for the "echo ... exit 1" stubs: the master-plan contract for 1A-1 requires these script names to exist, but the implementations come from 1A-2 (Modern.js/Rspack). Explicit non-zero exit prevents silent false-success if someone runs them before 1A-2 lands.

  • Step 4: Commit
git add .nvmrc package.json
git commit -m "Pin Node 24 and seed root package.json for Phase 1A-1"

Task 2 — Install base dev dependencies via pnpm

Files:

  • Modify: package.json (dependencies/devDependencies sections)

  • Create: pnpm-lock.yaml

  • Step 1: Install runtime dep

pnpm add zod@^3.23.0

Rationale: zod is used for env-var validation in Task 9 and will later be consumed by src/shared/storage.ts in 1H. It lives at 1A-1's level because both future consumers need it.

  • Step 2: Install dev deps (TypeScript, Vitest, ESLint, Node types)
pnpm add -D \
  typescript@^5.5.0 \
  @types/node@^24.0.0 \
  vitest@^2.0.0 \
  @vitest/ui@^2.0.0 \
  eslint@^9.0.0 \
  @typescript-eslint/parser@^8.0.0 \
  @typescript-eslint/eslint-plugin@^8.0.0 \
  eslint-plugin-unused-imports@^4.0.0
  • Step 3: Verify node_modules/ populated and lockfile exists
ls node_modules/.bin/tsc node_modules/.bin/vitest node_modules/.bin/eslint
test -f pnpm-lock.yaml && echo "lockfile present"

Expected: three binary paths print, and lockfile present echoes.

  • Step 4: Commit
git add package.json pnpm-lock.yaml
git commit -m "Install TypeScript, Vitest, ESLint, Zod base toolchain"

Task 3 — Create tsconfig.json with strict settings and path aliases

Files:

  • Create: tsconfig.json

  • Step 1: Write tsconfig.json

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ES2023", "DOM", "DOM.Iterable"],
    "jsx": "react-jsx",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "exactOptionalPropertyTypes": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true,
    "forceConsistentCasingInFileNames": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@phase0/*": ["scripts/phase-0/*"]
    },
    "types": ["node", "vitest/globals"]
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "vitest.config.ts"],
  "exclude": ["node_modules", "dist", "ClientApp", "wwwroot"]
}
  • Step 2: Verify pnpm typecheck runs on empty src (will fail — no files yet)
pnpm typecheck

Expected: exits 0 with no output, OR errors error TS18003: No inputs were found because src/ does not exist yet. Either outcome is acceptable at this step — the point is that the command runs and the config parses.

If TS18003 fires, create an empty placeholder so it goes away:

mkdir -p src
echo "export {};" > src/.typecheck-placeholder.ts
pnpm typecheck

Expected: exits 0 silently.

  • Step 3: Commit
git add tsconfig.json
git add src/.typecheck-placeholder.ts 2>/dev/null || true
git commit -m "Add strict TypeScript config with @/ and @phase0/ aliases"

Task 4 — Create baseline .eslintrc.cjs

Files:

  • Create: .eslintrc.cjs

  • Create: .eslintignore

  • Step 1: Write .eslintrc.cjs

Baseline only. Layered dependency rules (eslint-plugin-boundaries) and no-restricted-imports come in 1A-3.

/** @type {import('eslint').Linter.Config} */
module.exports = {
  root: true,
  parser: "@typescript-eslint/parser",
  parserOptions: {
    ecmaVersion: 2023,
    sourceType: "module",
    project: "./tsconfig.json",
    ecmaFeatures: { jsx: true },
  },
  plugins: ["@typescript-eslint", "unused-imports"],
  extends: [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
  ],
  rules: {
    "@typescript-eslint/no-unused-vars": "off",
    "unused-imports/no-unused-imports": "error",
    "unused-imports/no-unused-vars": [
      "warn",
      {
        vars: "all",
        varsIgnorePattern: "^_",
        args: "after-used",
        argsIgnorePattern: "^_",
      },
    ],
    "@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
    "@typescript-eslint/no-explicit-any": "warn",
    "@typescript-eslint/no-non-null-assertion": "warn",
    "no-console": ["warn", { allow: ["warn", "error"] }],
  },
  ignorePatterns: ["dist/", "node_modules/", "ClientApp/", "wwwroot/", "*.cjs"],
};
  • Step 2: Write .eslintignore
dist/
node_modules/
ClientApp/
wwwroot/
*.cjs
pnpm-lock.yaml
  • Step 3: Run lint
pnpm lint

Expected: either 0 errors / 0 warnings with no files to lint, OR a message like "No files matching the pattern 'src/**/*.{ts,tsx}' were found." Both are acceptable — the point is that the config parses and the rule set loads without error.

  • Step 4: Commit
git add .eslintrc.cjs .eslintignore
git commit -m "Add baseline ESLint config (no boundary rules yet)"

Task 5 — Create vitest.config.ts

Files:

  • Create: vitest.config.ts

  • Step 1: Write vitest.config.ts

import { defineConfig } from "vitest/config";
import path from "node:path";

export default defineConfig({
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
      "@phase0": path.resolve(__dirname, "./scripts/phase-0"),
    },
  },
  test: {
    environment: "node",
    globals: true,
    include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
    coverage: {
      provider: "v8",
      reporter: ["text", "json-summary", "lcov"],
      include: ["src/**/*.ts", "src/**/*.tsx"],
      exclude: [
        "src/**/*.test.ts",
        "src/**/*.test.tsx",
        "src/**/types.ts",
        "src/host-contract.ts",
      ],
    },
  },
});

Rationale for coverage excludes: type-only files and interface declarations have no runtime code to cover; including them would skew the coverage delta gate added in 1B.

  • Step 2: Run pnpm test on empty test suite
pnpm test

Expected: Vitest prints No test files found and exits with code 0 (vitest's default when passWithNoTests is not explicitly false — if it fails with exit 1, add passWithNoTests: true under test: in the config).

If passWithNoTests is required, update the config:

  test: {
    environment: "node",
    globals: true,
    passWithNoTests: true,
    // ... rest unchanged
  },

Re-run pnpm test — expected exit 0.

  • Step 3: Commit
git add vitest.config.ts
git commit -m "Configure Vitest with @/ alias and v8 coverage"

Task 6 — Seed type-only logger file

Files:

  • Create: src/observability/logger/types.ts

Ownership note: This file is owned by 1G-logger going forward. 1A-1 seeds it now to break the plan-order cycle: src/host-contract.ts (created in Task 8) imports Logger from this file, so the type must exist before HostContract can typecheck. 1G-logger extends this file with runtime transports and the provider later. Do not add anything runtime-related here.

  • Step 1: Write src/observability/logger/types.ts
// Ownership: src/observability/logger/ is owned by Phase 1 sub-plan 1G-logger.
// This type-only seed exists because Phase 1A-1's HostContract depends on Logger.
// Sub-plan 1G-logger extends this file with runtime transports.

export type LogLevel = "debug" | "info" | "warn" | "error";

export type LogFields = Record<string, string | number | boolean | null | undefined>;

export interface Logger {
  debug(msg: string, fields?: LogFields): void;
  info(msg: string, fields?: LogFields): void;
  warn(msg: string, fields?: LogFields): void;
  error(msg: string, fields?: LogFields & { err?: Error }): void;
  child(context: LogFields): Logger;
}

export interface LogRecord {
  ts: string;
  level: LogLevel;
  msg: string;
  fields: LogFields;
}

export interface LogTransport {
  write(record: LogRecord): void;
  flush(): Promise<void>;
}
  • Step 2: Typecheck
pnpm typecheck

Expected: exit 0.

  • Step 3: Commit
git add src/observability/logger/types.ts
git commit -m "Seed type-only Logger surface for HostContract dependency"

Task 7 — Seed type-only analytics file

Files:

  • Create: src/observability/analytics/types.ts

Ownership note: Owned by 1G-analytics going forward. Seeded here so src/env/index.ts (Task 9) can type ANALYTICS_ENABLED: AnalyticsProviders without a forward reference.

  • Step 1: Write src/observability/analytics/types.ts
// Ownership: src/observability/analytics/ is owned by Phase 1 sub-plan 1G-analytics.
// This type-only seed exists because Phase 1A-1's Env depends on AnalyticsProviders.
// Sub-plan 1G-analytics extends this file with the facade and adapter implementations.

export interface AnalyticsProviders {
  metrica: boolean;
  ctm: boolean;
  variocube: boolean;
  dynatrace: boolean;
}

export interface AnalyticsProps {
  [key: string]: unknown;
}

export interface AnalyticsEvent {
  kind: "track" | "page";
  name: string;
  props: AnalyticsProps;
  provider: "metrica" | "ctm" | "variocube" | "dynatrace";
  ts: string;
}

export interface Analytics {
  track(event: string, props?: AnalyticsProps): void;
  page(url: string, props?: AnalyticsProps): void;
}
  • Step 2: Typecheck
pnpm typecheck

Expected: exit 0.

  • Step 3: Commit
git add src/observability/analytics/types.ts
git commit -m "Seed type-only AnalyticsProviders for Env dependency"

Task 8 — Create src/host-contract.ts

Files:

  • Create: src/host-contract.ts

  • Step 1: Write src/host-contract.ts

Reproduce the shape byte-for-byte from master plan §1A-1 contracts (which itself matches design spec §2.4):

import type { Logger } from "@/observability/logger/types";

/**
 * Contract a host application must implement to embed this component
 * as an MF 2.0 remote. Both standalone SSR and remote modes satisfy
 * this interface; features depend only on HostContract and never on
 * the hosting mode.
 */
export interface HostContract {
  /** Resolved locale, e.g. "ru", "en". */
  locale: string;
  /** Canonical origin for SEO (e.g. "https://flights.aeroflot.ru"). */
  canonicalOrigin: string;
  /** Optional deep-link navigation override from the host router. */
  navigate?: (path: string) => void;
  /** Optional consent flags; absent == assumed true. */
  consent?: { analytics: boolean; telemetry: boolean };
  /** Optional host logger for log stream merging. */
  logger?: Logger;
}
  • Step 2: Typecheck — verify the @/observability/... alias resolves
pnpm typecheck

Expected: exit 0. If TypeScript cannot resolve @/observability/logger/types, check that tsconfig.json paths matches Task 3 exactly.

  • Step 3: Commit
git add src/host-contract.ts
git commit -m "Add HostContract interface per design spec §2.4"

Task 9 — TDD src/env/index.ts with Zod validation

Files:

  • Create: src/env/env.test.ts
  • Create: src/env/index.ts

This is the only non-trivial runtime code in 1A-1; it gets full TDD treatment.

Behavioral contract (from master plan §1A-1):

  • getEnv() reads from process.env, validates via Zod, returns a typed Env object.

  • On malformed input, throws a readable error listing every failing field.

  • Subsequent calls should be cached (idempotent, cheap) — getEnv() is called from many places.

  • Tests override process.env and reset the cache between cases.

  • Step 1: Write the failing test

Create src/env/env.test.ts:

import { afterEach, beforeEach, describe, expect, it } from "vitest";

describe("getEnv", () => {
  const originalEnv = { ...process.env };

  beforeEach(() => {
    // Clear env to a clean slate, then set a minimum valid shape.
    for (const key of Object.keys(process.env)) {
      if (key.startsWith("VITEST_")) continue;
      delete process.env[key];
    }
    process.env["NODE_ENV"] = "testing";
    process.env["BUILD_TARGET"] = "standalone";
    process.env["PROD_ORIGIN"] = "https://flights.aeroflot.ru";
    process.env["API_BASE_URL"] = "https://platform.aeroflot.ru";
    process.env["SIGNALR_HUB_URL"] = "wss://platform.aeroflot.ru/hub";
    process.env["ANALYTICS_METRICA"] = "true";
    process.env["ANALYTICS_CTM"] = "false";
    process.env["ANALYTICS_VARIOCUBE"] = "false";
    process.env["ANALYTICS_DYNATRACE"] = "true";
    process.env["VERSION"] = "abc1234";
  });

  afterEach(async () => {
    process.env = { ...originalEnv };
    // Reset the module cache so getEnv re-reads the updated env.
    const mod = await import("./index.js");
    mod.__resetEnvCacheForTests();
  });

  it("returns a typed Env object for valid input", async () => {
    const { getEnv } = await import("./index.js");
    const env = getEnv();
    expect(env.NODE_ENV).toBe("testing");
    expect(env.BUILD_TARGET).toBe("standalone");
    expect(env.PROD_ORIGIN).toBe("https://flights.aeroflot.ru");
    expect(env.API_BASE_URL).toBe("https://platform.aeroflot.ru");
    expect(env.SIGNALR_HUB_URL).toBe("wss://platform.aeroflot.ru/hub");
    expect(env.ANALYTICS_ENABLED).toEqual({
      metrica: true,
      ctm: false,
      variocube: false,
      dynatrace: true,
    });
    expect(env.VERSION).toBe("abc1234");
    expect(env.OTEL_EXPORTER_OTLP_ENDPOINT).toBeUndefined();
  });

  it("caches the result across calls (same object identity)", async () => {
    const { getEnv } = await import("./index.js");
    const a = getEnv();
    const b = getEnv();
    expect(a).toBe(b);
  });

  it("throws a readable error when a required field is missing", async () => {
    delete process.env["API_BASE_URL"];
    const { getEnv, __resetEnvCacheForTests } = await import("./index.js");
    __resetEnvCacheForTests();
    expect(() => getEnv()).toThrow(/API_BASE_URL/);
  });

  it("throws when NODE_ENV is not one of the allowed values", async () => {
    process.env["NODE_ENV"] = "banana";
    const { getEnv, __resetEnvCacheForTests } = await import("./index.js");
    __resetEnvCacheForTests();
    expect(() => getEnv()).toThrow(/NODE_ENV/);
  });

  it("throws when BUILD_TARGET is neither standalone nor remote", async () => {
    process.env["BUILD_TARGET"] = "hybrid";
    const { getEnv, __resetEnvCacheForTests } = await import("./index.js");
    __resetEnvCacheForTests();
    expect(() => getEnv()).toThrow(/BUILD_TARGET/);
  });

  it("accepts optional OTEL_EXPORTER_OTLP_ENDPOINT when provided", async () => {
    process.env["OTEL_EXPORTER_OTLP_ENDPOINT"] = "https://otel.example/v1/traces";
    const { getEnv, __resetEnvCacheForTests } = await import("./index.js");
    __resetEnvCacheForTests();
    const env = getEnv();
    expect(env.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("https://otel.example/v1/traces");
  });

  it("rejects OTEL_EXPORTER_OTLP_ENDPOINT that is not a URL", async () => {
    process.env["OTEL_EXPORTER_OTLP_ENDPOINT"] = "not-a-url";
    const { getEnv, __resetEnvCacheForTests } = await import("./index.js");
    __resetEnvCacheForTests();
    expect(() => getEnv()).toThrow(/OTEL_EXPORTER_OTLP_ENDPOINT/);
  });
});
  • Step 2: Run the test to verify it fails
pnpm test src/env

Expected: FAIL — Cannot find module './index.js' or similar. The file does not exist yet.

  • Step 3: Write minimal implementation

Create src/env/index.ts:

import { z } from "zod";
import type { AnalyticsProviders } from "@/observability/analytics/types";

const boolish = z
  .enum(["true", "false", "1", "0"])
  .transform((v) => v === "true" || v === "1");

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "testing", "staging", "production"]),
  BUILD_TARGET: z.enum(["standalone", "remote"]),
  PROD_ORIGIN: z.string().url(),
  API_BASE_URL: z.string().url(),
  SIGNALR_HUB_URL: z.string().url(),
  OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
  OTEL_EXPORTER_OTLP_HEADERS: z.string().optional(),
  LOGS_ENDPOINT: z.string().url().optional(),
  ANALYTICS_METRICA: boolish.default("false"),
  ANALYTICS_CTM: boolish.default("false"),
  ANALYTICS_VARIOCUBE: boolish.default("false"),
  ANALYTICS_DYNATRACE: boolish.default("false"),
  VERSION: z.string().min(1),
});

type RawEnv = z.infer<typeof EnvSchema>;

export interface Env {
  NODE_ENV: RawEnv["NODE_ENV"];
  BUILD_TARGET: RawEnv["BUILD_TARGET"];
  PROD_ORIGIN: string;
  API_BASE_URL: string;
  SIGNALR_HUB_URL: string;
  OTEL_EXPORTER_OTLP_ENDPOINT?: string;
  OTEL_EXPORTER_OTLP_HEADERS?: string;
  LOGS_ENDPOINT?: string;
  ANALYTICS_ENABLED: AnalyticsProviders;
  VERSION: string;
}

let cached: Env | undefined;

export function getEnv(): Env {
  if (cached) return cached;

  const parsed = EnvSchema.safeParse(process.env);
  if (!parsed.success) {
    const details = parsed.error.issues
      .map((i) => `${i.path.join(".")}: ${i.message}`)
      .join("; ");
    throw new Error(`Invalid environment configuration: ${details}`);
  }

  const raw = parsed.data;
  cached = {
    NODE_ENV: raw.NODE_ENV,
    BUILD_TARGET: raw.BUILD_TARGET,
    PROD_ORIGIN: raw.PROD_ORIGIN,
    API_BASE_URL: raw.API_BASE_URL,
    SIGNALR_HUB_URL: raw.SIGNALR_HUB_URL,
    OTEL_EXPORTER_OTLP_ENDPOINT: raw.OTEL_EXPORTER_OTLP_ENDPOINT,
    OTEL_EXPORTER_OTLP_HEADERS: raw.OTEL_EXPORTER_OTLP_HEADERS,
    LOGS_ENDPOINT: raw.LOGS_ENDPOINT,
    ANALYTICS_ENABLED: {
      metrica: raw.ANALYTICS_METRICA,
      ctm: raw.ANALYTICS_CTM,
      variocube: raw.ANALYTICS_VARIOCUBE,
      dynatrace: raw.ANALYTICS_DYNATRACE,
    },
    VERSION: raw.VERSION,
  };
  return cached;
}

/**
 * Test-only: resets the module-level cache so tests can mutate process.env
 * and re-read. Do NOT call this from production code.
 */
export function __resetEnvCacheForTests(): void {
  cached = undefined;
}
  • Step 4: Run the tests to verify they pass
pnpm test src/env

Expected: all 7 tests PASS.

If tests fail with module-resolution errors on ./index.js, check the vitest.config.ts alias and TypeScript moduleResolution: "Bundler" setting from Task 3.

  • Step 5: Typecheck and lint the new files
pnpm typecheck && pnpm lint

Expected: both exit 0.

  • Step 6: Commit
git add src/env/index.ts src/env/env.test.ts
git commit -m "Add Zod-validated getEnv() reader with module-level cache"

Task 10 — Create empty feature + UI barrels (frozen public surface)

Files:

  • Create: src/features/online-board/index.ts

  • Create: src/features/schedule/index.ts

  • Create: src/features/flights-map/index.ts

  • Create: src/features/popular-requests/index.ts

  • Create: src/ui/index.ts

  • Step 1: Create all five barrel files, each with an identical stub

Run for each feature:

mkdir -p src/features/online-board src/features/schedule src/features/flights-map src/features/popular-requests src/ui

Write src/features/online-board/index.ts:

// Public barrel for the online-board feature.
// This file is the ONLY public surface — other sub-plans and features
// must import exclusively from "@/features/online-board", never from
// deeper paths. See docs/superpowers/phase-1/frozen-barrels.md for the rule.
export {};

Write src/features/schedule/index.ts:

// Public barrel for the schedule feature. See frozen-barrels.md.
export {};

Write src/features/flights-map/index.ts:

// Public barrel for the flights-map feature. See frozen-barrels.md.
export {};

Write src/features/popular-requests/index.ts:

// Public barrel for the popular-requests feature. See frozen-barrels.md.
export {};

Write src/ui/index.ts:

// Public barrel for the UI adapter layer. See frozen-barrels.md.
// Feature code imports UI primitives exclusively through this barrel.
export {};
  • Step 2: Delete the placeholder from Task 3 if it exists
rm -f src/.typecheck-placeholder.ts
  • Step 3: Typecheck and lint
pnpm typecheck && pnpm lint

Expected: both exit 0.

  • Step 4: Commit
git add src/features/ src/ui/
git rm -f src/.typecheck-placeholder.ts 2>/dev/null || true
git commit -m "Seed frozen public barrels for 4 features + UI adapter"

Task 11 — Document the frozen-barrel rule and rename-pass rework plan

Files:

  • Create: docs/superpowers/phase-1/frozen-barrels.md

  • Create: docs/superpowers/phase-1/rename-pass-plan.md

  • Step 1: Write docs/superpowers/phase-1/frozen-barrels.md

# Frozen public barrels

**Rule.** Cross-module imports inside `src/` go through exactly these five public entries:

- `@/features/online-board`
- `@/features/schedule`
- `@/features/flights-map`
- `@/features/popular-requests`
- `@/ui`

No file outside `src/features/<feature>/` may import from `src/features/<feature>/components/...` or any deeper path. No file outside `src/ui/` may import `src/ui/primitives/Button`. Enforcement lands in sub-plan **1A-3** via `eslint-plugin-boundaries` + `no-restricted-imports`.

**Why this is frozen.** Phase 0 assumption **A1** (customer's standard remote-frontend module template) may arrive after 1A-1 ships. When it does, the rename pass documented in `rename-pass-plan.md` must be a mechanical move: rename directories, update import paths at the five barrels, done. If cross-module imports fan out through deep paths, the rename becomes a surgery across dozens of files.

**What this unblocks.** Every Phase 1 sub-plan can add *internal* files to a feature/UI directory without coordinating with other sub-plans, because nothing outside the barrel depends on internals. The barrel file itself is the review gate.

**What this costs.** Small friction when a sub-plan wants to expose a new symbol — it must update the barrel. Acceptable cost for the refactor safety it buys.
  • Step 2: Write docs/superpowers/phase-1/rename-pass-plan.md
# A1 rename-pass rework plan

**Trigger.** Phase 0 assumption **A1** — "customer's standard remote-frontend module template" — resolves to a directory layout that differs from the one this repo uses.

**Scope.** Move/rename directories inside `src/` to match the customer template. Update import paths *only at the five frozen public barrels* (see `frozen-barrels.md`). Do not restructure feature internals.

**Preconditions.**
- The frozen-barrel rule has been enforced since 1A-1 (Task 11) and 1A-3 ESLint rules are passing on `main`.
- Customer template document is in hand and reviewed for explicit directory conventions.

**Steps (to be fleshed out when A1 resolves).**
1. Create a target-layout scratch file mapping current path → new path for every file under `src/`.
2. Run the rename as a single automated pass (`git mv`) inside an isolated worktree.
3. Update `tsconfig.json` `paths` aliases if the top-level segments change.
4. Update `vitest.config.ts` aliases to match.
5. Update the five barrel files — this is the *only* hand-edit needed for consumer code.
6. Run `pnpm typecheck && pnpm lint && pnpm test` — green before commit.
7. Run all Phase 1 exit-gate checks (from master plan) — green before PR.
8. Single commit: `Rename src/ layout to match customer module template (A1)`.

**Escape valve.** If the rename touches more than the five barrels, something violated the frozen-barrel rule between 1A-1 and now. Fix the violation first (move the cross-boundary import through a barrel), then retry the rename.

**Owner.** This task is attached to 1A-1's exit gate and fires on A1 resolution, whether that happens during Phase 1 or early Phase 2.
  • Step 3: Commit
git add docs/superpowers/phase-1/
git commit -m "Document frozen-barrel rule and A1 rename-pass rework plan"

Task 12 — Full exit-gate verification

Files:

  • None (verification only).

  • Step 1: Fresh install from lockfile

rm -rf node_modules && pnpm install --frozen-lockfile

Expected: install succeeds, lockfile unchanged.

  • Step 2: Typecheck
pnpm typecheck

Expected: exit 0, no output.

  • Step 3: Lint
pnpm lint

Expected: exit 0, no errors or warnings.

  • Step 4: Tests
pnpm test

Expected: 7 env tests pass, no other test files.

  • Step 5: Coverage sanity check
pnpm test:coverage

Expected: exit 0. src/env/index.ts reports near-100% coverage. Type-only files (logger/types.ts, analytics/types.ts, host-contract.ts) are excluded per vitest.config.ts, so they do not appear in the coverage report.

  • Step 6: Verify the sub-plan directory listing matches the deliverables
ls -la src/ src/env/ src/observability/logger/ src/observability/analytics/ src/features/ src/ui/

Expected output shape:

  • src/env/index.ts and src/env/env.test.ts

  • src/host-contract.ts

  • src/observability/logger/types.ts

  • src/observability/analytics/types.ts

  • Four feature directories each with index.ts

  • src/ui/index.ts

  • No stray files (.typecheck-placeholder.ts must be gone).

  • Step 7: Verify Phase 0 and other sub-plans are unaffected

git status

Expected: clean working tree on plan/react-rewrite. ClientApp/, Startup.cs, Program.cs, Aeroflot.Flights.Web.csproj, wwwroot/ untouched.

  • Step 8: Final commit (exit-gate marker)

If there are any stray uncommitted files from verification steps, commit them. Otherwise no-op:

git status --porcelain | grep -q . && git commit -am "1A-1 exit gate verification" || echo "nothing to commit"

Self-review

Spec coverage. Every item in the master plan §1A-1 "Exports" bullet list maps to a task:

  • src/ layout (§1.3) → Task 5 (vitest.config), Task 610 (files created), Task 10 (barrels)
  • tsconfig.json → Task 3
  • .eslintrc.cjs base → Task 4
  • package.json scripts → Task 1
  • package.json dep ownership (zod) → Task 2
  • src/env/index.ts Zod-validated → Task 9 (TDD)
  • src/host-contract.ts → Task 8
  • Empty feature + UI barrels → Task 10
  • Frozen barrel rule documented → Task 11
  • Rename-pass rework task attached → Task 11
  • Node 24 pinned → Task 1

Design spec §1.3 (src tree) coverage: the top-level directories actually materialized by 1A-1 are src/env/, src/features/<four>/, src/observability/logger/, src/observability/analytics/, src/ui/, plus src/host-contract.ts. Other directories from §1.3 (src/routes/, src/mf/, src/shared/, src/i18n/, src/observability/metrics/) are not created by 1A-1 — they're created by the sub-plans that own them (1A-2, 1C, 1D, 1G-metrics, 1F-layout). This is consistent with the master plan's sub-plan ownership table.

Placeholder scan. No TBD / TODO / FIXME / "fill in later" in this plan. The "echo ... exit 1" stubs in package.json scripts are deliberate placeholders guarded by explicit error exits, not forgotten work.

Type consistency.

  • Logger interface: defined once in Task 6, imported by HostContract in Task 8. Same shape in both places.
  • AnalyticsProviders interface: defined once in Task 7, imported by Env in Task 9. Same shape.
  • Env interface: defined in Task 9, returned by getEnv(). Properties match the master plan §1A-1 contract.
  • HostContract interface: Task 8 shape matches master plan §1A-1 and design spec §2.4 byte-for-byte.

Test-plan scan. The only runtime module with logic in 1A-1 is src/env/index.ts. Its test file covers: happy path, cache identity, missing required field, enum violations (two), optional field present, optional field malformed. Seven tests, all behavior branches touched. Config files, type-only files, and barrels have no logic to test.

Commit hygiene. Twelve tasks produce 11 commits (Task 12 is verification-only). Each commit message is focused on why not what, in English, no Co-Authored-By. Commits are small, logical, and independently revertible.


Execution handoff

Plan complete and saved to docs/superpowers/plans/2026-04-14-phase-1a1-skeleton.md. Two execution options:

1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration.

2. Inline Execution — Execute tasks in this session using superpowers:executing-plans, batch execution with checkpoints.

Which approach?