Using Psy

Compiling To Data

JSON, YAML and TOML output — plus determinism, headers, manifests and how stale artifacts get pruned.

Psy is a general declarative language, so the most ordinary thing to do with a resolved program is emit it as data.

import { defineConfig } from "@psy/config";
import { json, yaml, toml } from "@psy/adapter-data";

export default defineConfig({
    source: ["./config"],
    targets: [
        json({ output: "generated/json" }),
        yaml({ output: "generated/yaml" }),
        toml({ output: "generated/toml" }),
    ],
});
psy build
psy build --check

The input

Take the deployment example, which uses multiple inheritance and composition:

export const Region = eu-west-1

export spec BaseService:
    region: Region
    replicas: 2

    resources:
        cpu: 500m
        memory: 512Mi

    tags:
        - managed
        - psy

export spec Observability:
    tags:
        - metrics
        - traces

    resources:
        memory: 768Mi

export spec Api extends BaseService, Observability:
    name: api
    replicas: 4

    resources:
        + super

    tags:
        + super
        - public

Targets see resolved IR. Inheritance, super, composition and references are already applied. There is no templating step, and there is nothing left to evaluate — which is the entire reason a config language with no runtime is worth having.

JSON

{
  "name": "api",
  "replicas": 4,
  "resources": {
    "cpu": "500m",
    "memory": "512Mi"
  },
  "tags": [
    "managed",
    "psy",
    "metrics",
    "traces",
    "public"
  ],
  "region": "eu-west-1"
}

BaseService outranks Observability, so its memory won the conflict, and the tag list is deduplicated in precedence order.

YAML

# Generated by Psy from examples/deployment/main.psy. Do not edit by hand.

name: api
replicas: 4
resources:
  cpu: 500m
  memory: 512Mi
tags:
  - managed
  - psy
  - metrics
  - traces
  - public
region: eu-west-1

Multiline strings become literal block scalars, which is the main reason to prefer YAML for anything prose-shaped:

prompt: |-
  Read the relevant Psy specification before modifying code.

  Preserve compiler stage boundaries.

  Add or update tests for every semantic change.

Scalars are quoted only when they would otherwise change meaning — "yes", "3", "has: colon" — and keys that YAML 1.1 reads as booleans get quoted too. (YAML 1.1 thinks no is false. YAML 1.1 has opinions about Norway.)

TOML

# Generated by Psy from examples/deployment/main.psy. Do not edit by hand.

name = "api"
replicas = 4
tags = ["managed", "psy", "metrics", "traces", "public"]
region = "eu-west-1"

[resources]
cpu = "500m"
memory = "512Mi"

Scalars come before tables, arrays of objects become [[array-of-tables]] sections, and multiline strings use """ blocks.

TOML has no null. A property that resolves to null cannot be represented, so the emitter reports it rather than inventing a value:

psy warning PSY6001: Api: missing — TOML has no null; the key was omitted.

Options

All three take the same ones:

OptionMeaning
outputoutput directory, relative to the project root
keywordonly specs declared with this framework keyword
includeAbstractinclude abstract specs (default false)
includeUnexportedinclude specs that are not exported (default false)
modulesonly specs whose module path starts with one of these prefixes
mode"per-spec" (default) or "single"
filefile name used by mode: "single"
specRefs"name" (default) or "inline"
metadataadd a $psy block naming the spec, module, keyword and bases
fileName"slug" (default) or "name"
selectan extra predicate, applied after the built-in filters

By default only exported, concrete specs are emitted.

Selecting a subset

yaml({
    output: "generated/agents",
    keyword: "agent",         // only `agent Foo:` declarations
    modules: ["build/"],      // only from this part of the tree
})

One document instead of many

json({
    output: "generated",
    mode: "single",
    file: "psy-spec.json",
    modules: ["spec/"],
    metadata: true,
})
{
  "ParentPrecedence": {
    "$psy": {
      "name": "ParentPrecedence",
      "module": "spec/syntax/inheritance.psy",
      "keyword": "semantic-rule",
      "bases": ["SemanticRule"]
    },
    "description": "Defines precedence for multiple inheritance.",
    "semantics": "In extends A, B, C, A has higher precedence than B and B has\nhigher precedence than C."
  }
}

That is exactly how Psy publishes its own language specification as machine-readable data. See Psy Describes Psy.

Spec references

A spec reference renders as the spec's name by default, which keeps use: [ReadPsySpec] readable:

use:
  - ReadPsySpec
  - RunTests

With specRefs: "inline" the referenced spec is embedded instead. Cycles are broken by falling back to the name, so a self-referential spec cannot loop forever.

Determinism

Every target is deterministic: the same source always produces byte-identical output. Module order, spec order, property order and object key order are all stable functions of the source, file paths are relative to the project root, and nothing reads the clock.

psy build --check

fails when anything on disk differs from what would be generated. That is what makes generated files safe to commit, diff and review.

Headers and manifests

By default a generated file contains only your content. Nothing announces itself, because the files belong to your project, not to Psy.

export default defineConfig({
    source: ["./src"],
    artifacts: {
        header: true,
        manifest: true,
        index: true,
    },
});
FieldDefaultEffect
headerfalsea comment naming Psy and the source module, in formats that have comments
manifestfalse.psy-manifest, listing every path a target wrote
indexfalseaggregate index documents

With header: true, each artifact carries a line naming the module it came from:

<!-- Generated by Psy from src/skills/ReviewDiff.psy. Do not edit by hand; edit the .psy source and run `psy build`. -->

JSON never carries a header, because JSON has no comment syntax. Which is exactly why manifest exists.

Pruning

psy build removes artifacts it previously wrote but no longer generates. It identifies them two ways: a path listed in last time's .psy-manifest, or a file still carrying the generation header.

With neither enabled it can prove nothing, so it does not prune, and it tells you so:

note: stale artifacts were not removed; a build can only remove what it can
prove it wrote. Set `artifacts.manifest` or `artifacts.header` in psy.config.ts
to enable pruning.

Rename a declaration in that state and the old artifact is left behind forever. Turn on manifest — it is a single dotfile — and renaming just works.

Targets may nest their output directories. Each one prunes only its own. And a scoped build never prunes at all, because it cannot tell a stale artifact from one it simply did not produce this time.

Next: Day To Day

On this page