/*
RFC-0082 shared parser for RFC-0076-headed onboarding artifacts.
Owns the rule "first YAML document is the RFC-0076 metadata header; last YAML
document is the artifact payload" so phase validation, audit validators, and
content-discipline parsers all see the same answer.
- Do not introduce a generic YAML schema framework — scoped to onboarding RFC-0076 metadata only.
- Do not read files from disk.
- Do not own the metadata key set beyond the four declared by RFC-0076.
- RFC-0082: Introduce shared multi-document YAML helper for onboarding artifacts.
*/
import YAML from "yaml";
import type { ZodType } from "zod";
/**
* The four top-level keys that belong to the RFC-0076 metadata header.
* Anything else at the top level is part of the artifact payload.
*/
export const RFC_METADATA_KEYS = [
"phase",
"derivedFromInputHash",
"generatedAt",
"generator",
] as const;
export type RfcMetadataKey = (typeof RFC_METADATA_KEYS)[number];
/**
* The RFC-0076 metadata header that every machine-readable file under
* onboarding/.output// MUST carry.
*
* - `phase` — the onboarding phase that produced the artifact (e.g. "04-author").
* - `derivedFromInputHash` — sha256 of the input manifest the artifact was derived from.
* - `generatedAt` — ISO 8601 timestamp.
* - `generator` — author name, typically "agent" or a command name.
*/
export interface RfcMetadataHeader {
phase: string;
derivedFromInputHash: string;
generatedAt: string;
generator: string;
}
function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function asString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function buildHeader(raw: Record): RfcMetadataHeader | null {
const phase = asString(raw["phase"]);
const hash = asString(raw["derivedFromInputHash"]);
if (phase === null || hash === null) {
return null;
}
return {
phase,
derivedFromInputHash: hash,
generatedAt: asString(raw["generatedAt"]) ?? "",
generator: asString(raw["generator"]) ?? "",
};
}
/**
* Read the RFC-0076 metadata header from an onboarding YAML artifact.
*
* Accepts both file shapes:
* - **Two-doc file**: first doc is the header, second doc is the payload.
* - **Single-doc file**: header keys and payload keys are merged at top level.
*
* Returns `null` when the source does not contain at minimum `phase` and
* `derivedFromInputHash` — callers can treat that as "no RFC-0076 header
* present" without inspecting the YAML themselves.
*/
export function parseOnboardingArtifactHeader(source: string): RfcMetadataHeader | null {
const docs = YAML.parseAllDocuments(source);
if (docs.length === 0) return null;
const first = docs[0].toJSON();
if (!isRecord(first)) return null;
return buildHeader(first);
}
/**
* Read the artifact payload from an onboarding YAML artifact and validate
* it against the caller-supplied Zod schema.
*
* Selection rule:
* - **Two-doc file** → the LAST document is the payload, returned unchanged
* (schemas can keep `.strict()` because the header lives in a separate doc).
* - **Single-doc file** → the document is the payload merged with the header;
* the four RFC_METADATA_KEYS are stripped before validation so `.strict()`
* schemas can still accept the file.
*
* Throws the Zod validation error on schema mismatch and a plain Error when
* the source contains zero documents or more than two documents (the second
* case enforces the "exactly header + payload" contract).
*/
export function parseOnboardingArtifactPayload(source: string, schema: ZodType): T {
const docs = YAML.parseAllDocuments(source);
if (docs.length === 0) {
throw new Error("parseOnboardingArtifactPayload: source contains no YAML documents.");
}
if (docs.length > 2) {
throw new Error(
`parseOnboardingArtifactPayload: expected exactly 1 or 2 YAML documents (RFC-0076 header + payload), found ${docs.length}.`,
);
}
let payload: unknown;
if (docs.length === 2) {
payload = docs[1].toJSON();
} else {
const merged = docs[0].toJSON();
if (isRecord(merged)) {
// Strip RFC-0076 metadata keys from the merged single-doc payload, BUT
// preserve any key the schema itself declares as a payload field. This
// matters most for `generatedAt`, which RFC-0076 carries in the header
// and several content-discipline schemas (e.g. contentAtomsFileSchema)
// also declare as a strict payload field. Without this carve-out the
// strip would erase a legitimate payload value and schema parse would
// fail with a confusing "expected string, received undefined" error.
const schemaKeys = getSchemaTopLevelKeys(schema);
const stripped: Record = { ...merged };
for (const key of RFC_METADATA_KEYS) {
if (!schemaKeys.has(key)) {
delete stripped[key];
}
}
payload = stripped;
} else {
payload = merged;
}
}
return schema.parse(payload);
}
/**
* Best-effort top-level-key extraction from a Zod schema. Used to decide which
* RFC-0076 metadata keys to preserve when stripping a merged single-doc
* payload. Returns an empty set when the schema does not expose a `.shape` —
* permissive schemas like z.unknown() / z.any() then accept the stripped
* payload as-is.
*/
function getSchemaTopLevelKeys(schema: ZodType): Set {
const candidate = schema as unknown as { shape?: Record };
if (candidate.shape && typeof candidate.shape === "object") {
return new Set(Object.keys(candidate.shape));
}
return new Set();
}