Skip to content

Codegen

Greg Chambers edited this page Sep 30, 2026 · 3 revisions

Codegen turns your facts into typed constants for TypeScript, Swift and Kotlin, so code reads the price instead of retyping it. Every generated constant carries its own @starchart anchors comment, so the chart knows exactly which fact each line mirrors, and a fact change classifies those symbols as auto instead of "go find the hardcoded value". This page covers the config, the output shape for each language (real output from the demo), naming and escaping rules, how generated files are detected, the workflow, and the limits.

Source: codegen/.

Config

# .starchart/config.yaml
codegen:
  - lang: ts
    out: apps/web/lib/starchart-facts.ts
    entities: [addon:pro]
  - lang: swift
    out: apps/ios/Sources/Core/StarchartFacts.swift
    entities: [addon:pro]
    name: StarchartFacts              # Swift enum name (default StarchartFacts)
  - lang: kotlin
    out: apps/android/app/src/main/java/com/nebula/Facts.kt
    name: StarchartFacts              # Kotlin object name (default StarchartFacts)
    package: com.nebula.facts         # Kotlin package (optional)
Field Required Meaning
lang yes ts, swift or kotlin
out yes Output path, relative to the project root. Parent directories are created. Must resolve inside the project root, symlinks followed (see Output stays in the project)
entities no Only these entity ids. Default: every entity that has at least one fact with a value
name no Root enum (Swift) / object (Kotlin) name. Ignored for TS
package no Kotlin package line. Must be dotted identifiers, or codegen throws invalid Kotlin package name "…"

Running it

$ starchart codegen
unchanged apps/web/lib/starchart-facts.ts
unchanged apps/ios/Sources/Core/StarchartFacts.swift

Each target prints wrote or unchanged. Output is deterministic (entities and keys are sorted), and a file whose content would not change is not rewritten, so mtimes and diffs stay quiet. No codegen: targets is an error: starchart: no codegen targets in .starchart/config.yaml (exit 2).

Output stays in the project

Since 0.1.1 every out is resolved with the same symlink-aware check the fs adapter uses. Before, out: ../x.ts quietly wrote next to your repo. Now it throws, and nothing is written:

$ starchart codegen          # out: ../x.ts
starchart: path "../x.ts" is outside the project root
$ echo $?
2

A path that looks local but runs through a symlink pointing out of the repo gets the second message:

$ starchart codegen          # apps/web/lib -> a directory outside the project
starchart: path "apps/web/lib/starchart-facts.ts" resolves outside the project root (symlink)

writeCodegen() throws the same errors to library callers, and inside apply the codegen step fails (see Apply, Revert and Journals). generateCode() only returns a string, so its out is never checked.

What gets emitted

collectEntities() walks the partOf hierarchy the compiler builds from YAML. Each entity becomes a namespace, each fact group a nested namespace, each leaf fact a constant. Facts without a value (an unresolved code-authority fact) are skipped, and so are container facts whose children were all skipped. An entity's status: is emitted too, because the compiler tracks it as a fact.

TypeScript

Real output for addon:pro in the demo:

// @starchart generated — do not edit
// Facts as code, generated by STARCHART from .starchart/. Regenerate with `starchart codegen`.
/* eslint-disable */

// ── addon:pro ──

// @starchart anchors addon:pro.billing
export const ADDON_PRO_BILLING = "monthly";
// @starchart anchors addon:pro.features
export const ADDON_PRO_FEATURES = ["Themes", "iCloud sync"] as const;
// @starchart anchors addon:pro.name
export const ADDON_PRO_NAME = "Nebula Pro";
// @starchart anchors addon:pro.price.eur
export const ADDON_PRO_PRICE_EUR = 4.99;
// @starchart anchors addon:pro.price.usd
export const ADDON_PRO_PRICE_USD = 4.99;
// @starchart anchors addon:pro.productId
export const ADDON_PRO_PRODUCT_ID = "nebula_pro_monthly";
// @starchart anchors addon:pro.status
export const ADDON_PRO_STATUS = "active";

