Skip to content

Compiler Build Pipeline

github-actions[bot] edited this page Nov 2, 2026 · 5 revisions

Compiler build pipeline

What pvl compile does, from reading pvlconfig.json to writing the Destination Directory. This part is the same for every Schema. Each schema page shows only what its own Emitter adds, and Compiled schema runtime shows how the written code runs.

The short version

The compiler never runs or imports your code. It parses your files as text (with ts-morph), finds every pvl.compile(...) call, reads the Schema inside it, and writes a copy of your source folder with each call replaced by plain validation code.

flowchart LR
    CFG["pvlconfig.json<br/>+ CLI flags"] --> SCAN["find the files<br/>include matches"]
    SCAN --> READ["parse them and read<br/>each pvl.compile(...)"]
    READ --> CHECK{"any error?"}
    CHECK -- yes --> STOP["report the Diagnostics,<br/>write nothing"]
    CHECK -- no --> EMIT["emit each Compiled Schema<br/>into a mirror of the file"]
    EMIT --> WRITE["write the<br/>Destination Directory"]
Loading

The whole run

pvl compile (bin.ts → runCli) parses the flags and calls runCompilation, the programmatic entry point. Every step that can fail adds a Diagnostic and keeps going, so one run lists every problem at once. Only when there are none is anything written.

By default the CLI draws each phase as it enters it, on stdout, and every Diagnostic on stderr at the end (ADR-0051). --tty false prints plain lines instead, and --json only the result:

┌  pvl compile
│
◇  Scanning
│
◇  Compiling
│
◇  Writing
│
└  📁 Written to .pvl  (⚠️ 1 warning)
flowchart TD
    CLI["pvl compile --flags<br/>runCli"] --> RC["runCompilation"]
    RC --> CR["ConfigResolver.resolveConfig<br/>flags, then pvlconfig.json, then defaults;<br/>finds the base directory"]
    CR --> CRD{"settings valid?"}
    CRD -- no --> E1["NO_CONFIG, CONFIG_UNREADABLE,<br/>INVALID_CONFIG"]
    CRD -- yes --> PAR["in parallel"]
    PAR --> DW["DestinationWriter.checkDestination<br/>DESTINATION_UNWRITABLE, DESTINATION_NOT_EMPTY"]
    PAR --> SC["Scanner.scan<br/>the files include matches;<br/>NO_INPUT_FILES, DESTINATION_INSIDE_INCLUDE"]
    DW --> TSP["TsMorphProject.create<br/>reads tsconfig.json for alias paths;<br/>TSCONFIG_UNREADABLE"]
    SC --> TSP
    TSP --> MOD["Scanner.getModules<br/>one Module per file"]
    MOD --> PC["Precheck.precheckModules<br/>(next graph)"]
    PC --> ERR{"any error?<br/>(or any warning with --strict)"}
    ERR -- yes --> NOWRITE["print the Diagnostics, exit 1,<br/>write nothing"]
    ERR -- no --> CA["compileAll<br/>Module.compile for each file"]
    CA --> WD["DestinationWriter.writeDestination<br/>into .pvl.pvl-tmp, then swapped in"]
    WD --> DONE["exit 0"]
Loading

Reading the Schemas: Precheck

Precheck checks each Module and reads its Marked-to-Compile Schemas. Reading is purely static: a Schema must be a pvl.* call chain written in place, or a top-level const of the same file holding one.

flowchart TD
    M["Module"] --> P1{"parses?"}
    P1 -- no --> PF["PARSE_FAILED<br/>(only FILE_OUTSIDE_ROOT_DIR<br/>is checked besides)"]
    P1 -- yes --> P2["FILE_OUTSIDE_ROOT_DIR, DEFAULT_EXPORT,<br/>FILE_EXPORTS_NOTHING, SIDE_EFFECT_COPIED"]
    P2 --> FM["findMarkedToCompileSchemas"]
    FM --> LN["PvlAst.findPvlImportLocalNames<br/>the names pvl is imported under"]
    LN --> CALLS["PvlAst.findCompileCalls<br/>every pvl.compile(...) call"]
    CALLS --> MODQ{"a method chained onto<br/>the result? .optional()…"}
    MODQ -- yes --> CRM["COMPILE_RESULT_MODIFIED"]
    MODQ -- no --> RS["readSchema<br/>(next graph)"]
    RS --> RSQ{"read statically?"}
    RSQ -- no --> CAU["COMPILE_ARGUMENT_UNRESOLVABLE,<br/>COMPILE_ARGUMENT_CYCLE"]
    RSQ -- yes --> UC["schema.findUncompilableDiagnostics"]
    UC --> UCQ{"an Emitter for it, its every<br/>method, and every nested Schema?"}
    UCQ -- no --> US["UNSUPPORTED_SCHEMA"]
    UCQ -- yes --> OK["a Compilable Schema,<br/>handed to Module.compile"]
