/** * Shared manifest schema + parser. * * Used by audit.ts, validate-parity.ts, and capture-previews.ts so the * three tools agree on: * 1. the Manifest / ManifestDemo TypeScript shape * 2. runtime shape validation of `manifest.yaml` * 3. the tagged-union return type distinguishing missing / * malformed / unreadable / ok */ import fs from "fs"; import yaml from "yaml"; /** * Branded, non-empty demo id. Structurally a string (so downstream * callers can still use `demo.id` in template strings, `Set` * membership, equality comparisons, etc.) but the branding prevents * arbitrary strings from flowing into a `DemoId` slot without going * through the `createDemoId` smart constructor. parseManifest is the * sole production caller of that constructor; it validates non-empty * at runtime and re-reports shape-malformed on failure. * * The `__brand` property is phantom-only — it does not exist at * runtime. That keeps the branded type zero-cost while still giving * the compiler a distinct nominal type for id values. */ export type DemoId = string & { readonly __brand: "DemoId" }; /** * Smart constructor for `DemoId`. Returns the branded value on success * or `null` if validation fails (non-string or empty string). Kept as * `null`-returning rather than throwing so `parseManifest` can turn a * failure into its usual `{kind:"malformed", subkind:"shape"}` result * without crossing an exception boundary. * * The parameter type is `unknown` — this function sits at an API * boundary (yaml.parse results, JSON-roundtripped demos, caller-supplied * strings) where the compile-time type does not hold. A widened param * keeps the runtime typeof guard alive rather than reducing to a dead * check. */ export function createDemoId(s: unknown): DemoId | null { if (typeof s !== "string" || s.length === 0) return null; return s as DemoId; } /** * One entry under `manifest.yaml :: demos[]`. Name is optional; id is * required AND non-empty (checked at runtime by parseManifest, typed * via the `DemoId` brand). * * Fields are `readonly`: parseManifest freezes the returned object so * downstream consumers cannot mutate a demo after validation. The type * matches the runtime freeze. */ export interface ManifestDemo { readonly id: DemoId; readonly name?: string; /** * Informational demos (e.g. cli-start): when set, the row surfaces this * copy-pasteable command in the dashboard instead of a live preview. * Such demos have no on-disk folder — parity / bundling skip them. */ readonly command?: string; /** * Relative URL path for the demo. `id` is the CATALOG identifier * (stable; matched against spec/qa filenames and shell-side registry * entries), whereas `route` is the deliberate URL / filesystem path * (`/demos/` — resolved to `src/app/demos//` by consumers * that need the on-disk location, e.g. bundle-demo-content and * validate-parity). The two are intentionally separate so renaming a * catalog id does not mass-rewrite URLs (and vice versa). * * Optional at the type boundary for backward compatibility with test * fixtures and historical manifests. When present, parseManifest * validates it is a non-empty string starting with "/demos/". * Consumers that need an on-disk demo directory should prefer * `route` when present and fall back to `id`. */ readonly route?: string; } /** * Union of the fields used by audit.ts / validate-parity.ts / capture-previews.ts. * * `slug` is REQUIRED: every manifest in showcase/integrations/ carries a * slug, and none of the three consumers ever constructs or accepts a * Manifest without one. Marking it required here lets downstream code * drop `manifest.slug ?? "(unknown)"` fallbacks without TypeScript * complaining. parseManifest enforces `slug` is a non-empty string at * runtime, so the type matches reality. * * `name` and `deployed` remain optional: in practice not every manifest * sets `name`, and `deployed` only appears when meaningful. * * `demos` is NON-optional but always set by parseManifest — an empty * readonly array when the manifest omits the field. Callers iterate * unconditionally instead of `?.` chaining. * * Fields are `readonly`: parseManifest deep-freezes the returned object * (including the nested `demos` array), so the public type matches the * runtime invariant. */ export interface Manifest { readonly slug: string; readonly name?: string; readonly deployed?: boolean; /** * Deployed backend URL for this integration (e.g. the Railway public * domain). Used by capture-previews.ts to navigate to each demo. Not * required by audit.ts / validate-parity.ts, so it remains optional; * callers that need it should check before use. * parseManifest validates that, when present, it is a non-empty string. */ readonly backend_url?: string; readonly demos: readonly ManifestDemo[]; } /** * Tagged union of manifest parse outcomes. Callers discriminate on * `kind`: * * - "missing" — manifest.yaml does not exist on disk * - "malformed" — file exists but its contents do not round-trip * to a valid Manifest. Further split on `subkind`: * "syntax" = YAML parser rejected the text outright * (unterminated arrays, bad indentation); * "shape" = YAML parsed but the resulting value * does not match the Manifest shape * (null/scalar top-level, non-array demos, * demo missing id, duplicate demo id, etc.) * - "unreadable" — file exists but readFileSync threw * (permissions, I/O race, etc.) * - "ok" — parse succeeded and shape validated * * The `subkind` discriminator on "malformed" lets callers route each * failure mode distinctly: a "syntax" subkind flags a likely typo in * the YAML source, whereas "shape" flags a schema-drift / validation * problem (missing required field, wrong type, duplicate id, etc.). */ export type ParsedManifest = | { kind: "ok"; manifest: Manifest } | { kind: "missing" } | { kind: "malformed"; subkind: "syntax" | "shape"; error: string } | { kind: "unreadable"; error: string }; function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e); } /** * Describe a value for error messages. Distinguishes null/array from * plain `typeof` because `typeof null === "object"` and * `typeof [] === "object"` both hide the real shape from users. */ function describeType(v: unknown): string { if (v === null) return "null"; if (Array.isArray(v)) return "array"; return typeof v; } /** * `Object.hasOwn`-backed predicate that narrows `obj` to a type where * `key` is known to exist. `Object.hasOwn` avoids inherited-property * pitfalls of the raw `in` operator for any plain object, and pairs * with the TS predicate so callers read `obj[key]` without further * casts. */ function hasOwnProp( obj: object, key: K, ): obj is object & Record { return Object.hasOwn(obj, key); } /** * Shared frozen empty-array sentinel reused across the "no demos * declared" path. Every ok-result's `.demos` is this same frozen * readonly value when the manifest omits demos, so callers can iterate * unconditionally and the public `readonly` type matches the runtime * freeze. */ const EMPTY_DEMOS: readonly ManifestDemo[] = Object.freeze([]); /** * Read + parse + validate a manifest.yaml at `filePath`. Returns a * tagged-union `ParsedManifest`; never throws for content errors. * * The shape checks are intentionally strict: * - top-level must be a plain object mapping (not null, scalar, or * array) — otherwise downstream `.demos` / `.deployed` reads would * TypeError at runtime; * - `slug` must be a non-empty string (every consumer relies * on it — missing slug is always a bug); * - `name`, if present, must be a string; * - `demos`, if present, must be an array of objects each with a * string `id`; * - `deployed`, if present, must be a boolean (YAML `"yes"` parses to * a string, which would be silently treated as truthy without this * check — classic footgun). * * Any shape failure produces * `{ kind: "malformed", subkind: "shape", error }` with a * human-readable reason. YAML parser failures produce * `{ kind: "malformed", subkind: "syntax", error }` (distinct so CI can * route syntax errors differently from schema-drift errors). Missing * files produce `{ kind: "missing" }` (distinct from malformed so * callers can emit different anomalies). Read errors (EACCES etc.) * produce `{ kind: "unreadable" }` — we do NOT collapse them into * malformed because the file's contents are not actually known to be * invalid. */ export function parseManifest( filePath: string, dirSlug?: string, ): ParsedManifest { // Empty-string dirSlug is a caller bug: `undefined` is the opt-out // sentinel ("caller did not supply a dir slug"). If a caller's // path.basename or similar produced `""` (trailing-slash path, // typo), silently collapsing to undefined would hide the bug and // skip the slug-mismatch guard. Surface as malformed-shape so the // caller sees the error at the parser boundary. if (dirSlug === "") { return { kind: "malformed", subkind: "shape", error: `caller passed empty dirSlug (use undefined to opt out of the slug-check)`, }; } // Use statSync instead of fs.existsSync so ENOENT and non-ENOENT // errno values (EACCES, ENOTDIR, etc.) are distinguished. existsSync // CONFLATES these: a manifest whose parent dir is 0700 owned by // another user returns false from existsSync, which would collapse // an infrastructure failure into a benign "missing" signal. The // long docstring on probeDir in validate-parity.ts explains the // same anti-pattern — the fix is to stat + inspect errno. try { fs.statSync(filePath); } catch (e) { const code = (e as NodeJS.ErrnoException)?.code; if (code === "ENOENT") return { kind: "missing" }; return { kind: "unreadable", error: errMsg(e) }; } let raw: string; try { raw = fs.readFileSync(filePath, "utf-8"); } catch (e) { return { kind: "unreadable", error: errMsg(e) }; } let parsed: unknown; try { parsed = yaml.parse(raw); } catch (e) { return { kind: "malformed", subkind: "syntax", error: errMsg(e) }; } // Top-level type guard. yaml.parse("") is null and yaml.parse("42") is // 42 — neither is a manifest. Arrays also aren't valid (manifest.yaml // is a mapping). if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { return { kind: "malformed", subkind: "shape", error: `expected YAML object at top level, got ${ parsed === null ? "null (empty file?)" : describeType(parsed) }`, }; } // At this point `parsed` is known to be a plain object mapping. We // narrow each field access through `hasOwnProp` so the compiler does // not need a blanket `as Record` cast — every read // below is narrowed by the predicate it just passed. const obj: object = parsed; // slug is required — every consumer (audit.ts / validate-parity.ts / // capture-previews.ts) assumes a slug exists. A manifest without one is // always a bug, not a tolerable edge case. if (!hasOwnProp(obj, "slug")) { return { kind: "malformed", subkind: "shape", error: `missing required "slug" (non-empty string)`, }; } const slug = obj.slug; if (typeof slug !== "string" || slug.length === 0) { // `hasOwnProp(obj, "slug")` already passed above, so the key is // present; the value can still be the empty string, null, or a // non-string. `describeType` covers all of those uniformly. return { kind: "malformed", subkind: "shape", error: `expected "slug" to be a non-empty string, got ${describeType(slug)}`, }; } // Slug-mismatch guard. Every consumer derives the target // package by computing `packagesDir//manifest.yaml`, so if the // manifest's declared slug disagrees with the directory that holds // it, downstream tools would silently key a copy-paste or rename // mistake into the wrong package. Catch at the parser so both tools // report the drift the same way. // // Opt-in: callers pass the expected dir slug via the `dirSlug` // parameter. When omitted (undefined), the check is skipped so // existing tests and programmatic callers that don't operate against // the packages tree continue to work. An explicit empty string is // rejected at the top of the function as a caller bug — `undefined` // is the opt-out sentinel; `""` is never a valid dir slug. // The two production callers (audit.ts, validate-parity.ts) pass the // slug they used to build the filePath. const expectedDirSlug = typeof dirSlug === "string" ? dirSlug : undefined; if (expectedDirSlug !== undefined && expectedDirSlug !== slug) { return { kind: "malformed", subkind: "shape", error: `slug mismatch: manifest declares "${slug}" but lives under dir "${expectedDirSlug}"`, }; } // name (optional) must be a string if present. let name: string | undefined; if (hasOwnProp(obj, "name") && obj.name !== undefined) { if (typeof obj.name !== "string") { return { kind: "malformed", subkind: "shape", error: `expected "name" to be a string, got ${describeType(obj.name)}`, }; } name = obj.name; } // deployed (optional) must be a real boolean if present. let deployed: boolean | undefined; if (hasOwnProp(obj, "deployed")) { if (typeof obj.deployed !== "boolean") { return { kind: "malformed", subkind: "shape", error: `expected "deployed" to be boolean, got ${describeType(obj.deployed)}`, }; } deployed = obj.deployed; } // backend_url (optional) must be a non-empty string if present. Not // required by the audit/parity tools, so an absent field is fine. let backendUrl: string | undefined; if (hasOwnProp(obj, "backend_url") && obj.backend_url !== undefined) { if (typeof obj.backend_url !== "string" || obj.backend_url.length === 0) { return { kind: "malformed", subkind: "shape", error: `expected "backend_url" to be a non-empty string, got ${describeType(obj.backend_url)}`, }; } backendUrl = obj.backend_url; } // demos must be an array of objects with non-empty string id. Both // "key absent" and "explicit null" (YAML `demos:`) are treated as // "no demos declared" — parseManifest normalizes to an empty frozen // array so `Manifest.demos` is always set. If the key is present with // a non-nullish value that is not an array, fall through and report. let demos: readonly ManifestDemo[] = EMPTY_DEMOS; if (hasOwnProp(obj, "demos") && obj.demos != null) { const rawDemos = obj.demos; if (!Array.isArray(rawDemos)) { return { kind: "malformed", subkind: "shape", error: `expected "demos" to be an array, got ${describeType(rawDemos)}`, }; } const validated: ManifestDemo[] = []; // Duplicate-id detection: two demos with the same id cascade into // double-counted coverage and double missing-demo-dir anomalies // downstream. Reject at validation time so the error surfaces at // the manifest, not at the consuming tool. const seen = new Set(); for (let i = 0; i < rawDemos.length; i++) { const d: unknown = rawDemos[i]; if (d === null || typeof d !== "object" || Array.isArray(d)) { return { kind: "malformed", subkind: "shape", error: `expected demos[${i}] to be an object, got ${describeType(d)}`, }; } if (!hasOwnProp(d, "id") || typeof d.id !== "string") { // Missing key or non-string value: describe the actual type so // debuggers can distinguish this from the empty-string branch // below. `describeType(undefined)` covers the missing-key case // (the `hasOwnProp` narrowing means `d.id` is typed as unknown // only inside the branch, but at runtime an absent key reads as // undefined). const actual = hasOwnProp(d, "id") ? d.id : undefined; return { kind: "malformed", subkind: "shape", error: `expected demos[${i}].id to be a string, got ${describeType(actual)}`, }; } const brandedId = createDemoId(d.id); if (brandedId === null) { // `d.id` is a string here (checked above); createDemoId only // returns null for the empty-string case. Keep the "non-empty // string" wording so this branch is distinct from the // missing/non-string branch above. return { kind: "malformed", subkind: "shape", error: `expected demos[${i}].id to be a non-empty string`, }; } if (seen.has(brandedId)) { return { kind: "malformed", subkind: "shape", error: `duplicate demo id "${brandedId}" at demos[${i}]`, }; } seen.add(brandedId); // demo-level `name` strictness: match the strictness applied to // other fields so schema drift (present-but-wrong-type name) // surfaces at the parser, not downstream. Absent `name` remains // valid. let demoName: string | undefined; if (hasOwnProp(d, "name") && d.name !== undefined) { if (typeof d.name !== "string") { return { kind: "malformed", subkind: "shape", error: `expected demos[${i}].name to be a string, got ${describeType(d.name)}`, }; } demoName = d.name; } // demo-level `command`: optional informational demos (e.g. cli-start). // When set, the row surfaces this copy-pasteable command in the // dashboard instead of a live preview. Such demos have no on-disk // folder — parity / bundling skip them. let demoCommand: string | undefined; if (hasOwnProp(d, "command") && d.command !== undefined) { if (typeof d.command !== "string" || d.command.length === 0) { return { kind: "malformed", subkind: "shape", error: `expected demos[${i}].command to be a non-empty string, got ${describeType(d.command)}`, }; } demoCommand = d.command; } // demo-level `route`: optional. When present, must be a non-empty // string beginning with "/demos/" so downstream consumers // (bundle-demo-content, validate-parity) can uniformly strip that // prefix to derive the on-disk demo directory. The `/demos/` guard // catches accidental absolute URLs or bare segments that would // silently point to the wrong directory at runtime. let demoRoute: string | undefined; if (hasOwnProp(d, "route") && d.route !== undefined) { if (typeof d.route !== "string" || d.route.length === 0) { return { kind: "malformed", subkind: "shape", error: `expected demos[${i}].route to be a non-empty string, got ${describeType(d.route)}`, }; } if (!d.route.startsWith("/demos/")) { return { kind: "malformed", subkind: "shape", error: `expected demos[${i}].route to start with "/demos/", got "${d.route}"`, }; } // Reject exactly "/demos/" (empty tail segment). Downstream // consumers strip the "/demos/" prefix to derive an on-disk // directory name; an empty tail would point at the parent // demos/ directory rather than a specific demo. Guard at the // parser boundary so validate-parity / bundle-demo-content can // treat a successful parse as a non-empty segment invariant. if (d.route.length <= "/demos/".length) { return { kind: "malformed", subkind: "shape", error: `expected demos[${i}].route to have a non-empty segment after "/demos/", got "${d.route}"`, }; } demoRoute = d.route; } const demoEntry: { id: DemoId; name?: string; command?: string; route?: string; } = { id: brandedId, }; if (demoName !== undefined) demoEntry.name = demoName; if (demoCommand !== undefined) demoEntry.command = demoCommand; if (demoRoute !== undefined) demoEntry.route = demoRoute; validated.push(Object.freeze(demoEntry)); } demos = Object.freeze(validated); } // Construct the result field-by-field from the narrowed locals so // there is no `as unknown as Manifest` double-cast crossing the // validation boundary. Each field below was checked individually // above; the compiler tracks the narrowed type through the local // bindings, so this object literal typechecks against Manifest // without any casts. The final object is frozen so the `readonly` // fields on Manifest match the runtime behavior. const manifest: Manifest = Object.freeze({ slug, ...(name !== undefined ? { name } : {}), ...(deployed !== undefined ? { deployed } : {}), ...(backendUrl !== undefined ? { backend_url: backendUrl } : {}), demos, }); return { kind: "ok", manifest }; }