# 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 <head> + 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/<route-slug>.json # ~20 files
tests/fixtures/phase-0/hreflang-parity/<route-slug>.json
tests/fixtures/phase-0/vrt-baselines/<route>-<viewport>-<lang>.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<Record<string, string>>;
}

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, string>): 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<T = unknown>(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<void> {
  const rl = createInterface({ input: process.stdin });
  const seen = new Set<string>();

  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<RouteFeature, CorpusEntry[]> {
  const byFeature = new Map<RouteFeature, CorpusEntry[]>();
  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<RouteFeature, CorpusEntry[]>): 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<string, CorpusEntry>();
  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<string, string>;
}

let lastCallAt = 0;

export async function fetchWithRetry(
  url: string,
  opts: FetchWithRetryOptions = {},
): Promise<Response> {
  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<void> {
  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 `<title>`, `<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</title>
  <meta name="description" content="Актуальный статус рейса SU100">
  <link rel="canonical" href="https://flights.aeroflot.ru/ru/onlineboard/SU100-2025-01-15">
  <link rel="alternate" hreflang="ru" href="https://flights.aeroflot.ru/ru/onlineboard/SU100-2025-01-15">
  <link rel="alternate" hreflang="en" href="https://flights.aeroflot.ru/en/onlineboard/SU100-2025-01-15">
  <link rel="alternate" hreflang="x-default" href="https://flights.aeroflot.ru/ru/onlineboard/SU100-2025-01-15">
  <meta property="og:title" content="SU100 status">
  <meta property="og:type" content="website">
  <meta property="og:url" content="https://flights.aeroflot.ru/ru/onlineboard/SU100-2025-01-15">
  <meta property="og:image" content="https://flights.aeroflot.ru/og/default.png">
  <meta name="twitter:card" content="summary_large_image">
  <script type="application/ld+json">{"@context":"https://schema.org","@type":"Flight","flightNumber":"SU100"}</script>
</head>
<body></body>
</html>
`;

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("<html><head></head><body></body></html>");
    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<string, string>;
  openGraph: Record<string, string>;
  twitterCard: Record<string, string>;
  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<string, string> = {};
  $('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<string, string> = {};
  $('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<string, string> = {};
  $('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<void> {
  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/<slug>.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<void> {
  for (const shape of ROUTE_SHAPES) {
    const sample = shape.samples[0];
    if (!sample) continue;
    const rendered = renderTemplate(shape.template, sample);

    const perLang: Record<string, { url: string; hreflang: Record<string, string> | 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<string, string> => 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<T extends Record<string, unknown>>(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)
 *   - <p-*> 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 = /<p-([a-z][a-zA-Z0-9-]*)/g;

interface InventoryRow {
  item: string;
  kind: "import" | "tag";
  files: Set<string>;
}

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<string, InventoryRow>();

  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 <p-*> 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<string, Sighting[]>;
  cssVars: Map<string, Sighting[]>;
  primeSelectors: Map<string, Sighting[]>;
} {
  const scssVars = new Map<string, Sighting[]>();
  const cssVars = new Map<string, Sighting[]>();
  const primeSelectors = new Map<string, Sighting[]>();

  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<string, Sighting[]>, file: string): void {
  const matches = [...source.matchAll(regex)];
  const localCounts = new Map<string, number>();
  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("<br>")} |`);
  }
  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("<br>")} |`);
  }
  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("<br>")} |`);
  }
  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<string>();
  const usedFiles = new Map<string, Set<string>>();

  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("<details><summary>Show list</summary>");
  lines.push("");
  for (const key of live) lines.push(`- \`${key}\``);
  lines.push("");
  lines.push("</details>");
  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("<details><summary>Show list</summary>");
  lines.push("");
  for (const key of dead) lines.push(`- \`${key}\``);
  lines.push("");
  lines.push("</details>");
  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:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_ &nbsp;&nbsp; **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).