export const ADDON_PRO = {
  billing: ADDON_PRO_BILLING,
  features: ADDON_PRO_FEATURES,
  name: ADDON_PRO_NAME,
  price: {
    eur: ADDON_PRO_PRICE_EUR,
    usd: ADDON_PRO_PRICE_USD,
  },
  productId: ADDON_PRO_PRODUCT_ID,
  status: ADDON_PRO_STATUS,
} as const;

/** Every leaf fact by id. */
export const FACTS = {
  "addon:pro.billing": ADDON_PRO_BILLING,
  "addon:pro.features": ADDON_PRO_FEATURES,
  "addon:pro.name": ADDON_PRO_NAME,
  "addon:pro.price.eur": ADDON_PRO_PRICE_EUR,
  "addon:pro.price.usd": ADDON_PRO_PRICE_USD,
  "addon:pro.productId": ADDON_PRO_PRODUCT_ID,
  "addon:pro.status": ADDON_PRO_STATUS,
} as const;

export type FactId = keyof typeof FACTS;

Three shapes, one source:

Export Shape Use it for
ADDON_PRO_PRICE_USD one top-level const per leaf, each with its own anchor anything; this is the node the chart links
ADDON_PRO nested as const object built from those constants ADDON_PRO.price.usd ergonomics
FACTS / FactId flat map keyed by fact id lookups by id, typed with FactId

Object and array values get as const. Values are JSON-style literals (NaN, Infinity, -Infinity for non-finite numbers). Object keys that are not identifiers are quoted.

Swift

Real output:

// @starchart generated — do not edit
// Facts as code, generated by STARCHART from .starchart/. Regenerate with `starchart codegen`.

public enum StarchartFacts {
    /// addon:pro
    public enum AddonPro {
        // @starchart anchors addon:pro.billing
        public static let billing: String = "monthly"
        // @starchart anchors addon:pro.features
        public static let features: [String] = ["Themes", "iCloud sync"]
        // @starchart anchors addon:pro.name
        public static let name: String = "Nebula Pro"
        public enum Price {
            // @starchart anchors addon:pro.price.eur
            public static let eur: Double = 4.99
            // @starchart anchors addon:pro.price.usd
            public static let usd: Double = 4.99
        }
        // @starchart anchors addon:pro.productId
        public static let productId: String = "nebula_pro_monthly"
        // @starchart anchors addon:pro.status
        public static let status: String = "active"
    }
}

Caseless enums are namespaces (they cannot be instantiated). Type mapping:

Fact value Swift
string String
boolean Bool
safe integer Int
other number Double (always with a ., Double.nan, Double.infinity)
homogeneous string / bool / int / double array [String] / [Bool] / [Int] / [Double] (empty array → [String])
null String? = nil
object nested public enum with its keys sorted
mixed array String holding the JSON, with a // JSON-encoded (not a homogeneous scalar list) comment

Kotlin

Generated from the demo facts with package: com.nebula.facts and entities: [addon:pro, app:nebula]:

// @starchart generated — do not edit
// Facts as code, generated by STARCHART from .starchart/. Regenerate with `starchart codegen`.

package com.nebula.facts

object StarchartFacts {
    /** addon:pro */
    object AddonPro {
        // @starchart anchors addon:pro.billing
        const val BILLING: String = "monthly"
        // @starchart anchors addon:pro.features
        val FEATURES: List<String> = listOf("Themes", "iCloud sync")
        // @starchart anchors addon:pro.name
        const val NAME: String = "Nebula Pro"
        object Price {
            // @starchart anchors addon:pro.price.eur
            const val EUR: Double = 4.99
            // @starchart anchors addon:pro.price.usd
            const val USD: Double = 4.99
        }
        // @starchart anchors addon:pro.productId
        const val PRODUCT_ID: String = "nebula_pro_monthly"
        // @starchart anchors addon:pro.status
        const val STATUS: String = "active"
    }

