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.
34 KiB
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):
.nvmrcpinned to Node 24.- Root
package.jsonwith scriptsdev,build:standalone,build:remote,build:both,test,test:coverage,lint,typecheck. (Non-build scripts are stubs until 1A-2 wires Modern.js.) tsconfig.json— strict,noUncheckedIndexedAccess,isolatedModules, path aliases@/*→src/*,@phase0/*→scripts/phase-0/*..eslintrc.cjsbaseline (no boundary rules yet).vitest.config.tswired to the@/alias.src/directory with:src/env/index.ts— Zod-validatedEnvreader (and its test).src/host-contract.ts— theHostContractinterface.src/observability/logger/types.ts— type-onlyLogger,LogFields,LogLevel,LogRecord,LogTransport(seeded here, owned by 1G-logger going forward).src/observability/analytics/types.ts— type-onlyAnalyticsProviders,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.
docs/superpowers/phase-1/frozen-barrels.md— documents the frozen public-surface rule.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
.nvmrcto 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 typecheckruns 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 teston 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 fromprocess.env, validates via Zod, returns a typedEnvobject. -
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.envand 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.tsandsrc/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.tsmust 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 6–10 (files created), Task 10 (barrels)
tsconfig.json→ Task 3.eslintrc.cjsbase → Task 4package.jsonscripts → Task 1package.jsondep ownership (zod) → Task 2src/env/index.tsZod-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.
Loggerinterface: defined once in Task 6, imported byHostContractin Task 8. Same shape in both places.AnalyticsProvidersinterface: defined once in Task 7, imported byEnvin Task 9. Same shape.Envinterface: defined in Task 9, returned bygetEnv(). Properties match the master plan §1A-1 contract.HostContractinterface: 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?