From 59a94b50b9aaadc377127af0db00c997ebec38a6 Mon Sep 17 00:00:00 2001 From: gnezim Date: Tue, 14 Apr 2026 21:42:49 +0300 Subject: [PATCH] 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. --- .../plans/2026-04-14-phase-1a1-skeleton.md | 1026 +++++++++++++++++ 1 file changed, 1026 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-14-phase-1a1-skeleton.md diff --git a/docs/superpowers/plans/2026-04-14-phase-1a1-skeleton.md b/docs/superpowers/plans/2026-04-14-phase-1a1-skeleton.md new file mode 100644 index 00000000..30725d70 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-1a1-skeleton.md @@ -0,0 +1,1026 @@ +# 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: +```bash +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: + +```json +{ + "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** + +```bash +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** + +```bash +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)** + +```bash +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** + +```bash +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** + +```bash +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`** + +```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)** + +```bash +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: +```bash +mkdir -p src +echo "export {};" > src/.typecheck-placeholder.ts +pnpm typecheck +``` +Expected: exits `0` silently. + +- [ ] **Step 3: Commit** + +```bash +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**. + +```javascript +/** @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** + +```bash +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** + +```bash +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`** + +```typescript +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** + +```bash +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: + +```typescript + test: { + environment: "node", + globals: true, + passWithNoTests: true, + // ... rest unchanged + }, +``` + +Re-run `pnpm test` — expected exit `0`. + +- [ ] **Step 3: Commit** + +```bash +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`** + +```typescript +// 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; + +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; +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +pnpm typecheck +``` +Expected: exit `0`. + +- [ ] **Step 3: Commit** + +```bash +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`** + +```typescript +// 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** + +```bash +pnpm typecheck +``` +Expected: exit `0`. + +- [ ] **Step 3: Commit** + +```bash +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): + +```typescript +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** + +```bash +pnpm typecheck +``` +Expected: exit `0`. If TypeScript cannot resolve `@/observability/logger/types`, check that `tsconfig.json` `paths` matches Task 3 exactly. + +- [ ] **Step 3: Commit** + +```bash +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`: + +```typescript +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** + +```bash +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`: + +```typescript +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; + +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** + +```bash +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** + +```bash +pnpm typecheck && pnpm lint +``` +Expected: both exit `0`. + +- [ ] **Step 6: Commit** + +```bash +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: + +```bash +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`: + +```typescript +// 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`: + +```typescript +// Public barrel for the schedule feature. See frozen-barrels.md. +export {}; +``` + +Write `src/features/flights-map/index.ts`: + +```typescript +// Public barrel for the flights-map feature. See frozen-barrels.md. +export {}; +``` + +Write `src/features/popular-requests/index.ts`: + +```typescript +// Public barrel for the popular-requests feature. See frozen-barrels.md. +export {}; +``` + +Write `src/ui/index.ts`: + +```typescript +// 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** + +```bash +rm -f src/.typecheck-placeholder.ts +``` + +- [ ] **Step 3: Typecheck and lint** + +```bash +pnpm typecheck && pnpm lint +``` +Expected: both exit `0`. + +- [ ] **Step 4: Commit** + +```bash +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`** + +```markdown +# 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//` may import from `src/features//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`** + +```markdown +# 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** + +```bash +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** + +```bash +rm -rf node_modules && pnpm install --frozen-lockfile +``` +Expected: install succeeds, lockfile unchanged. + +- [ ] **Step 2: Typecheck** + +```bash +pnpm typecheck +``` +Expected: exit `0`, no output. + +- [ ] **Step 3: Lint** + +```bash +pnpm lint +``` +Expected: exit `0`, no errors or warnings. + +- [ ] **Step 4: Tests** + +```bash +pnpm test +``` +Expected: 7 env tests pass, no other test files. + +- [ ] **Step 5: Coverage sanity check** + +```bash +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** + +```bash +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** + +```bash +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: + +```bash +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 6–10 (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//`, `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?**