Files
flights_web/docs/superpowers/plans/2026-04-14-phase-0-preflight.md
T
gnezim 013fad6236 Add Phase 0 (Preflight) implementation plan
16 tasks covering URL corpus extraction, SEO + hreflang + VRT baseline
capture from Angular prod, PrimeNG/SCSS/i18n inventories, and the
customer confirmation checklist. Phase 0 is discovery-only; no
production change. Output is the fixture + inventory set Phase 1
sub-plans consume.
2026-04-14 19:47:54 +03:00

76 KiB
Raw Blame History

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 A1A5 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   # A1A5 + 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

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
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
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:

{
  "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:

packages:
  - '.'
  - '!ClientApp'
  • Step 4: Create root tsconfig.json

Create tsconfig.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
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

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
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:

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
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:

cat ClientApp/src/app/app-routing.module.ts
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:

/**
 * 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:

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
pnpm test

Expected: 6 passing tests, 0 failing.

  • Step 5: Create the small JSON I/O helper

Create scripts/phase-0/lib/io.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
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:

#!/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)
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)
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:

#!/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
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
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
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:

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
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:

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
pnpm test tests/phase-0/lib/http.test.ts

Expected: 4 passing tests.

  • Step 5: Commit
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:

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
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:

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
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:

#!/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
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
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
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:

#!/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
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)
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
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:

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:

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:

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
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
ls tests/fixtures/phase-0/vrt-baselines/*.png | wc -l

Expected: 60

  • Step 6: Commit
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:

#!/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
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
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
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:

#!/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
pnpm phase0:inventory:scss

Expected: stderr prints the write confirmation; the manifest file exists.

  • Step 3: Spot-check
head -40 docs/superpowers/phase-0/scss-theme-manifest.md

Expected: totals line with non-zero counts + the "SCSS variables" table.

  • Step 4: Commit
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:

#!/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
pnpm phase0:inventory:i18n

Expected: stderr prints live=N dead=M broken=K.

  • Step 3: Commit
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:

# 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
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 A1A7 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:

# 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 A1A8
  • 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
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
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
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
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
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)
test -f docs/superpowers/phase-0/customer-confirmation-checklist.md && echo "present"

Expected: present

  • Step 7: Tag the Phase 0 closure commit
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:

  • URL corpus from access logs — Tasks 5, 6 (supports both observed + enumerated modes)
  • JSON-LD / OG / hreflang baselines on representative routes — Tasks 8, 9
  • VRT baselines of Angular prod — Task 10
  • PrimeNG component inventory → src/ui/primitives/ backlog — Task 11
  • SCSS token + theme inventory → src/ui/styles/ port list — Task 12
  • @ngx-translate keys actually in use → translation-file port manifest — Task 13
  • Customer confirmation of spec assumptions (§2.5, §8.2, §8.5, §7.2) — Task 14
  • 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).