Loading

readSchema turns one call chain into a tree of compiler Schema objects, one subclass per factory, each naming its Emitter:

flowchart TD
    EXP["pvl.compile(pvl.object({ name: pvl.string().max(max) }).strict())"] --> RCC["PvlAst.readCallChain<br/>splits it into the factory call<br/>and the methods chained after it"]
    RCC --> F{"factory?"}
    F -- "compile" --> RCC2["read its argument the same way,<br/>keeping the methods chained outside"]
    RCC2 --> F
    F -- "not a pvl factory" --> UNR["COMPILE_ARGUMENT_UNRESOLVABLE"]
    F -- "string, number, object…" --> MAP["SCHEMA_CLASS_BY_FACTORY<br/>object → ObjectSchema"]
    MAP --> RFA["ObjectSchema.readFactoryArgument<br/>reads the shape; each field<br/>through readNested, recursively"]
    MAP --> RMC["readMethodCall for each chained method<br/>.standalone() only sets isStandalone;<br/>.transform() is split off as transformCall"]
    RMC --> ARG{"each argument a literal<br/>written in place?"}
    ARG -- "yes: 3, 'a', { message: 'x' }" --> LIT["read as a value,<br/>written into the code later"]
    ARG -- "no: max, getMax(), a function" --> RA["a RuntimeArgument,<br/>evaluated by the Compiled Schema"]
    RFA --> TREE["ObjectSchema { calls: [strict],<br/>shape: [name: StringSchema { calls: [max] }] }"]
    LIT --> TREE
    RA --> TREE
Loading

A literal argument is baked into the code. Anything else is a Runtime Argument, passed to the Compiled Schema's constructor and evaluated once, where it is written.

Writing a file: Module.compile

Each scanned file becomes one mirrored file at the same path under the Destination Directory, whether or not it marks a Schema.

flowchart TD
    MC["Module.compile"] --> HO["nameHoistedArguments<br/>a Runtime Argument in a top-level const<br/>the call follows → const pvlArg0 = …"]
    HO --> CS["compileSchemas: for each Compilable Schema"]
    CS --> SCOMP["schema.compile()<br/>→ its Emitter's emitCompiledSchema"]
    SCOMP --> EMIT["the Emitter writes the parts,<br/>the Frame composes the class<br/>(Compiled schema runtime)"]
    EMIT --> REP["the class expression replaces<br/>the pvl.compile(...) call in the text"]
    REP --> IMP["add a type-only import of<br/>Schema, Issue, Result… from @pcvl/schema"]
    IMP --> SPEC["rewrite module specifiers so they<br/>still resolve from the mirror"]
    SPEC --> OUT["the mirrored file's text"]
Loading

Only the outermost pvl.compile(...) is replaced: one nested inside another's argument is compiled into it. A file with no pvl.compile(...) is copied with only its module specifiers rewritten.

Example

The source file src/schemas/string.ts:

import { pvl } from '@pcvl/schema';

export const username = pvl.compile(pvl.string().min(3).max(20));

becomes .pvl/schemas/string.ts (the class body is shortened here; String schema shows it whole):

// @generated by @pcvl/compiler. Do not edit: run `pvl compile` to regenerate.
import { pvl } from '@pcvl/schema';
import type { Issue as PvlIssue, Result as PvlResult, SCHEMA_BRAND as PVL_SCHEMA_BRAND, Schema as PvlSchema, StandaloneMarker as PvlStandaloneMarker, StandardSchemaProps as PvlStandardSchemaProps } from '@pcvl/schema';

export const username = (new (class PvlCompiledSchema0 implements PvlSchema<string, string> {
  …
  _validate(value: unknown, path: ReadonlyArray<PropertyKey>): PvlResult<unknown> {
    const issues: PvlIssue[] = [];
    if (typeof value !== "string") {
      issues.push({ code: "INVALID_TYPE", message: "Expected string", path: path.length > 0 ? path : undefined });
    } else {
      if (value.length < 3) { … }
      if (value.length > 20) { … }
    }
    return issues.length > 0 ? { issues } : { value };
  }
  …
})() as PvlSchema<string, string>);

plus a .pvl-generated marker so a later run knows it may replace the folder.

Source

Further reading: compilation.md, configuration-and-cli.md, destination-directory.md, ADR-0002, ADR-0005.

Clone this wiki locally