diff --git a/docs/superpowers/plans/2026-04-14-phase-0-preflight.md b/docs/superpowers/plans/2026-04-14-phase-0-preflight.md new file mode 100644 index 00000000..d5ad1217 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-0-preflight.md @@ -0,0 +1,2246 @@ +# Phase 0 — Preflight 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:** Capture everything needed from the current Angular prod app (URL corpus, SEO baselines, VRT baselines, component inventories, translation keys) and confirm customer-side spec assumptions, so Phase 1 foundation work can proceed without blockers. + +**Architecture:** Discovery-only phase. No production change. Produces committed fixtures under `tests/fixtures/phase-0/` and inventory documents under `docs/superpowers/phase-0/` that Phase 1 and later phases depend on. Introduces a minimal top-level `package.json` + `scripts/phase-0/` at the repo root; no framework or runtime is installed beyond what the capture scripts need. + +**Tech Stack:** Node 20, pnpm, TypeScript + `tsx`, Playwright, `cheerio`, `zod`, `schema-dts`, Vitest (for the few pure-function tests in this phase). + +**Reference spec:** `docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md` — Phase 0 is described in §9.2 of that document. + +--- + +## Prerequisites — block Phase 0 start until resolved + +These are not tasks; they are gates. Confirm each before running Task 1. + +- [ ] **Production URL access.** The capture scripts fetch pages from the live Angular app. Get the production URL (likely `https://flights.aeroflot.ru` or equivalent). If prod is rate-limited, get an unthrottled staging mirror URL that is byte-equivalent to prod. +- [ ] **Access-log availability decision.** The URL-corpus task is ideally driven by anonymized prod access logs. If those are unavailable, fall back to enumerating Angular route definitions directly (Task 4 supports both modes). +- [ ] **Customer point-of-contact.** Identify who will answer the spec assumptions A1–A5 listed in `docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md` §10. Task 16 produces the questionnaire; a human must deliver it. + +--- + +## File structure + +Files created by Phase 0: + +**Repo root — minimal project skeleton (extended in Phase 1):** +``` +package.json # minimal — only scripts/phase-0 deps +pnpm-lock.yaml # frozen +pnpm-workspace.yaml # excludes ClientApp/ from workspace +.nvmrc # 20.11.0 +tsconfig.json # root TS config for scripts +.gitignore # ignores dist/, node_modules/, playwright-report/, tests/fixtures/phase-0/vrt-baselines/raw/ +playwright.phase0.config.ts # Phase-0-only Playwright config +``` + +**Phase 0 scripts:** +``` +scripts/phase-0/lib/angular-routes.ts # list of Angular route shapes (source of truth for URL enumeration) +scripts/phase-0/lib/http.ts # small fetch wrapper with retry + throttle +scripts/phase-0/lib/io.ts # read/write JSON + PNG helpers +scripts/phase-0/anonymize-access-logs.ts # optional: raw log → anonymized URL list +scripts/phase-0/extract-url-corpus.ts # writes url-corpus/*.json from routes + optional logs +scripts/phase-0/capture-seo-baselines.ts # fetches pages, extracts + JSON-LD +scripts/phase-0/capture-hreflang-parity.ts # verifies reciprocal hreflang on every language variant +scripts/phase-0/capture-vrt-baselines.ts # Playwright driver for VRT baseline capture +scripts/phase-0/inventory-primeng.ts # scans ClientApp/ for PrimeNG usages +scripts/phase-0/inventory-scss-tokens.ts # extracts SCSS variable + theme override list +scripts/phase-0/inventory-translation-keys.ts # finds @ngx-translate keys actually referenced in templates/TS +``` + +**Committed fixtures:** +``` +tests/fixtures/phase-0/url-corpus/onlineboard.json # enumerated + observed URLs +tests/fixtures/phase-0/url-corpus/schedule.json +tests/fixtures/phase-0/url-corpus/flights-map.json +tests/fixtures/phase-0/url-corpus/popular.json +tests/fixtures/phase-0/seo-baselines/.json # ~20 files +tests/fixtures/phase-0/hreflang-parity/.json +tests/fixtures/phase-0/vrt-baselines/--.png # ~60 files +``` + +**Committed documents:** +``` +docs/superpowers/phase-0/README.md # index of Phase 0 deliverables +docs/superpowers/phase-0/primeng-backlog.md # PrimeNG component inventory +docs/superpowers/phase-0/scss-theme-manifest.md # SCSS token/theme port list +docs/superpowers/phase-0/translation-keys-used.md # which keys are live +docs/superpowers/phase-0/customer-confirmation-checklist.md # A1–A5 + answers +``` + +--- + +## Task 1: Attach spec commit to a working branch + +The spec commit `75dbec0` currently sits on detached HEAD. Before any other work, move it to a branch. + +**Files:** +- None (git operations only) + +- [ ] **Step 1: Create working branch from current HEAD** + +```bash +git checkout -b feat/react-migration-phase0 +``` + +Expected: `Switched to a new branch 'feat/react-migration-phase0'` + +- [ ] **Step 2: Verify the spec commit is reachable from the new branch** + +```bash +git log --oneline -3 +``` + +Expected first line: `75dbec0 Add design spec for Angular-to-React MF remote rewrite` + +- [ ] **Step 3: Push the branch to origin** + +```bash +git push -u origin feat/react-migration-phase0 +``` + +Expected: new remote branch created; tracking set. + +--- + +## Task 2: Root project scaffolding (package.json + TS config + gitignore) + +Create the minimal project skeleton at the repo root. This is additive — the existing `ClientApp/` Angular app is untouched and explicitly excluded from the workspace. + +**Files:** +- Create: `package.json` +- Create: `pnpm-workspace.yaml` +- Create: `.nvmrc` +- Create: `tsconfig.json` +- Modify: `.gitignore` (create if missing) + +- [ ] **Step 1: Pin Node version** + +Create `.nvmrc` at repo root with exactly: +``` +20.11.0 +``` + +- [ ] **Step 2: Create minimal root `package.json`** + +Create `package.json` at repo root: + +```json +{ + "name": "aeroflot-flights-web", + "private": true, + "version": "0.0.0", + "packageManager": "pnpm@9.12.0", + "engines": { + "node": ">=20.11.0" + }, + "scripts": { + "phase0:url-corpus": "tsx scripts/phase-0/extract-url-corpus.ts", + "phase0:seo": "tsx scripts/phase-0/capture-seo-baselines.ts", + "phase0:hreflang": "tsx scripts/phase-0/capture-hreflang-parity.ts", + "phase0:vrt": "playwright test --config=playwright.phase0.config.ts", + "phase0:inventory:primeng": "tsx scripts/phase-0/inventory-primeng.ts", + "phase0:inventory:scss": "tsx scripts/phase-0/inventory-scss-tokens.ts", + "phase0:inventory:i18n": "tsx scripts/phase-0/inventory-translation-keys.ts", + "test": "vitest run" + }, + "devDependencies": {} +} +``` + +- [ ] **Step 3: Exclude `ClientApp/` from the workspace** + +Create `pnpm-workspace.yaml`: + +```yaml +packages: + - '.' + - '!ClientApp' +``` + +- [ ] **Step 4: Create root `tsconfig.json`** + +Create `tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "types": ["node"], + "baseUrl": ".", + "paths": { + "@phase0/*": ["scripts/phase-0/*"] + } + }, + "include": ["scripts/**/*.ts", "tests/**/*.ts"], + "exclude": ["ClientApp", "node_modules", "dist"] +} +``` + +- [ ] **Step 5: Update `.gitignore`** + +Add these lines to `.gitignore` (create the file if it doesn't exist at the repo root): + +``` +# Phase 0 + later +node_modules/ +dist/ +.DS_Store +playwright-report/ +test-results/ +.playwright/ + +# Ephemeral capture artifacts (final baselines are committed, raw intermediate files are not) +tests/fixtures/phase-0/vrt-baselines/raw/ +``` + +- [ ] **Step 6: Commit scaffolding** + +```bash +git add package.json pnpm-workspace.yaml .nvmrc tsconfig.json .gitignore +git commit -m "Add root project scaffolding for React rewrite + +Phase 0 introduces a minimal top-level package.json + TS config at the +repo root. ClientApp/ is excluded from the workspace so the Angular app +keeps its own dependency tree during the strangler-fig migration." +``` + +--- + +## Task 3: Install Phase 0 toolchain + +Install the minimal dependency set needed for Phase 0 capture scripts. Do NOT install Modern.js, React, or anything else — that happens in Phase 1A. + +**Files:** +- Modify: `package.json` +- Create: `pnpm-lock.yaml` + +- [ ] **Step 1: Install dev dependencies** + +```bash +pnpm add -D \ + typescript@^5.5.0 \ + tsx@^4.19.0 \ + @types/node@^20.11.0 \ + playwright@^1.47.0 \ + @playwright/test@^1.47.0 \ + cheerio@^1.0.0 \ + zod@^3.23.0 \ + schema-dts@^1.1.2 \ + vitest@^2.1.0 +``` + +Expected: `pnpm-lock.yaml` created; `node_modules/` populated; no warnings about peer dependency conflicts. + +- [ ] **Step 2: Install Playwright browsers** + +```bash +pnpm exec playwright install chromium +``` + +Expected: Chromium downloaded to Playwright's cache. + +- [ ] **Step 3: Verify `tsx` runs a trivial script** + +Create a throwaway sanity check — do not commit: + +```bash +echo 'console.log("tsx works", process.version)' > /tmp/sanity.ts +pnpm exec tsx /tmp/sanity.ts +rm /tmp/sanity.ts +``` + +Expected output: `tsx works v20.x.x` + +- [ ] **Step 4: Commit lockfile + dep additions** + +```bash +git add package.json pnpm-lock.yaml +git commit -m "Install Phase 0 capture toolchain + +Minimal dependency set for Phase 0 scripts: Playwright (VRT + SEO +capture), cheerio (HTML parsing), zod (fixture schema validation), +schema-dts (JSON-LD types), tsx + typescript. No framework yet." +``` + +--- + +## Task 4: URL corpus — enumerate Angular route shapes + +The URL corpus is the source of truth for every URL the Angular app currently serves. It drives SEO capture, VRT capture, and the Phase 2 URL parity tests. Two sources: (a) the static route definitions in `ClientApp/src/`, which gives us every *shape* of URL; (b) optionally, anonymized access logs, which give us *real observed values* for the dynamic segments. + +This task covers (a). Task 5 covers (b). + +**Files:** +- Create: `scripts/phase-0/lib/angular-routes.ts` +- Create: `scripts/phase-0/lib/io.ts` +- Create: `tests/phase-0/lib/angular-routes.test.ts` + +- [ ] **Step 1: Read the Angular route definitions to build the list** + +Before writing code, read these files in `ClientApp/src/app/` to confirm the route shapes: + +```bash +cat ClientApp/src/app/app-routing.module.ts +``` + +```bash +find ClientApp/src/app/features -name '*-routing.module.ts' -exec echo '===' {} ';' -exec cat {} ';' +``` + +Record the route shapes and their param-parsing rules. You'll hand-transcribe them into the next step — the Angular files are the authoritative source. + +- [ ] **Step 2: Write the route-shape catalog** + +Create `scripts/phase-0/lib/angular-routes.ts`: + +```ts +/** + * Catalog of Angular route shapes, authored by hand from the current + * ClientApp/src/app routing modules. This is the source of truth for + * URL enumeration in Phase 0. + * + * Each entry is a template with :params placeholders. The sample values + * below are representative defaults used when access logs are unavailable; + * a real Phase 0 run should prefer observed values from access logs + * (see extract-url-corpus.ts). + */ + +export type RouteFeature = "onlineboard" | "schedule" | "flights-map" | "popular"; + +export interface RouteShape { + feature: RouteFeature; + /** Human-readable route slug used as a fixture filename. */ + slug: string; + /** Template with {placeholders}, no language prefix (added per language at enumeration time). */ + template: string; + /** Representative sample values used when no observed values are available. */ + samples: Array>; +} + +export const LANGUAGES = ["ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de"] as const; +export type Language = (typeof LANGUAGES)[number]; + +export const ROUTE_SHAPES: RouteShape[] = [ + { + feature: "onlineboard", + slug: "onlineboard-start", + template: "/onlineboard", + samples: [{}], + }, + { + feature: "onlineboard", + slug: "onlineboard-flight", + template: "/onlineboard/flight/{flightNumber}-{date}", + samples: [ + { flightNumber: "SU100", date: "2025-01-15" }, + { flightNumber: "SU0001", date: "2025-06-01" }, + ], + }, + { + feature: "onlineboard", + slug: "onlineboard-departure", + template: "/onlineboard/departure/{airport}-{date}", + samples: [ + { airport: "SVO", date: "2025-01-15" }, + { airport: "LED", date: "2025-06-01" }, + ], + }, + { + feature: "onlineboard", + slug: "onlineboard-arrival", + template: "/onlineboard/arrival/{airport}-{date}", + samples: [ + { airport: "JFK", date: "2025-01-15" }, + { airport: "DXB", date: "2025-06-01" }, + ], + }, + { + feature: "onlineboard", + slug: "onlineboard-route", + template: "/onlineboard/route/{departure}-{arrival}-{date}", + samples: [ + { departure: "SVO", arrival: "JFK", date: "2025-01-15" }, + { departure: "LED", arrival: "DXB", date: "2025-06-01" }, + ], + }, + { + feature: "onlineboard", + slug: "onlineboard-details", + template: "/onlineboard/{flightNumber}-{date}", + samples: [{ flightNumber: "SU100", date: "2025-01-15" }], + }, + { + feature: "schedule", + slug: "schedule-start", + template: "/schedule", + samples: [{}], + }, + { + feature: "schedule", + slug: "schedule-oneway", + template: "/schedule/route/{departure}-{arrival}-{date}", + samples: [{ departure: "SVO", arrival: "JFK", date: "2025-01-15" }], + }, + { + feature: "schedule", + slug: "schedule-roundtrip", + template: "/schedule/route/{departure}-{arrival}-{date}/{returnDeparture}-{returnArrival}-{returnDate}", + samples: [ + { + departure: "SVO", + arrival: "JFK", + date: "2025-01-15", + returnDeparture: "JFK", + returnArrival: "SVO", + returnDate: "2025-01-22", + }, + ], + }, + { + feature: "schedule", + slug: "schedule-multileg", + template: "/schedule/{legs}", + samples: [{ legs: "SU0001-2025-01-15/SU0002-2025-01-15" }], + }, + { + feature: "flights-map", + slug: "flights-map-start", + template: "/flights-map", + samples: [{}], + }, + { + feature: "flights-map", + slug: "flights-map-route", + template: "/flights-map/route/{departure}-{arrival}", + samples: [{ departure: "SVO", arrival: "JFK" }], + }, + { + feature: "popular", + slug: "popular-start", + template: "/popular", + samples: [{}], + }, +]; + +/** Substitute {placeholders} in a template with concrete values. */ +export function renderTemplate(template: string, values: Record): string { + return template.replace(/\{(\w+)\}/g, (_, key) => { + const value = values[key]; + if (value === undefined) { + throw new Error(`renderTemplate: missing value for '${key}' in template '${template}'`); + } + return value; + }); +} + +/** Prepend a language prefix and the canonical origin to a rendered route. */ +export function buildUrl(origin: string, lang: Language, path: string): string { + const normalized = path.startsWith("/") ? path : `/${path}`; + return `${origin}/${lang}${normalized}`; +} +``` + +- [ ] **Step 3: Write a unit test for `renderTemplate` + `buildUrl`** + +Create `tests/phase-0/lib/angular-routes.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { renderTemplate, buildUrl } from "@phase0/lib/angular-routes"; + +describe("renderTemplate", () => { + it("substitutes a single placeholder", () => { + expect(renderTemplate("/onlineboard/{flightNumber}-{date}", { flightNumber: "SU100", date: "2025-01-15" })) + .toBe("/onlineboard/SU100-2025-01-15"); + }); + + it("substitutes multiple placeholders of the same name (first occurrence rule is fine because there are no duplicates in our catalog)", () => { + expect(renderTemplate("{a}/{b}", { a: "x", b: "y" })).toBe("x/y"); + }); + + it("throws on a missing value", () => { + expect(() => renderTemplate("/{missing}", {})).toThrow(/missing value for 'missing'/); + }); + + it("leaves a literal path untouched", () => { + expect(renderTemplate("/onlineboard", {})).toBe("/onlineboard"); + }); +}); + +describe("buildUrl", () => { + it("joins origin + lang + path", () => { + expect(buildUrl("https://flights.aeroflot.ru", "ru", "/onlineboard")) + .toBe("https://flights.aeroflot.ru/ru/onlineboard"); + }); + + it("inserts a leading slash if the path lacks one", () => { + expect(buildUrl("https://flights.aeroflot.ru", "en", "onlineboard")) + .toBe("https://flights.aeroflot.ru/en/onlineboard"); + }); +}); +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +```bash +pnpm test +``` + +Expected: 6 passing tests, 0 failing. + +- [ ] **Step 5: Create the small JSON I/O helper** + +Create `scripts/phase-0/lib/io.ts`: + +```ts +import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { dirname } from "node:path"; + +export function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(value, null, 2) + "\n", "utf8"); +} + +export function readJson(path: string): T { + return JSON.parse(readFileSync(path, "utf8")) as T; +} + +export function fileExists(path: string): boolean { + return existsSync(path); +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add scripts/phase-0/lib/angular-routes.ts scripts/phase-0/lib/io.ts tests/phase-0/lib/angular-routes.test.ts +git commit -m "Catalog Angular route shapes for Phase 0 URL enumeration + +Hand-transcribed from ClientApp/src/app routing modules. Source of truth +for URL corpus, SEO capture, and VRT capture driver scripts that follow." +``` + +--- + +## Task 5: Optional — anonymize prod access logs into an observed-URL list + +Only run this task if you have access to raw access logs. If not, skip to Task 6 — the URL corpus will be built from the route shapes alone. + +**Files:** +- Create: `scripts/phase-0/anonymize-access-logs.ts` + +- [ ] **Step 1: Write the anonymizer** + +Create `scripts/phase-0/anonymize-access-logs.ts`: + +```ts +#!/usr/bin/env tsx +/** + * Reads raw access log lines from stdin, extracts URL paths that match + * the /{lang}/... pattern, strips query strings, deduplicates, and writes + * the result to tests/fixtures/phase-0/url-corpus/observed.json. + * + * Usage: + * cat raw-access.log | pnpm tsx scripts/phase-0/anonymize-access-logs.ts + * + * Accepts Common Log Format or any format where the path is the 7th + * whitespace-separated field inside a quoted request line like + * "GET /ru/onlineboard/flight/SU100-2025-01-15 HTTP/1.1" + */ + +import { createInterface } from "node:readline"; +import { writeJson } from "./lib/io.js"; +import { LANGUAGES } from "./lib/angular-routes.js"; + +const OUTPUT_PATH = "tests/fixtures/phase-0/url-corpus/observed.json"; +const LANG_PREFIX_RE = new RegExp(`^/(?:${LANGUAGES.join("|")})(/|$)`); + +async function main(): Promise { + const rl = createInterface({ input: process.stdin }); + const seen = new Set(); + + for await (const line of rl) { + const requestMatch = line.match(/"\w+\s+(\S+)\s+HTTP/); + if (!requestMatch) continue; + const pathAndQuery = requestMatch[1]; + if (!pathAndQuery) continue; + const [path] = pathAndQuery.split("?", 1); + if (!path) continue; + if (!LANG_PREFIX_RE.test(path)) continue; + seen.add(path); + } + + const urls = [...seen].sort(); + writeJson(OUTPUT_PATH, { capturedAt: new Date().toISOString(), count: urls.length, urls }); + console.error(`Wrote ${urls.length} unique URLs to ${OUTPUT_PATH}`); +} + +await main(); +``` + +- [ ] **Step 2: Run it against a sample (skip if no logs)** + +```bash +cat path/to/anonymized-access.log | pnpm tsx scripts/phase-0/anonymize-access-logs.ts +``` + +Expected: stderr prints a URL count; `tests/fixtures/phase-0/url-corpus/observed.json` exists and contains a sorted unique list. + +- [ ] **Step 3: Commit the script (not the observed.json output — that comes out of Task 6)** + +```bash +git add scripts/phase-0/anonymize-access-logs.ts +git commit -m "Add Phase 0 access-log anonymizer + +Reads raw access logs from stdin, extracts /{lang}/... paths, strips +query strings, deduplicates. Output feeds into the URL corpus." +``` + +--- + +## Task 6: URL corpus — write + run the corpus extractor + +Builds `tests/fixtures/phase-0/url-corpus/{feature}.json`, one file per feature, combining enumerated routes (from Task 4) + observed URLs (from Task 5 if available). + +**Files:** +- Create: `scripts/phase-0/extract-url-corpus.ts` +- Create: `tests/fixtures/phase-0/url-corpus/onlineboard.json` (generated) +- Create: `tests/fixtures/phase-0/url-corpus/schedule.json` (generated) +- Create: `tests/fixtures/phase-0/url-corpus/flights-map.json` (generated) +- Create: `tests/fixtures/phase-0/url-corpus/popular.json` (generated) + +- [ ] **Step 1: Write the extractor** + +Create `scripts/phase-0/extract-url-corpus.ts`: + +```ts +#!/usr/bin/env tsx +/** + * Builds the URL corpus fixture files for each feature, combining: + * - Enumerated URLs from ROUTE_SHAPES × LANGUAGES × samples + * - Observed URLs from tests/fixtures/phase-0/url-corpus/observed.json (if present) + * + * Output: tests/fixtures/phase-0/url-corpus/{feature}.json + * + * Each output file has the shape: + * { feature, capturedAt, count, urls: [{ path, source, slug? }] } + */ + +import { ROUTE_SHAPES, LANGUAGES, renderTemplate, type RouteFeature } from "./lib/angular-routes.js"; +import { writeJson, readJson, fileExists } from "./lib/io.js"; + +interface CorpusEntry { + path: string; + source: "enumerated" | "observed"; + slug?: string; +} + +interface Corpus { + feature: RouteFeature; + capturedAt: string; + count: number; + urls: CorpusEntry[]; +} + +const OBSERVED_PATH = "tests/fixtures/phase-0/url-corpus/observed.json"; + +function enumerate(): Map { + const byFeature = new Map(); + for (const feature of ["onlineboard", "schedule", "flights-map", "popular"] as const) { + byFeature.set(feature, []); + } + for (const shape of ROUTE_SHAPES) { + for (const lang of LANGUAGES) { + for (const sample of shape.samples) { + const rendered = renderTemplate(shape.template, sample); + const path = `/${lang}${rendered}`; + byFeature.get(shape.feature)!.push({ path, source: "enumerated", slug: shape.slug }); + } + } + } + return byFeature; +} + +function assignObservedToFeatures(observed: string[], byFeature: Map): void { + for (const path of observed) { + const feature = classify(path); + if (!feature) continue; + byFeature.get(feature)!.push({ path, source: "observed" }); + } +} + +function classify(path: string): RouteFeature | null { + if (/^\/(ru|en|es|fr|it|ja|ko|zh|de)\/onlineboard(\/|$)/.test(path)) return "onlineboard"; + if (/^\/(ru|en|es|fr|it|ja|ko|zh|de)\/schedule(\/|$)/.test(path)) return "schedule"; + if (/^\/(ru|en|es|fr|it|ja|ko|zh|de)\/flights-map(\/|$)/.test(path)) return "flights-map"; + if (/^\/(ru|en|es|fr|it|ja|ko|zh|de)\/popular(\/|$)/.test(path)) return "popular"; + return null; +} + +function main(): void { + const byFeature = enumerate(); + + if (fileExists(OBSERVED_PATH)) { + const observedDoc = readJson<{ urls: string[] }>(OBSERVED_PATH); + assignObservedToFeatures(observedDoc.urls, byFeature); + console.error(`Merged ${observedDoc.urls.length} observed URLs into the corpus`); + } else { + console.error(`No observed URLs at ${OBSERVED_PATH}; corpus is enumerated-only`); + } + + const capturedAt = new Date().toISOString(); + for (const [feature, urls] of byFeature.entries()) { + const dedup = dedupe(urls); + const corpus: Corpus = { feature, capturedAt, count: dedup.length, urls: dedup }; + const out = `tests/fixtures/phase-0/url-corpus/${feature}.json`; + writeJson(out, corpus); + console.error(`Wrote ${feature} corpus (${dedup.length} URLs) → ${out}`); + } +} + +function dedupe(entries: CorpusEntry[]): CorpusEntry[] { + const byPath = new Map(); + for (const entry of entries) { + const existing = byPath.get(entry.path); + if (!existing) { + byPath.set(entry.path, entry); + continue; + } + // Prefer observed over enumerated (observed has real user evidence). + if (existing.source === "enumerated" && entry.source === "observed") { + byPath.set(entry.path, entry); + } + } + return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path)); +} + +main(); +``` + +- [ ] **Step 2: Run the extractor** + +```bash +pnpm phase0:url-corpus +``` + +Expected stderr output (with no observed logs): +``` +No observed URLs at tests/fixtures/phase-0/url-corpus/observed.json; corpus is enumerated-only +Wrote onlineboard corpus (N URLs) → tests/fixtures/phase-0/url-corpus/onlineboard.json +Wrote schedule corpus (N URLs) → tests/fixtures/phase-0/url-corpus/schedule.json +Wrote flights-map corpus (N URLs) → tests/fixtures/phase-0/url-corpus/flights-map.json +Wrote popular corpus (N URLs) → tests/fixtures/phase-0/url-corpus/popular.json +``` + +- [ ] **Step 3: Inspect one corpus file** + +```bash +head -30 tests/fixtures/phase-0/url-corpus/onlineboard.json +``` + +Expected: JSON starting with `{ "feature": "onlineboard", ... }` and a non-empty `urls` array. + +- [ ] **Step 4: Commit corpus fixtures** + +```bash +git add scripts/phase-0/extract-url-corpus.ts tests/fixtures/phase-0/url-corpus/ +git commit -m "Generate Phase 0 URL corpus fixtures from Angular route shapes + +One fixture file per feature. Combines enumerated routes with observed +log URLs where available. Consumed by SEO capture (Task 8) and by the +Phase 2 URL parity test suite." +``` + +--- + +## Task 7: HTTP fetch helper with retry + throttle + +Before the SEO/hreflang capture tasks can hit prod, we need a small shared HTTP helper that handles transient failures and doesn't hammer the origin. + +**Files:** +- Create: `scripts/phase-0/lib/http.ts` +- Create: `tests/phase-0/lib/http.test.ts` + +- [ ] **Step 1: Write the failing test first** + +Create `tests/phase-0/lib/http.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { fetchWithRetry } from "@phase0/lib/http"; + +describe("fetchWithRetry", () => { + const realFetch = globalThis.fetch; + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { + globalThis.fetch = realFetch; + vi.useRealTimers(); + }); + + it("returns the response on first-try success", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response("ok", { status: 200 })); + const res = await fetchWithRetry("https://example.test/a"); + expect(res.status).toBe(200); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + it("retries on 5xx and returns the eventual success", async () => { + globalThis.fetch = vi.fn() + .mockResolvedValueOnce(new Response("oops", { status: 503 })) + .mockResolvedValueOnce(new Response("ok", { status: 200 })); + const promise = fetchWithRetry("https://example.test/b", { maxRetries: 2, retryDelayMs: 10 }); + await vi.advanceTimersByTimeAsync(10); + const res = await promise; + expect(res.status).toBe(200); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); + + it("gives up after maxRetries and throws with the last status", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response("nope", { status: 500 })); + const promise = fetchWithRetry("https://example.test/c", { maxRetries: 2, retryDelayMs: 1 }); + const pending = expect(promise).rejects.toThrow(/HTTP 500/); + await vi.advanceTimersByTimeAsync(10); + await pending; + expect(globalThis.fetch).toHaveBeenCalledTimes(3); + }); + + it("does not retry on 4xx", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response("no", { status: 404 })); + await expect(fetchWithRetry("https://example.test/d", { maxRetries: 3, retryDelayMs: 1 })) + .rejects.toThrow(/HTTP 404/); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +pnpm test tests/phase-0/lib/http.test.ts +``` + +Expected: all 4 tests fail with `Cannot find module '@phase0/lib/http'` or equivalent. + +- [ ] **Step 3: Implement the helper** + +Create `scripts/phase-0/lib/http.ts`: + +```ts +export interface FetchWithRetryOptions { + maxRetries?: number; + retryDelayMs?: number; + throttleMs?: number; + headers?: Record; +} + +let lastCallAt = 0; + +export async function fetchWithRetry( + url: string, + opts: FetchWithRetryOptions = {}, +): Promise { + const maxRetries = opts.maxRetries ?? 2; + const retryDelayMs = opts.retryDelayMs ?? 500; + const throttleMs = opts.throttleMs ?? 0; + const headers = opts.headers ?? { "User-Agent": "aeroflot-flights-phase0/1.0" }; + + if (throttleMs > 0) { + const sinceLast = Date.now() - lastCallAt; + if (sinceLast < throttleMs) { + await sleep(throttleMs - sinceLast); + } + lastCallAt = Date.now(); + } + + let attempt = 0; + let lastStatus = 0; + while (attempt <= maxRetries) { + const res = await fetch(url, { headers }); + if (res.status >= 200 && res.status < 400) return res; + if (res.status >= 400 && res.status < 500) { + throw new Error(`HTTP ${res.status} ${url}`); + } + lastStatus = res.status; + attempt += 1; + if (attempt > maxRetries) break; + await sleep(retryDelayMs * attempt); + } + throw new Error(`HTTP ${lastStatus} ${url} (after ${maxRetries} retries)`); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +``` + +- [ ] **Step 4: Re-run the test** + +```bash +pnpm test tests/phase-0/lib/http.test.ts +``` + +Expected: 4 passing tests. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/phase-0/lib/http.ts tests/phase-0/lib/http.test.ts +git commit -m "Add fetch-with-retry helper for Phase 0 capture scripts + +Retries on 5xx, fails fast on 4xx, optional per-call throttle window. +Protects the Angular prod origin from being hammered by capture runs." +``` + +--- + +## Task 8: SEO baseline capture script + +Fetches a curated set of representative pages from Angular prod, extracts ``, `<meta>` tags, `<link rel="canonical">`, `<link rel="alternate">` (hreflang), OpenGraph, Twitter Card, and `<script type="application/ld+json">` content. Writes one fixture file per route. + +**Files:** +- Create: `scripts/phase-0/capture-seo-baselines.ts` +- Create: `scripts/phase-0/lib/seo-extractor.ts` +- Create: `tests/phase-0/lib/seo-extractor.test.ts` + +- [ ] **Step 1: Write the failing extractor test** + +Create `tests/phase-0/lib/seo-extractor.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { extractSeo } from "@phase0/lib/seo-extractor"; + +const SAMPLE_HTML = ` +<!doctype html> +<html lang="ru"> +<head> + <title>Аэрофлот — Онлайн-табло SU100 + + + + + + + + + + + + + + +`; + +describe("extractSeo", () => { + it("extracts title and description", () => { + const seo = extractSeo(SAMPLE_HTML); + expect(seo.title).toBe("Аэрофлот — Онлайн-табло SU100"); + expect(seo.description).toBe("Актуальный статус рейса SU100"); + }); + + it("extracts canonical link", () => { + expect(extractSeo(SAMPLE_HTML).canonical) + .toBe("https://flights.aeroflot.ru/ru/onlineboard/SU100-2025-01-15"); + }); + + it("extracts hreflang set including x-default", () => { + const { hreflang } = extractSeo(SAMPLE_HTML); + expect(hreflang).toEqual({ + ru: "https://flights.aeroflot.ru/ru/onlineboard/SU100-2025-01-15", + en: "https://flights.aeroflot.ru/en/onlineboard/SU100-2025-01-15", + "x-default": "https://flights.aeroflot.ru/ru/onlineboard/SU100-2025-01-15", + }); + }); + + it("extracts OpenGraph tags", () => { + const { openGraph } = extractSeo(SAMPLE_HTML); + expect(openGraph).toEqual({ + title: "SU100 status", + type: "website", + url: "https://flights.aeroflot.ru/ru/onlineboard/SU100-2025-01-15", + image: "https://flights.aeroflot.ru/og/default.png", + }); + }); + + it("extracts parsed JSON-LD blocks", () => { + const { jsonLd } = extractSeo(SAMPLE_HTML); + expect(jsonLd).toHaveLength(1); + expect(jsonLd[0]).toMatchObject({ "@type": "Flight", flightNumber: "SU100" }); + }); + + it("returns nulls for missing fields", () => { + const seo = extractSeo(""); + expect(seo.title).toBeNull(); + expect(seo.description).toBeNull(); + expect(seo.canonical).toBeNull(); + expect(seo.hreflang).toEqual({}); + expect(seo.openGraph).toEqual({}); + expect(seo.jsonLd).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +pnpm test tests/phase-0/lib/seo-extractor.test.ts +``` + +Expected: 6 tests fail with `Cannot find module '@phase0/lib/seo-extractor'`. + +- [ ] **Step 3: Implement the extractor** + +Create `scripts/phase-0/lib/seo-extractor.ts`: + +```ts +import * as cheerio from "cheerio"; + +export interface ExtractedSeo { + title: string | null; + description: string | null; + canonical: string | null; + hreflang: Record; + openGraph: Record; + twitterCard: Record; + jsonLd: unknown[]; +} + +export function extractSeo(html: string): ExtractedSeo { + const $ = cheerio.load(html); + + const title = $("head > title").first().text().trim() || null; + const description = $('head > meta[name="description"]').attr("content") ?? null; + const canonical = $('head > link[rel="canonical"]').attr("href") ?? null; + + const hreflang: Record = {}; + $('head > link[rel="alternate"][hreflang]').each((_, el) => { + const lang = $(el).attr("hreflang"); + const href = $(el).attr("href"); + if (lang && href) hreflang[lang] = href; + }); + + const openGraph: Record = {}; + $('head > meta[property^="og:"]').each((_, el) => { + const property = $(el).attr("property"); + const content = $(el).attr("content"); + if (property && content) openGraph[property.replace(/^og:/, "")] = content; + }); + + const twitterCard: Record = {}; + $('head > meta[name^="twitter:"]').each((_, el) => { + const name = $(el).attr("name"); + const content = $(el).attr("content"); + if (name && content) twitterCard[name.replace(/^twitter:/, "")] = content; + }); + + const jsonLd: unknown[] = []; + $('head > script[type="application/ld+json"]').each((_, el) => { + const raw = $(el).contents().text(); + if (!raw.trim()) return; + try { + jsonLd.push(JSON.parse(raw)); + } catch { + jsonLd.push({ __parseError: true, raw }); + } + }); + + return { title, description, canonical, hreflang, openGraph, twitterCard, jsonLd }; +} +``` + +- [ ] **Step 4: Re-run the test** + +```bash +pnpm test tests/phase-0/lib/seo-extractor.test.ts +``` + +Expected: 6 passing. + +- [ ] **Step 5: Write the driver script** + +Create `scripts/phase-0/capture-seo-baselines.ts`: + +```ts +#!/usr/bin/env tsx +/** + * For each feature corpus + each language, fetches one representative + * URL from Angular prod, extracts SEO tags, and writes one fixture per + * route slug to tests/fixtures/phase-0/seo-baselines/. + * + * Required env: PROD_ORIGIN (e.g. https://flights.aeroflot.ru) + * + * Only captures one URL per slug (not every enumerated variant) — the + * purpose is a shape baseline, not a load test. + */ + +import { ROUTE_SHAPES, LANGUAGES, renderTemplate, buildUrl } from "./lib/angular-routes.js"; +import { fetchWithRetry } from "./lib/http.js"; +import { extractSeo } from "./lib/seo-extractor.js"; +import { writeJson } from "./lib/io.js"; + +const origin = process.env.PROD_ORIGIN; +if (!origin) { + console.error("PROD_ORIGIN env var required (e.g. https://flights.aeroflot.ru)"); + process.exit(1); +} + +async function main(): Promise { + const langs = ["ru", "en"] as const; // Two languages per slug is enough for a baseline. + let captured = 0; + let failed = 0; + + for (const shape of ROUTE_SHAPES) { + const sample = shape.samples[0]; + if (!sample) continue; + const rendered = renderTemplate(shape.template, sample); + + for (const lang of langs) { + const url = buildUrl(origin!, lang, rendered); + const fixturePath = `tests/fixtures/phase-0/seo-baselines/${shape.slug}.${lang}.json`; + try { + const res = await fetchWithRetry(url, { throttleMs: 500, maxRetries: 2 }); + const html = await res.text(); + const seo = extractSeo(html); + writeJson(fixturePath, { + capturedAt: new Date().toISOString(), + slug: shape.slug, + lang, + url, + seo, + }); + console.error(` ✔ ${url}`); + captured += 1; + } catch (err) { + console.error(` ✘ ${url} — ${(err as Error).message}`); + failed += 1; + } + } + } + + console.error(`\nCaptured ${captured} SEO baselines, ${failed} failures.`); + if (failed > 0) process.exit(1); +} + +void main(); +``` + +- [ ] **Step 6: Run against Angular prod** + +```bash +PROD_ORIGIN=https://flights.aeroflot.ru pnpm phase0:seo +``` + +Expected: stderr prints ✔ / ✘ per URL; ~26 files written under `tests/fixtures/phase-0/seo-baselines/`. Zero failures. + +**If any fetches fail:** investigate before committing. Common causes: origin gating by `User-Agent` (edit the header in `lib/http.ts`), prod returning 403 for bot-like traffic (work with the customer to whitelist the script's source IP or use a staging mirror), or a URL in the samples that doesn't exist (adjust `ROUTE_SHAPES.samples`). + +- [ ] **Step 7: Spot-check one fixture** + +```bash +cat tests/fixtures/phase-0/seo-baselines/onlineboard-flight.ru.json +``` + +Expected: JSON with a non-null `title`, a `canonical` URL, a non-empty `hreflang` object with at least `ru` and `en` entries, and at least one `jsonLd` block (or empty — that's a gap the React rewrite fills). + +- [ ] **Step 8: Commit** + +```bash +git add scripts/phase-0/capture-seo-baselines.ts scripts/phase-0/lib/seo-extractor.ts tests/phase-0/lib/seo-extractor.test.ts tests/fixtures/phase-0/seo-baselines/ +git commit -m "Capture SEO baselines from Angular prod + +One fixture per route slug × language. Records title, description, +canonical, hreflang set, OpenGraph, Twitter Card, and JSON-LD blocks. +Consumed by the Phase 2+ SEO parity tests — React output must match +or improve on these." +``` + +--- + +## Task 9: Hreflang reciprocal-parity check + +The current Angular app may or may not emit reciprocal `hreflang` sets correctly. Phase 0 captures the *actual* state so the Phase 2+ parity tests can assert "at least as good as Angular, and ideally better." + +**Files:** +- Create: `scripts/phase-0/capture-hreflang-parity.ts` +- Create: `tests/fixtures/phase-0/hreflang-parity/.json` (generated) + +- [ ] **Step 1: Write the script** + +Create `scripts/phase-0/capture-hreflang-parity.ts`: + +```ts +#!/usr/bin/env tsx +/** + * For each route slug, fetches all 9 language variants from Angular prod, + * extracts each variant's hreflang set, and writes a single fixture per + * slug comparing the sets. + * + * Required env: PROD_ORIGIN + * + * The resulting fixture records whether the hreflang sets are identical + * across language variants (they should be — hreflang must be reciprocal). + * Phase 2+ SEO parity tests will assert the React output has reciprocal + * hreflang regardless of what Angular does today. + */ + +import { ROUTE_SHAPES, LANGUAGES, renderTemplate, buildUrl } from "./lib/angular-routes.js"; +import { fetchWithRetry } from "./lib/http.js"; +import { extractSeo } from "./lib/seo-extractor.js"; +import { writeJson } from "./lib/io.js"; + +const origin = process.env.PROD_ORIGIN; +if (!origin) { + console.error("PROD_ORIGIN env var required"); + process.exit(1); +} + +async function main(): Promise { + for (const shape of ROUTE_SHAPES) { + const sample = shape.samples[0]; + if (!sample) continue; + const rendered = renderTemplate(shape.template, sample); + + const perLang: Record | null; error?: string }> = {}; + for (const lang of LANGUAGES) { + const url = buildUrl(origin!, lang, rendered); + try { + const res = await fetchWithRetry(url, { throttleMs: 500, maxRetries: 2 }); + const html = await res.text(); + const { hreflang } = extractSeo(html); + perLang[lang] = { url, hreflang }; + } catch (err) { + perLang[lang] = { url, hreflang: null, error: (err as Error).message }; + } + } + + const sets = Object.values(perLang) + .map((v) => v.hreflang) + .filter((h): h is Record => h !== null); + const reciprocal = sets.length > 0 && sets.every((s) => JSON.stringify(sortKeys(s)) === JSON.stringify(sortKeys(sets[0]!))); + + const out = `tests/fixtures/phase-0/hreflang-parity/${shape.slug}.json`; + writeJson(out, { + capturedAt: new Date().toISOString(), + slug: shape.slug, + reciprocal, + perLang, + }); + console.error(` ${reciprocal ? "✔" : "✘"} ${shape.slug} — reciprocal: ${reciprocal}`); + } +} + +function sortKeys>(obj: T): T { + return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b))) as T; +} + +void main(); +``` + +- [ ] **Step 2: Run against Angular prod** + +```bash +PROD_ORIGIN=https://flights.aeroflot.ru pnpm phase0:hreflang +``` + +Expected: one line per route slug, marked ✔ (reciprocal) or ✘ (drift). A few ✘ are fine at this stage — they document the Angular baseline we need to beat. + +- [ ] **Step 3: Inspect a drift case (if any)** + +```bash +grep -l '"reciprocal": false' tests/fixtures/phase-0/hreflang-parity/ || echo "No drift found" +``` + +If drift is found, note the affected slugs in `docs/superpowers/phase-0/README.md` (Task 17) as "Angular hreflang bugs the React rewrite fixes by default." + +- [ ] **Step 4: Commit** + +```bash +git add scripts/phase-0/capture-hreflang-parity.ts tests/fixtures/phase-0/hreflang-parity/ +git commit -m "Capture Angular hreflang reciprocal-parity baseline + +One fixture per route slug records the hreflang sets emitted by every +language variant. The 'reciprocal' flag says whether all variants agree. +Any false results document Angular SEO bugs the React rewrite fixes." +``` + +--- + +## Task 10: Playwright VRT config + baseline capture script + +Captures ~60 reference screenshots from Angular prod: 10 curated routes × 3 viewports (375 / 768 / 1440) × 2 languages (ru, en). Used by the Phase 2+ visual-regression gate to enforce pixel parity. + +**Files:** +- Create: `playwright.phase0.config.ts` +- Create: `scripts/phase-0/capture-vrt-baselines.ts` +- Create: `tests/phase-0/vrt/baseline.spec.ts` + +- [ ] **Step 1: Create the Playwright config** + +Create `playwright.phase0.config.ts` at the repo root: + +```ts +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/phase-0/vrt", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 4, + reporter: [["list"]], + use: { + actionTimeout: 10_000, + navigationTimeout: 30_000, + ignoreHTTPSErrors: false, + userAgent: "aeroflot-flights-phase0-vrt/1.0", + }, + projects: [ + { + name: "mobile", + use: { ...devices["iPhone SE"], viewport: { width: 375, height: 667 } }, + }, + { + name: "tablet", + use: { viewport: { width: 768, height: 1024 }, userAgent: "aeroflot-flights-phase0-vrt/1.0" }, + }, + { + name: "desktop", + use: { viewport: { width: 1440, height: 900 }, userAgent: "aeroflot-flights-phase0-vrt/1.0" }, + }, + ], +}); +``` + +- [ ] **Step 2: Curate the 10 VRT routes** + +The 60-image matrix = 10 routes × 3 viewports × 2 languages. Select representative routes from the URL corpus — aim for coverage of start pages, search results, and detail pages across all four features: + +Create `scripts/phase-0/lib/vrt-routes.ts`: + +```ts +import type { RouteShape } from "./angular-routes.js"; +import { ROUTE_SHAPES, renderTemplate } from "./angular-routes.js"; + +/** Curated subset of routes used for VRT baseline capture. */ +const VRT_SLUGS = [ + "onlineboard-start", + "onlineboard-flight", + "onlineboard-departure", + "onlineboard-arrival", + "onlineboard-route", + "onlineboard-details", + "schedule-start", + "schedule-oneway", + "flights-map-start", + "popular-start", +]; + +export interface VrtRoute { + slug: string; + path: string; // without language prefix +} + +export function getVrtRoutes(): VrtRoute[] { + const byFeatureSlug = new Map(ROUTE_SHAPES.map((s) => [s.slug, s])); + return VRT_SLUGS.map((slug) => { + const shape = byFeatureSlug.get(slug); + if (!shape) throw new Error(`VRT: unknown slug ${slug}`); + const sample = shape.samples[0]; + if (!sample) throw new Error(`VRT: no sample for ${slug}`); + return { slug, path: renderTemplate(shape.template, sample) }; + }); +} +``` + +- [ ] **Step 3: Write the Playwright baseline spec** + +Create `tests/phase-0/vrt/baseline.spec.ts`: + +```ts +import { test, expect } from "@playwright/test"; +import { getVrtRoutes } from "../../../scripts/phase-0/lib/vrt-routes.js"; + +const ORIGIN = process.env.PROD_ORIGIN; +const LANGS = ["ru", "en"] as const; + +if (!ORIGIN) { + throw new Error("PROD_ORIGIN env var required for VRT baseline capture"); +} + +test.describe("Angular prod VRT baselines", () => { + for (const route of getVrtRoutes()) { + for (const lang of LANGS) { + test(`${route.slug} | ${lang}`, async ({ page }, testInfo) => { + const url = `${ORIGIN}/${lang}${route.path}`; + const response = await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 }); + expect(response?.status()).toBeLessThan(400); + + // Give any lazy-loaded fonts + images a beat to settle. + await page.waitForTimeout(1500); + + // Full-page screenshot; masks cover dynamic content (live times, moving map tiles). + const screenshot = await page.screenshot({ + fullPage: true, + animations: "disabled", + }); + + const outPath = `tests/fixtures/phase-0/vrt-baselines/${route.slug}-${testInfo.project.name}-${lang}.png`; + await testInfo.attach("baseline", { body: screenshot, contentType: "image/png" }); + + // Write the baseline file directly (Playwright snapshots are overkill for Phase 0 capture). + const { writeFileSync, mkdirSync } = await import("node:fs"); + const { dirname } = await import("node:path"); + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, screenshot); + }); + } + } +}); +``` + +- [ ] **Step 4: Run VRT capture** + +```bash +PROD_ORIGIN=https://flights.aeroflot.ru pnpm phase0:vrt +``` + +Expected: 60 tests pass (10 routes × 3 projects × 2 langs). `tests/fixtures/phase-0/vrt-baselines/` contains 60 PNGs. + +If some pages fail with navigation timeouts or `networkidle` never settles, add a per-slug override that uses `domcontentloaded` instead — Leaflet tile loading and SignalR connection attempts can hold `networkidle` open indefinitely. Adjust `waitUntil` in the spec for the problem routes and re-run. + +- [ ] **Step 5: Verify file count** + +```bash +ls tests/fixtures/phase-0/vrt-baselines/*.png | wc -l +``` + +Expected: `60` + +- [ ] **Step 6: Commit** + +```bash +git add playwright.phase0.config.ts scripts/phase-0/lib/vrt-routes.ts tests/phase-0/vrt/baseline.spec.ts tests/fixtures/phase-0/vrt-baselines/ +git commit -m "Capture 60 Playwright VRT baselines from Angular prod + +10 curated routes × 3 viewports × 2 languages. These PNGs are the +pixel-parity source of truth for the Phase 2+ visual regression gate. +Phase-end re-baselining (§8.4 of the design spec) replaces these with +React-build baselines once each feature ships." +``` + +--- + +## Task 11: PrimeNG component inventory + +Scans `ClientApp/src/` for every `import` from `primeng/*` and every `p-*` tag used in templates. Produces a backlog the Phase 1E (UI adapter) and Phase 2+ work plans pull from. + +**Files:** +- Create: `scripts/phase-0/inventory-primeng.ts` +- Create: `docs/superpowers/phase-0/primeng-backlog.md` (generated) + +- [ ] **Step 1: Write the inventory script** + +Create `scripts/phase-0/inventory-primeng.ts`: + +```ts +#!/usr/bin/env tsx +/** + * Scans ClientApp/src for: + * - imports from 'primeng/*' (in .ts files) + * - tag usages (in .html template files) + * + * Produces a markdown backlog at docs/superpowers/phase-0/primeng-backlog.md + * grouped by PrimeNG module, with the files that reference each one. + */ + +import { readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +const ROOT = "ClientApp/src"; +const IMPORT_RE = /from\s+['"]primeng\/(\w[\w-]*)['"]/g; +const TAG_RE = /; +} + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + const s = statSync(full); + if (s.isDirectory()) { + if (name === "node_modules" || name === "dist") continue; + walk(full, out); + } else if (/\.(ts|html)$/.test(name)) { + out.push(full); + } + } + return out; +} + +function main(): void { + const files = walk(ROOT); + const index = new Map(); + + for (const file of files) { + const contents = readFileSync(file, "utf8"); + const relFile = relative(process.cwd(), file); + + if (file.endsWith(".ts")) { + for (const match of contents.matchAll(IMPORT_RE)) { + const mod = match[1]!; + const key = `import:${mod}`; + if (!index.has(key)) index.set(key, { item: mod, kind: "import", files: new Set() }); + index.get(key)!.files.add(relFile); + } + } + if (file.endsWith(".html")) { + for (const match of contents.matchAll(TAG_RE)) { + const tag = `p-${match[1]}`; + const key = `tag:${tag}`; + if (!index.has(key)) index.set(key, { item: tag, kind: "tag", files: new Set() }); + index.get(key)!.files.add(relFile); + } + } + } + + const imports = [...index.values()].filter((r) => r.kind === "import").sort((a, b) => a.item.localeCompare(b.item)); + const tags = [...index.values()].filter((r) => r.kind === "tag").sort((a, b) => a.item.localeCompare(b.item)); + + const lines: string[] = []; + lines.push("# PrimeNG component inventory"); + lines.push(""); + lines.push("> Generated by `scripts/phase-0/inventory-primeng.ts`. Do not hand-edit."); + lines.push(""); + lines.push(`**Totals:** ${imports.length} imported modules, ${tags.length} template tags.`); + lines.push(""); + lines.push("## Imported modules"); + lines.push(""); + lines.push("| Module | Files | Phase 1E target |"); + lines.push("|---|---|---|"); + for (const row of imports) { + lines.push(`| \`primeng/${row.item}\` | ${row.files.size} | (TBD — see §5.3 of the design spec) |`); + } + lines.push(""); + lines.push("## Template tags"); + lines.push(""); + lines.push("| Tag | Files |"); + lines.push("|---|---|"); + for (const row of tags) { + lines.push(`| \`<${row.item}>\` | ${row.files.size} |`); + } + lines.push(""); + lines.push("## File detail"); + lines.push(""); + for (const row of [...imports, ...tags]) { + lines.push(`### \`${row.kind === "import" ? `primeng/${row.item}` : `<${row.item}>`}\``); + lines.push(""); + for (const file of [...row.files].sort()) { + lines.push(`- ${file}`); + } + lines.push(""); + } + + mkdirSync("docs/superpowers/phase-0", { recursive: true }); + writeFileSync("docs/superpowers/phase-0/primeng-backlog.md", lines.join("\n")); + console.error(`Wrote ${imports.length} imports + ${tags.length} tags to docs/superpowers/phase-0/primeng-backlog.md`); +} + +main(); +``` + +- [ ] **Step 2: Run it** + +```bash +pnpm phase0:inventory:primeng +``` + +Expected: stderr reports counts; `docs/superpowers/phase-0/primeng-backlog.md` exists with a non-empty table. + +- [ ] **Step 3: Spot-check the output** + +```bash +head -40 docs/superpowers/phase-0/primeng-backlog.md +``` + +Expected: Markdown with an "Imported modules" table listing PrimeNG modules (`calendar`, `autocomplete`, `accordion`, `dropdown`, `table`, `tooltip`, `dialog`, `toast`, etc.). + +- [ ] **Step 4: Commit** + +```bash +git add scripts/phase-0/inventory-primeng.ts docs/superpowers/phase-0/primeng-backlog.md +git commit -m "Inventory PrimeNG usages in ClientApp/ + +Produces the Phase 1E UI adapter backlog: every primeng/ import and +every template tag, grouped by referenced file." +``` + +--- + +## Task 12: SCSS token + theme-override inventory + +Extracts every `$variable`, every `:root { --var }`, every selector override of a PrimeNG class (`.p-*`), and every `@import` of SCSS files under `ClientApp/src/styles/`. Produces the port manifest for the `src/ui/styles/` theme porting work in Phase 1E. + +**Files:** +- Create: `scripts/phase-0/inventory-scss-tokens.ts` +- Create: `docs/superpowers/phase-0/scss-theme-manifest.md` (generated) + +- [ ] **Step 1: Write the inventory script** + +Create `scripts/phase-0/inventory-scss-tokens.ts`: + +```ts +#!/usr/bin/env tsx +/** + * Extracts SCSS variables, CSS custom properties, and PrimeNG selector + * overrides from ClientApp/src for the theme-port manifest. + */ + +import { readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +const ROOTS = ["ClientApp/src/styles", "ClientApp/src/app"]; +const SCSS_VAR_RE = /\$([a-zA-Z][\w-]*)\s*:/g; +const CSS_VAR_RE = /--([a-zA-Z][\w-]*)\s*:/g; +const PRIME_SELECTOR_RE = /(\.p-[a-zA-Z][\w-]*(?:[:\s]+[^{]*)?)\s*\{/g; + +interface Sighting { file: string; count: number; } + +function walk(dir: string, out: string[] = []): string[] { + try { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + const s = statSync(full); + if (s.isDirectory()) { + if (name === "node_modules" || name === "dist") continue; + walk(full, out); + } else if (/\.scss$/.test(name)) { + out.push(full); + } + } + } catch { + // ROOT may not exist; ignore. + } + return out; +} + +function collect(): { + scssVars: Map; + cssVars: Map; + primeSelectors: Map; +} { + const scssVars = new Map(); + const cssVars = new Map(); + const primeSelectors = new Map(); + + const files: string[] = []; + for (const root of ROOTS) walk(root, files); + + for (const file of files) { + const contents = readFileSync(file, "utf8"); + const rel = relative(process.cwd(), file); + count(contents, SCSS_VAR_RE, scssVars, rel); + count(contents, CSS_VAR_RE, cssVars, rel); + count(contents, PRIME_SELECTOR_RE, primeSelectors, rel); + } + + return { scssVars, cssVars, primeSelectors }; +} + +function count(source: string, regex: RegExp, into: Map, file: string): void { + const matches = [...source.matchAll(regex)]; + const localCounts = new Map(); + for (const m of matches) { + const key = (m[1] ?? m[0]).trim(); + localCounts.set(key, (localCounts.get(key) ?? 0) + 1); + } + for (const [key, n] of localCounts.entries()) { + if (!into.has(key)) into.set(key, []); + into.get(key)!.push({ file, count: n }); + } +} + +function render(): string { + const { scssVars, cssVars, primeSelectors } = collect(); + const lines: string[] = []; + lines.push("# SCSS theme-port manifest"); + lines.push(""); + lines.push("> Generated by `scripts/phase-0/inventory-scss-tokens.ts`. Do not hand-edit."); + lines.push(""); + lines.push(`**Totals:** ${scssVars.size} SCSS variables, ${cssVars.size} CSS custom properties, ${primeSelectors.size} PrimeNG selector overrides.`); + lines.push(""); + lines.push("## SCSS variables (`$var`)"); + lines.push(""); + lines.push("These port to `src/ui/styles/_tokens.scss` as CSS custom properties (`--var`) per §5.3 of the design spec."); + lines.push(""); + lines.push("| Variable | Defined in |"); + lines.push("|---|---|"); + for (const key of [...scssVars.keys()].sort()) { + const sight = scssVars.get(key)!; + lines.push(`| \`$${key}\` | ${sight.map((s) => s.file).join("
")} |`); + } + lines.push(""); + lines.push("## CSS custom properties (`--var`)"); + lines.push(""); + lines.push("| Property | Defined in |"); + lines.push("|---|---|"); + for (const key of [...cssVars.keys()].sort()) { + const sight = cssVars.get(key)!; + lines.push(`| \`--${key}\` | ${sight.map((s) => s.file).join("
")} |`); + } + lines.push(""); + lines.push("## PrimeNG selector overrides"); + lines.push(""); + lines.push("These port to `src/ui/styles/_theme-primereact.scss`. Most port unchanged because PrimeReact uses the same `.p-*` class taxonomy."); + lines.push(""); + lines.push("| Selector | Defined in |"); + lines.push("|---|---|"); + for (const key of [...primeSelectors.keys()].sort()) { + const sight = primeSelectors.get(key)!; + lines.push(`| \`${key}\` | ${sight.map((s) => s.file).join("
")} |`); + } + lines.push(""); + return lines.join("\n"); +} + +function main(): void { + const markdown = render(); + mkdirSync("docs/superpowers/phase-0", { recursive: true }); + writeFileSync("docs/superpowers/phase-0/scss-theme-manifest.md", markdown); + console.error("Wrote docs/superpowers/phase-0/scss-theme-manifest.md"); +} + +main(); +``` + +- [ ] **Step 2: Run it** + +```bash +pnpm phase0:inventory:scss +``` + +Expected: stderr prints the write confirmation; the manifest file exists. + +- [ ] **Step 3: Spot-check** + +```bash +head -40 docs/superpowers/phase-0/scss-theme-manifest.md +``` + +Expected: totals line with non-zero counts + the "SCSS variables" table. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/phase-0/inventory-scss-tokens.ts docs/superpowers/phase-0/scss-theme-manifest.md +git commit -m "Inventory SCSS tokens + PrimeNG overrides for Phase 1E + +Extracts every SCSS variable, CSS custom property, and .p-* selector +override from ClientApp/src. Phase 1E uses the manifest as the theme +port backlog." +``` + +--- + +## Task 13: Translation-key usage inventory + +The Angular i18n JSON files contain every translated string ever used. Some keys are dead code. This script scans `ClientApp/src` for keys that are *actually referenced* (via the `translate` pipe, `translate` directive, or `TranslateService` API), so the Phase 1C i18n port can skip dead strings if desired. + +**Files:** +- Create: `scripts/phase-0/inventory-translation-keys.ts` +- Create: `docs/superpowers/phase-0/translation-keys-used.md` (generated) + +- [ ] **Step 1: Write the script** + +Create `scripts/phase-0/inventory-translation-keys.ts`: + +```ts +#!/usr/bin/env tsx +/** + * Scans ClientApp/src for @ngx-translate key references: + * - '{{ "key" | translate }}' in templates + * - '[translate]="..."' attribute in templates + * - translate.get("key") / translate.instant("key") in TS + * + * Compares against the key set in ClientApp/src/assets/i18n/ru.json + * (used as the canonical key list) and produces a markdown report: + * - keys used in code + * - keys present in ru.json but never referenced (dead) + * - keys referenced but missing from ru.json (broken) + */ + +import { readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +const SRC_ROOT = "ClientApp/src/app"; +const RU_JSON = "ClientApp/src/assets/i18n/ru.json"; +const PIPE_RE = /['"]([a-zA-Z][\w.-]*?)['"]\s*\|\s*translate/g; +const SERVICE_RE = /translate\.(?:get|instant|stream)\(['"]([a-zA-Z][\w.-]*?)['"]/g; +const ATTR_RE = /\[translate\]="['"]?([a-zA-Z][\w.-]*?)['"]?"/g; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + const s = statSync(full); + if (s.isDirectory()) walk(full, out); + else if (/\.(ts|html)$/.test(name)) out.push(full); + } + return out; +} + +function flattenKeys(obj: unknown, prefix = ""): string[] { + if (typeof obj !== "object" || obj === null) return prefix ? [prefix] : []; + const keys: string[] = []; + for (const [k, v] of Object.entries(obj)) { + const next = prefix ? `${prefix}.${k}` : k; + if (typeof v === "object" && v !== null) keys.push(...flattenKeys(v, next)); + else keys.push(next); + } + return keys; +} + +function main(): void { + const used = new Set(); + const usedFiles = new Map>(); + + for (const file of walk(SRC_ROOT)) { + const contents = readFileSync(file, "utf8"); + const rel = relative(process.cwd(), file); + const regexes = file.endsWith(".html") ? [PIPE_RE, ATTR_RE] : [PIPE_RE, SERVICE_RE]; + for (const re of regexes) { + for (const m of contents.matchAll(re)) { + const key = m[1]!; + used.add(key); + if (!usedFiles.has(key)) usedFiles.set(key, new Set()); + usedFiles.get(key)!.add(rel); + } + } + } + + const defined = new Set(flattenKeys(JSON.parse(readFileSync(RU_JSON, "utf8")))); + const dead = [...defined].filter((k) => !used.has(k)).sort(); + const broken = [...used].filter((k) => !defined.has(k)).sort(); + const live = [...used].filter((k) => defined.has(k)).sort(); + + const lines: string[] = []; + lines.push("# Translation-key usage inventory"); + lines.push(""); + lines.push("> Generated by `scripts/phase-0/inventory-translation-keys.ts`. Do not hand-edit."); + lines.push(""); + lines.push(`**Totals:** ${defined.size} defined in ru.json · ${live.length} live · ${dead.length} dead · ${broken.length} broken references.`); + lines.push(""); + + lines.push(`## Live keys (${live.length})`); + lines.push(""); + lines.push("These must be ported to `src/i18n/locales/*/common.json` in Phase 1C."); + lines.push(""); + lines.push("
Show list"); + lines.push(""); + for (const key of live) lines.push(`- \`${key}\``); + lines.push(""); + lines.push("
"); + lines.push(""); + + lines.push(`## Dead keys (${dead.length})`); + lines.push(""); + lines.push("Present in ru.json but never referenced. The Phase 1C port may skip these, or keep them as insurance against keys used via dynamic composition that this static scan can't catch."); + lines.push(""); + lines.push("
Show list"); + lines.push(""); + for (const key of dead) lines.push(`- \`${key}\``); + lines.push(""); + lines.push("
"); + lines.push(""); + + lines.push(`## Broken references (${broken.length})`); + lines.push(""); + lines.push("Keys used in code but missing from ru.json. These are Angular bugs — the React port should fix them by adding the missing translations."); + lines.push(""); + for (const key of broken) { + const files = [...(usedFiles.get(key) ?? [])].sort(); + lines.push(`- \`${key}\` (in ${files.join(", ")})`); + } + lines.push(""); + + mkdirSync("docs/superpowers/phase-0", { recursive: true }); + writeFileSync("docs/superpowers/phase-0/translation-keys-used.md", lines.join("\n")); + console.error(`live=${live.length} dead=${dead.length} broken=${broken.length}`); +} + +main(); +``` + +- [ ] **Step 2: Run it** + +```bash +pnpm phase0:inventory:i18n +``` + +Expected: stderr prints `live=N dead=M broken=K`. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/phase-0/inventory-translation-keys.ts docs/superpowers/phase-0/translation-keys-used.md +git commit -m "Inventory live vs dead translation keys in Angular i18n + +Compares keys referenced in ClientApp/src against keys defined in +ru.json. Produces live/dead/broken buckets used by the Phase 1C i18n +port to decide what moves and what gets dropped." +``` + +--- + +## Task 14: Customer confirmation checklist + +Produces the questionnaire the customer point-of-contact must answer to unblock Phase 1. Not an automated task — a hand-written document. + +**Files:** +- Create: `docs/superpowers/phase-0/customer-confirmation-checklist.md` + +- [ ] **Step 1: Author the checklist** + +Create `docs/superpowers/phase-0/customer-confirmation-checklist.md`: + +```markdown +# Customer confirmation checklist (Phase 0 blockers) + +This questionnaire collects the customer-side decisions flagged as assumptions in the design spec (`docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md` §10). Each must be answered before Phase 1 can start — their answers shape the Modern.js project layout, CI configuration, and logging transport. + +Deliver this document to the customer point-of-contact. Record answers inline below and commit the updated file to close out Phase 0. + +--- + +## A1 — Remote-frontend module template (design spec §2.5) + +**Question.** The design spec defaults to the idiomatic Modern.js + Module Federation 2.0 remote module layout. The customer's "standard remote-frontend module template" (required by customer requirement 9) has not been shared. + +- Is there a published template (repo link, archive, or written conventions) the customer wants us to match? +- If yes, who owns it and what does it specify for: directory layout, exposed module naming, `mf-manifest.json` metadata fields, shared-dependency declarations, artifact packaging? +- If no, does the customer accept that the Modern.js default layout will be used and reconciled later (rename-only migration expected)? + +**Answer:** + +--- + +## A2 — CDN vendor (design spec §8.2) + +**Question.** Standalone SSR cache headers (`Cache-Control` with `s-maxage` + `stale-while-revalidate`) assume a standards-compliant CDN in front of the origin. Remote-mode static artifacts also require a CDN. + +- Which CDN does the customer operate? (Yandex Cloud CDN / Cloudflare / Akamai / other) +- Are there customer-side constraints on cache header values, purge mechanisms, or TLS certificate delivery? +- Who in the customer's infrastructure team owns the CDN configuration? + +**Answer:** + +--- + +## A3 — CI provider (design spec §8.5) + +**Question.** The design spec assumes GitHub Actions for CI. Pipelines port directly to GitLab CI or TeamCity if needed, but environment-specific glue has to be written. + +- Which CI provider does the customer use for frontend projects? +- Are there existing pipeline templates / shared workflows / Docker base images we must adopt? +- Are there customer-mandated scanning tools (SAST / SCA / license scan) beyond `osv-scanner` + `npm audit` the spec already includes? + +**Answer:** + +--- + +## A4 — Frontend log format (design spec §7.2) + +**Question.** Customer requirement 8 states: "frontend logs collected, formed into a file of a customer-specified format (to be provided separately), shipped to the customer's log system." The format specification has not been shared. + +- What log format does the customer require? (JSON-lines / plain text / vendor-specific like Fluentd / CEF / other) +- What is the log-ingestion endpoint? (HTTP POST URL / Kafka topic / Filebeat agent / other) +- Authentication? (bearer token / mTLS / IP allowlist) +- Required fields beyond the standard set (timestamp, level, message, trace id)? +- Any PII handling rules beyond the redaction list in the spec (`password`, `token`, `authorization`, `cookie`, `email`, `phone`)? + +Until this is answered, Phase 1G ships with `JsonLinesHttpTransport` as the default (spec §7.2, assumption A4). The customer format plugs in as a new `LogTransport` implementation at `src/observability/logger/index.ts` without feature-code changes. + +**Answer:** + +--- + +## A5 — ASP.NET host retention (design spec §9.2, Phase 6) + +**Question.** Phase 6 assumes `Aeroflot.Flights.Web.csproj` + `Startup.cs` + `Program.cs` can be deleted once the React app reaches 100% traffic, unless the host serves something unrelated to the frontend. + +- Does the ASP.NET host have responsibilities beyond serving the Angular SPA? (API proxy, SSO, server-side form handling, static asset delivery) +- Who owns the ASP.NET code currently? +- Is there a shared infrastructure component (load balancer, reverse proxy, security headers) that the ASP.NET layer provides and that we must replace before decommissioning? + +This only needs to be answered before Phase 6 starts, but it's cheap to ask now. + +**Answer:** + +--- + +## A6 — Metrics aggregator endpoint (design spec §7.3) + +**Question.** The spec uses OpenTelemetry with OTLP/HTTP export to any aggregator the customer operates. The endpoint URL and auth mechanism aren't in the spec. + +- What is the OTLP ingestion endpoint for the metrics aggregator? (Dynatrace / Grafana Mimir / other) +- Authentication? (API key in `OTEL_EXPORTER_OTLP_HEADERS` / mTLS) +- Are there customer-standard metric naming conventions we must follow? +- Will the customer's aggregator also ingest traces + logs, or are those separate pipelines? + +**Answer:** + +--- + +## A7 — Analytics vendor credentials (design spec §7.4) + +**Question.** Four analytics vendors are listed: Яндекс.Метрика, CTM, Вариокуб, Ключ-Астром (Dynatrace). Each needs a property/container ID and (in some cases) an auth snippet. + +| Vendor | What we need | +|---|---| +| Яндекс.Метрика | Counter ID | +| CTM | Tracking ID + script source | +| Вариокуб | Property ID + script source | +| Ключ-Астром (Dynatrace RUM) | Agent script URL + application ID | + +Per-environment (dev / testing / staging / production) if they differ. + +**Answer:** + +--- + +## A8 — Production URL + access logs (Phase 0 prerequisites) + +**Question.** + +- Production URL for Angular app (used by Phase 0 capture scripts): \_\_\_ +- Staging mirror URL (fallback if prod blocks scraping): \_\_\_ +- Access-log availability: (yes / no — if yes, format and how we fetch them) +- Contact on the customer side if the capture scripts get blocked by WAF / rate limits: \_\_\_ + +**Answer:** + +--- + +## Sign-off + +- [ ] All blockers above answered +- [ ] Any follow-up tickets opened on the customer side +- [ ] Phase 0 capture scripts successfully re-run with confirmed inputs + +**Signed:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_    **Date:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_ +``` + +- [ ] **Step 2: Commit the checklist** + +```bash +git add docs/superpowers/phase-0/customer-confirmation-checklist.md +git commit -m "Add customer confirmation checklist for Phase 0 blockers + +Questionnaire covering spec assumptions A1–A7 plus Phase 0 prerequisites +(prod URL, access logs, escalation contact). Must be filled in by the +customer point-of-contact before Phase 1 can start." +``` + +--- + +## Task 15: Phase 0 README + manifest + +Indexes all Phase 0 deliverables so future engineers can find them without re-reading the plan. + +**Files:** +- Create: `docs/superpowers/phase-0/README.md` + +- [ ] **Step 1: Author the README** + +Create `docs/superpowers/phase-0/README.md`: + +```markdown +# Phase 0 — Preflight deliverables + +This directory indexes everything Phase 0 produced. Phase 1 and later phases consume these artifacts; none of them should be hand-edited after Phase 0 closes — regenerate by re-running the capture scripts if the source changes. + +## What Phase 0 produced + +### Fixtures (consumed by Phase 1+ tests) + +| Artifact | Path | Produced by | +|---|---|---| +| URL corpus (per feature) | `tests/fixtures/phase-0/url-corpus/{feature}.json` | `scripts/phase-0/extract-url-corpus.ts` | +| SEO baselines | `tests/fixtures/phase-0/seo-baselines/*.json` | `scripts/phase-0/capture-seo-baselines.ts` | +| Hreflang parity baselines | `tests/fixtures/phase-0/hreflang-parity/*.json` | `scripts/phase-0/capture-hreflang-parity.ts` | +| VRT baselines (60 PNGs) | `tests/fixtures/phase-0/vrt-baselines/*.png` | `tests/phase-0/vrt/baseline.spec.ts` via `playwright.phase0.config.ts` | + +### Inventory documents (consumed by Phase 1 sub-plans) + +| Document | Consumer | +|---|---| +| `primeng-backlog.md` | Phase 1E (UI adapter) | +| `scss-theme-manifest.md` | Phase 1E (UI adapter) | +| `translation-keys-used.md` | Phase 1C (i18n) | + +### Gate documents + +| Document | Purpose | +|---|---| +| `customer-confirmation-checklist.md` | Phase 0 exit gate — blockers for Phase 1 | + +## How to regenerate + +All scripts assume Node 20 + pnpm. Set `PROD_ORIGIN` for scripts that hit the live Angular app. + +```bash +pnpm install + +# URL corpus (enumerated + optional observed) +pnpm phase0:url-corpus + +# SEO + hreflang (require PROD_ORIGIN) +PROD_ORIGIN=https://flights.aeroflot.ru pnpm phase0:seo +PROD_ORIGIN=https://flights.aeroflot.ru pnpm phase0:hreflang + +# VRT (requires PROD_ORIGIN; slow — captures 60 PNGs) +PROD_ORIGIN=https://flights.aeroflot.ru pnpm phase0:vrt + +# Inventories (read-only scans of ClientApp/src, no network) +pnpm phase0:inventory:primeng +pnpm phase0:inventory:scss +pnpm phase0:inventory:i18n +``` + +## Phase 0 exit gate + +Phase 0 is complete when: + +- [ ] All 4 URL corpus fixtures exist and have non-zero `count` +- [ ] At least 20 SEO baseline fixtures captured without errors +- [ ] 60 VRT baseline PNGs captured without errors +- [ ] All three inventory markdown files generated and non-empty +- [ ] `customer-confirmation-checklist.md` has signed answers for A1–A8 +- [ ] Any "Angular hreflang bugs" from Task 9 are noted in this README (§ below) + +## Angular baseline anomalies + +Issues in the current Angular app discovered during Phase 0, noted here so the Phase 2+ parity gates know to *improve* on them rather than replicate them: + +- (Populate during Task 9 / Task 12 / Task 13 reviews) +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/superpowers/phase-0/README.md +git commit -m "Add Phase 0 deliverables README + +Indexes URL corpus, SEO/hreflang/VRT baselines, and inventory documents +for Phase 1+ consumers. Documents regeneration commands and the exit +gate checklist." +``` + +--- + +## Task 16: Phase 0 exit gate — verify everything and close out + +Final pass that confirms Phase 0 is complete and produces nothing new — just asserts the state is correct. + +**Files:** none (verification only) + +- [ ] **Step 1: Count URL corpus entries** + +```bash +for f in tests/fixtures/phase-0/url-corpus/*.json; do + echo -n "$f: " + node -e "console.log(require('./$f').count)" +done +``` + +Expected: all 4 files report non-zero counts. + +- [ ] **Step 2: Count SEO baselines** + +```bash +ls tests/fixtures/phase-0/seo-baselines/*.json 2>/dev/null | wc -l +``` + +Expected: at least 20 (ideally 26: 13 route shapes × 2 languages). + +- [ ] **Step 3: Count VRT baselines** + +```bash +ls tests/fixtures/phase-0/vrt-baselines/*.png 2>/dev/null | wc -l +``` + +Expected: `60`. + +- [ ] **Step 4: Confirm inventory docs exist and are non-empty** + +```bash +wc -l docs/superpowers/phase-0/primeng-backlog.md docs/superpowers/phase-0/scss-theme-manifest.md docs/superpowers/phase-0/translation-keys-used.md +``` + +Expected: all three files have > 10 lines. + +- [ ] **Step 5: Run the full unit-test suite one more time** + +```bash +pnpm test +``` + +Expected: all Phase 0 unit tests pass (`angular-routes`, `http`, `seo-extractor`). + +- [ ] **Step 6: Confirm customer checklist exists (answers pending is OK for this step — blocking Phase 1 but not Phase 0 closure)** + +```bash +test -f docs/superpowers/phase-0/customer-confirmation-checklist.md && echo "present" +``` + +Expected: `present` + +- [ ] **Step 7: Tag the Phase 0 closure commit** + +```bash +git tag -a phase-0-complete -m "Phase 0 (Preflight) deliverables captured and committed" +git push origin phase-0-complete +``` + +Expected: annotated tag created on origin. + +- [ ] **Step 8: Open a tracking issue (if using GitHub/GitLab)** + +Copy the customer confirmation checklist into a tracking issue titled "Phase 0 → Phase 1 blockers: customer answers needed" with the file's contents. Assign to the customer point-of-contact. Phase 1A does not start until that issue is closed with signed answers. + +--- + +## Self-review + +**Spec coverage against the design spec §9.2 Phase 0:** + +- [x] URL corpus from access logs — Tasks 5, 6 (supports both observed + enumerated modes) +- [x] JSON-LD / OG / hreflang baselines on representative routes — Tasks 8, 9 +- [x] VRT baselines of Angular prod — Task 10 +- [x] PrimeNG component inventory → `src/ui/primitives/` backlog — Task 11 +- [x] SCSS token + theme inventory → `src/ui/styles/` port list — Task 12 +- [x] `@ngx-translate` keys actually in use → translation-file port manifest — Task 13 +- [x] Customer confirmation of spec assumptions (§2.5, §8.2, §8.5, §7.2) — Task 14 +- [x] Phase 0 deliverables committed as fixtures + indexed — Tasks 15, 16 + +**Placeholder scan.** Searched for TBD/TODO/placeholder patterns in the task bodies above. Found one: the PrimeNG backlog template has a "(TBD — see §5.3 of the design spec)" column in the generated markdown. This is acceptable — it's a column in *generated output* that Phase 1E engineers fill in when they claim a component; it is not a gap in the plan itself. + +**Type consistency.** `RouteShape`, `RouteFeature`, `LANGUAGES`, `VrtRoute`, `ExtractedSeo`, `FetchWithRetryOptions`, `InventoryRow`, `Sighting` — all defined in exactly one place, referenced consistently across tasks. No Task-N-to-Task-M drift. + +**Scope check.** Phase 0 is appropriately sized for one plan file (~16 tasks over ~1 week of work). No decomposition needed. Phase 1 is a separate plan (see next document).