    /** app:nebula */
    object AppNebula {
        // @starchart anchors app:nebula.appStoreId
        const val APP_STORE_ID: String = "6450000000"
        // @starchart anchors app:nebula.category
        const val CATEGORY: String = "ProductivityApplication"
        // @starchart anchors app:nebula.name
        const val NAME: String = "Nebula"
        // @starchart anchors app:nebula.operatingSystem
        const val OPERATING_SYSTEM: String = "iOS 18+"
    }
}

const val for strings, booleans and numbers; plain val for lists and nullable values (Kotlin does not allow const there).

Fact value Kotlin
string const val X: String
boolean const val X: Boolean
integer within 32-bit const val X: Int
larger safe integer const val X: Long = 123L
other number const val X: Double (Double.NaN, Double.POSITIVE_INFINITY, …)
homogeneous list val X: List<String|Boolean|Int|Long|Double> = listOf(…) (empty string/bool list → emptyList())
null val X: String? = null
object nested object
mixed array const val X: String with the JSON and the same // JSON-encoded … comment

Naming rules

Names come from fact keys and entity ids, split into words on camelCase boundaries and on any non-alphanumeric character (addon:pro → addon, pro; productId → product, id).

Target Entity namespace Group / object key Leaf constant
TS UPPER_SNAKE of the entity id (ADDON_PRO) camelCase key inside the nested object UPPER_SNAKE of the full fact id (ADDON_PRO_PRICE_USD)
Swift PascalCase (AddonPro) PascalCase enum (Price) camelCase (productId)
Kotlin PascalCase object PascalCase object UPPER_SNAKE (PRODUCT_ID)
  • A name that starts with a digit gets a leading _ (_2026).
  • An empty name becomes value / Value / VALUE.
  • Collisions within one scope get _2, _3, … (TS reserves FACTS at top level, so an entity called facts becomes FACTS_2).
  • Swift: keywords are backticked (`default`); types that would shadow Type, Self, Protocol, Any, String, Int, Double, Bool, Array, Optional get a trailing _. A root name that is not a valid identifier (or is a keyword) is PascalCased.
  • Kotlin: types that would shadow Any, String, Int, Long, Double, Boolean, List, Unit, Nothing, Companion get a trailing _; hard keywords in the package path are backticked.

Escaping

Target Strings
TS JSON.stringify
Swift \\, \", \n, \r, \t, \0; every other control or non-ASCII character as \u{XXXX} (so 日本 is emitted as "\u{65E5}\u{672C}")
Kotlin \\, \", \$ (no accidental string templates), \n, \r, \t, \b; other control / non-ASCII as \uXXXX

Comments (entity headers, anchor lines) have */ defused and line breaks collapsed, so a fact id cannot break out of a comment.

The generated marker and per-leaf anchors

Every file starts with:

// @starchart generated — do not edit

The code ingester checks the first five lines for @starchart generated. When found:

  • the file node and every symbol in it get meta.generated: true;
  • starchart scan treats all literals in the file as bound;
  • starchart init --discover ignores its constants.

Each leaf constant is preceded by // @starchart anchors <fact id>. That comment is a normal annotation: it attaches to the next declaration and creates symbol --anchors--> fact with origin annotation. You get one precise bridge per constant, not one vague bridge per file.

How generated symbols classify in impact

anchors propagates both ways. When a fact changes, the impact walk reaches every symbol that anchors it, and classify() decides:

Symbol Class Reason
generated (meta.generated) auto regenerate fact constants (codegen)
hand-written, anchors a fact code hardcoded value anchors this fact; update or switch to codegen
hand-written, anchors an artifact (e.g. a Stripe price id) code holds this artifact's external id; update it if the id changes

From the demo, starchart impact addon:pro.price.usd:

  ~ auto    symbol:ios/StarchartFacts.AddonPro.Price.usd        anchors    regenerate fact constants (codegen)
  ~ auto    symbol:web/lib/starchart-facts#ADDON_PRO_PRICE_USD  anchors    regenerate fact constants (codegen)
  …
  ⌘ code    symbol:ios/Pricing.proUSD                           anchors    hardcoded value anchors this fact; update or switch to codegen
  ⌘ code    symbol:web/lib/stripe#PRICE_PRO_MONTHLY             anchors    holds this artifact's external id; update it if the id changes

starchart cost uses the same split to tell you what switching to codegen would save. It only counts code items reached directly from a fact. PRICE_PRO_MONTHLY anchors the Stripe price, not a fact, and codegen can't produce a Stripe price id, so it's left out:

████████████████████   9.4h  addon:pro.price.usd (21 impacted)
      → 1 hardcoded code anchor: generate constants with `starchart codegen` to make these auto

Workflow

  1. Add codegen: targets, run starchart codegen, commit the output.
  2. Replace hardcoded values with the generated constants (StarchartFacts.AddonPro.Price.usd, ADDON_PRO_PRICE_USD). Delete their old @starchart anchors comments; the generated file carries them now.
  3. When a fact changes, run starchart apply. It regenerates the codegen targets for you (below) and updates the lock. starchart codegen on its own still works when you just want the files.

Codegen inside apply

When a plan contains generated constants (auto via anchors), apply runs one codegen pass over every configured target. Each generated symbol in the plan reports that pass under the codegen adapter. Real output after adding "Widgets" to the Swift proFeatures array in the demo:

✓ symbol:ios/StarchartFacts.AddonPro.features [codegen] regenerated apps/web/lib/starchart-facts.ts; regenerated apps/ios/Sources/Core/StarchartFacts.swift
✓ symbol:web/lib/starchart-facts#ADDON_PRO_FEATURES [codegen] regenerated apps/web/lib/starchart-facts.ts; regenerated apps/ios/Sources/Core/StarchartFacts.swift
  • apply --dry-run prints would regenerate apps/web/lib/starchart-facts.ts, apps/ios/Sources/Core/StarchartFacts.swift and writes nothing.
  • If the files already match: generated constants already current.
  • Codegen output is not journaled. starchart revert doesn't undo it; git does. (The run above wrote no journal at all: starchart journals printed no journals.)
  • Generated constants in the plan but no codegen: targets in config: those items fail with generated constants found but no codegen targets are configured.
  • The Future Universe Preview labels these items "regenerated by starchart codegen on apply".

In CI, fail when the generated files are out of date:

- run: npx @space-pirate-zero/starchart codegen
- run: git diff --exit-code -- apps/web/lib/starchart-facts.ts apps/ios/Sources/Core/StarchartFacts.swift

As a pre-commit hook (any hook runner):

npx @space-pirate-zero/starchart codegen && git add apps/web/lib/starchart-facts.ts apps/ios/Sources/Core/StarchartFacts.swift

From code:

import { buildProject, generateCode, writeCodegen } from "@space-pirate-zero/starchart";

const project = await buildProject();
const { files, changed } = writeCodegen(project);                  // every config target
const kotlin = generateCode(project.graph, { lang: "kotlin", out: "Facts.kt", package: "com.nebula.facts" });

Limitations

  • Mixed arrays become JSON strings. [1, "a"] or an array of objects is emitted as a JSON-encoded String constant, not a typed structure.
  • Arrays are only typed when every element is the same scalar kind; a list mixing integers and decimals becomes Double.
  • Arrays of objects are not expanded into nested types.
  • Code-authority facts are only emitted when the code layer resolved them (they need ingestion to have a value).
  • TS, Swift and Kotlin only. No Go, Dart or Python output.
  • apply regenerates every codegen target, not just the entities in the plan, and doesn't journal the result.

See also

Clone this wiki locally