Build System

For contributors setting up their development environment or troubleshooting builds.

Executive Summary#

  • Self-hosted build./build.pas is a FreePascal script via instantfpc; no Make/CMake/npm required
  • Two modes--dev (default: debug info, checks) and --prod (O4, stripped, smart-linked)
  • CI/CDci.yml for main/tags (full platform matrix), pr.yml for PRs (ubuntu-latest x64 only, with benchmark comparison)
  • Auto-formatter./format.pas with Lefthook pre-commit hook enforces code style automatically
  • Explicit clean recovery./build.pas --clean <target> clears stale FPC artifacts before diagnosing misleading compiler/resource failures

GocciaScript uses a self-hosted build script written in FreePascal, executed via instantfpc. No external build tools (Make, CMake, npm) are required beyond the FreePascal compiler itself.

This file is the authoritative command reference for local builds and CLI usage. Root-level files such as README.md, CONTRIBUTING.md, and AGENTS.md link here instead of duplicating the complete command list.

Prerequisites#

See Prerequisites in the README for FPC installation instructions.

  • instantfpc — Comes bundled with FreePascal. Used to run build.pas as a script.

Build Commands#

Build Modes#

The build script supports two modes via --dev (default) and --prod flags:

./build.pas loader              # Dev build (default)
./build.pas loaderbare          # Dev build of Bare Script Loader
./build.pas sandboxrunner       # Dev build of Sandbox Runner
./build.pas --dev loader        # Explicit dev build
./build.pas --prod loader       # Production build
./build.pas --prod              # Production build of all components
./build.pas --clean --prod      # Clean, then production build of all components
./build.pas --prod loader repl  # Production build of specific components

Build Everything#

./build.pas           # Dev build of all components
./build.pas --prod    # Production build of all components

Builds all components in order: tests, loader, loaderbare, sandboxrunner, testrunner, benchmarkrunner, bundler, repl. The default full build does not clean first; pass --clean explicitly when you need to remove stale build artifacts.

Build Specific Components#

./build.pas repl             # Interactive REPL
./build.pas loader           # Script Loader
./build.pas loaderbare       # Bare Script Loader (core engine only)
./build.pas sandboxrunner    # Sandbox Runner with virtual filesystem
./build.pas testrunner       # JavaScript test runner + native FFI fixture
./build.pas benchmarkrunner  # Performance benchmark runner
./build.pas bundler          # Bundler (compile to .gbc)
./build.pas tests            # Pascal unit tests

Multiple components can be specified:

./build.pas loader repl

Clean Builds#

./build.pas --clean              # Clean, then build all components
./build.pas --clean loader       # Clean then build loader

Cleaning is explicit. ./build.pas and ./build.pas --prod build all components without cleaning; use ./build.pas --clean for a clean full build or ./build.pas --clean <target...> to clean before selected targets.

Use the explicit clean form after a branch switch, merge, PR sync, generated resource update, or unexplained FPC/resource compiler error. FPC 3.2.2 can report stale compiled state as misleading messages such as Compilation raised exception internally or Error while compiling resources; retry with --clean first, then diagnose the reported source line only if the same target still fails.

Compile and Run#

./build.pas loader && ./build/GocciaScriptLoader ./example.js
printf "const x = 2 + 2; x;" | ./build/GocciaScriptLoader
printf 'import { bench, group } from "goccia:microbench"; group("stdin", () => { bench("sum", () => 1 + 1); });\n' | ./build/GocciaBenchmarkRunner --source-type=module

Both loaders are silent about the script's last evaluated value unless you pass --print. For local dev that's fine; for CI scripts and shell pipelines that previously parsed Result: <value> from the loader's stdout, pass --print and parse the bare value on the line after the timing banner — or switch to --output=json and read the result field, which is always populated regardless of --print.

Leading Unix shebang lines such as #!/usr/bin/env goccia are treated as comments by the lexer, so executable scripts can be run directly without preprocessing.

For a smaller runtime surface without the loader runtime profile, build and run the Bare Script Loader:

./build.pas loaderbare && ./build/GocciaScriptLoaderBare example.js
printf "Goccia.version;" | ./build/GocciaScriptLoaderBare --print

Coverage and profiling output are available from the loader:

./build/GocciaScriptLoader example.js --coverage --coverage-format=lcov --coverage-output=coverage.lcov
./build/GocciaScriptLoader example.js --profile=all --profile-output=profile.json

Compile and Test#

./build.pas testrunner
./build/GocciaTestRunner tests
./build/GocciaTestRunner tests --mode=bytecode

./build.pas testrunner also builds fixtures/ffi/libfixture.* for the current platform, which the folder-configured FFI JavaScript tests need.

Useful test-runner forms:

./build/GocciaTestRunner tests --jobs=4
./build/GocciaTestRunner tests --no-progress --exit-on-first-failure --silent
./build/GocciaTestRunner tests --output=results.json --log=test-console.log
./build/GocciaTestRunner tests --coverage --coverage-format=lcov --coverage-output=coverage.lcov

Bytecode Mode#

All execution tools support --mode=bytecode to compile and run via the Goccia bytecode VM instead of the tree-walk interpreter:

# Execute via bytecode VM
./build/GocciaScriptLoader example.js --mode=bytecode
printf "const x = 2 + 2; x;" | ./build/GocciaScriptLoader --mode=bytecode --print

# Load and execute a pre-compiled .gbc file
./build/GocciaScriptLoader output.gbc

# Emit structured JSON for programmatic consumers
printf "console.log('hi'); 2 + 2;" | ./build/GocciaScriptLoader --output=json
# Emit a smaller JSON envelope without build, memory, stdout, or stderr.
# Console output remains in the normalized `output` array; errors stay in `error`.
printf "console.log('hi'); 2 + 2;" | ./build/GocciaScriptLoader --output=compact-json

# `compact-json` is recognised by every runner that emits JSON.
# - GocciaTestRunner: pass it as the value of --output. `--output=json` and
#   `--output=compact-json` emit JSON to stdout (suppressing the human-readable
#   summary); any other --output value is treated as a file path that receives
#   the full JSON envelope.
./build/GocciaTestRunner tests --output=compact-json
# - GocciaBenchmarkRunner: pass it as the value of --format alongside the
#   existing console/text/csv/json options. `--output=<path>` still selects the
#   destination file when provided.
./build/GocciaBenchmarkRunner benchmarks --format=compact-json --output=out.json

# Inject globals from the CLI
printf "x + y;" | ./build/GocciaScriptLoader --global x=10 --global y=20 --print
printf "name;" | ./build/GocciaScriptLoader --globals=context.json --output=json
printf "name;" | ./build/GocciaScriptLoader --globals=context.json5 --output=json
printf "name;" | ./build/GocciaScriptLoader --globals=context.toml --output=json
# `--global name=value` parses inline values as JSON only; `--globals=file` accepts JSON, JSON5, TOML, or YAML by file extension.
# Injected globals can override earlier injected values, but not built-in globals like console

# Load an explicit import map
./build/GocciaScriptLoader app.js --import-map=imports.json

# Add one-off import-map-style aliases from the CLI
./build/GocciaScriptLoader app.js --alias @/=./src/ --alias config=./config/default.js

# The same module-resolution flags are available on GocciaTestRunner, GocciaBenchmarkRunner, and GocciaREPL.
./build/GocciaTestRunner tests --import-map=imports.json --alias @/=./tests/helpers/
./build/GocciaBenchmarkRunner benchmarks --import-map=imports.json
./build/GocciaREPL --import-map=imports.json

# REPL supports the same engine options as the script loader:
./build/GocciaREPL --log=repl.log           # Console log capture
./build/GocciaREPL --stack-size=5000         # Custom call stack depth limit
./build/GocciaREPL --max-memory=10485760     # 10 MB GC heap limit

# When --import-map is omitted, the CLI walks up from the entry file's directory
# and uses the first goccia.json (or .json5 / .toml) it finds.
printf 'import { add } from "@/math"; add(1, 2);' | ./build/GocciaScriptLoader

# Abort long-running scripts
printf "const f = () => f(); f();" | ./build/GocciaScriptLoader --timeout=100

# Abort after a fixed number of bytecode instructions
printf "const f = () => f(); f();" | ./build/GocciaScriptLoader --max-instructions=1000000 --mode=bytecode

# Set call stack depth limit (default 2900; 0 = unlimited)
./build/GocciaScriptLoader example.js --stack-size=5000
./build/GocciaScriptLoader example.js --stack-size=0

# Write .map source map alongside execution
./build/GocciaScriptLoader example.jsx --source-map --mode=bytecode

# Run tests via bytecode VM
./build/GocciaTestRunner tests --mode=bytecode

# Control parallel worker threads (default: CPU count; --jobs=1 forces sequential)
./build/GocciaScriptLoader example.js --jobs=4
./build/GocciaTestRunner tests --jobs=4
./build/GocciaBenchmarkRunner benchmarks --jobs=1

# Split a single input file (or stdin) on `---` separator lines and dispatch
# each section as an independent file across the worker pool
./build/GocciaScriptLoader scenarios.js --multifile
./build/GocciaTestRunner suites.js --multifile --jobs=4
./build/GocciaBundler scenarios.js --multifile --output=dist/
printf '1;\n---\n2;\n---\n3;\n' | ./build/GocciaScriptLoader --multifile

# Run benchmarks via bytecode VM
./build/GocciaBenchmarkRunner benchmarks --mode=bytecode
printf 'import { bench, group } from "goccia:microbench"; group("stdin", () => { bench("sum", () => 1 + 1); });\n' | ./build/GocciaBenchmarkRunner - --source-type=module --mode=bytecode

--multifile (split a single input on --- separators)#

When --multifile is set, each input — whether a file or stdin — is scanned for lines whose trimmed content equals exactly ---. If any such separator is present, the input is split at those lines and each section is dispatched through the regular worker pool as if it were its own input file. Sections execute in fully isolated TGocciaEngine instances, so per-section state (intrinsic prototype mutations, top-level bindings) cannot leak across sections.

Section naming. The [partN] suffix (1-indexed) is appended to the input name:

  • File foo.js with three sections produces foo[part1].js, foo[part2].js, foo[part3].js. The suffix is inserted before the extension so ExtractFileExt, ChangeFileExt, JSX/.tsx dispatch, and the bundler's .gbc output naming continue to work without any per-site changes.
  • Stdin with three sections produces <stdin>[part1], <stdin>[part2], <stdin>[part3]. The suffix is appended (no extension to insert before).
  • Section names appear as the fileName field in JSON output, in error messages, in coverage reports, and in source maps.

Separator semantics. A line is a separator iff its trimmed content equals --- exactly. ----, --- comment, and ---abc are not separators. Empty leading and trailing sections are dropped silently (a file that starts or ends with --- does not produce empty sections), as are sections between consecutive separators. A file with no separator is treated as a single section, so --multifile is safe to enable globally via goccia.json.

Per-runner behaviour.

  • GocciaScriptLoader runs each section in a fresh engine; results appear separately in human-readable output and as separate files[] entries in --output=json.
  • GocciaTestRunner runs each section as a separate test file; the aggregate counts (totalFiles, totalTests, etc.) include every section.
  • GocciaBundler emits one .gbc per section. --output=<file> is rejected with --multifile because the input may expand to multiple outputs; pass a directory instead.
  • GocciaBenchmarkRunner produces one file entry per section in the report.
  • GocciaREPL accepts --multifile for symmetry but ignores it (the REPL takes no file arguments).

Combining with other options. --source-map=<file> is rejected with --multifile for the same reason as the bundler's --output=<file> — a single source-map output cannot represent multiple section sources. All other options compose normally, including --jobs for parallel section dispatch and goccia.json integration via "multifile": true.

Configuration File (goccia.json)#

All CLI options can also be set via a project configuration file. The CLI discovers the file by walking up from the entry file's directory and checking for (in priority order):

1. goccia.toml 2. goccia.json5 3. goccia.json

The first file found is loaded and applied as the root config. When running multiple files (e.g. via GocciaTestRunner or GocciaBundler), a per-file config is also discovered from each file's directory. The full precedence is:

1. CLI options (highest priority — always win) 2. Per-file config (goccia.toml, goccia.json5, or goccia.json nearest to the file being processed) 3. Root config (discovered from the entry file's directory at startup, or supplied via --config) 4. File extension default (.mjs infers module source) 5. System default (engine defaults)

GocciaTestRunner keeps explicit multi-file test invocations isolated: when you pass more than one input path and do not pass --config, the first file's auto-discovered config is not promoted to a root config for the rest of the list. Each file still gets its nearest per-file config. Pass --config=<path> when you intentionally want one shared root config across an explicit test file list.

`--config=<path>` — Override auto-discovery and load the root config from an explicit location. Available on every CLI tool.

The path may be either a file (any registered extension — .json, .json5, .toml; the parser is selected by extension), or a directory, in which case the CLI looks for goccia.tomlgoccia.json5goccia.json in that directory only (same priority as auto-discovery). The directory form does not walk upward — that's the point of the explicit override.

# File form (use this exact file)
./build/GocciaScriptLoader example.js --config=./configs/strict.toml
./build/GocciaTestRunner tests --config=./configs/ci.json

# Directory form (find goccia.{toml,json5,json} inside, no walk-up)
./build/GocciaScriptLoader example.js --config=./configs/

Relative paths are resolved against the current working directory. A missing file or a directory with no recognised goccia.* is a hard error so a typo is not silently ignored. CLI options still take precedence over values from the file, and per-file configs continue to be discovered normally for individual files.

source-type follows that same precedence. Without an explicit CLI or config value, .mjs entry files are parsed and evaluated as module source; other script extensions default to script source.

{
  "mode": "bytecode",
  "source-type": "module",
  "warning-unsupported-features": true,
  "compat-asi": true,
  "compat-non-strict-mode": true,
  "compat-arguments-object": true,
  "compat-loose-equality": true,
  "compat-label": true,
  "compat-traditional-for-loop": true,
  "compat-for-in-loop": true,
  "compat-while-loops": true,
  "experimental-js-module-source": true,
  "strict-types": true,
  "unsafe-ffi": true,
  "unsafe-shadowrealm": true,
  "timeout": 5000,
  "max-memory": 10485760,
  "stack-size": 2900,
  "inspect-depth": 5,
  "allowed-hosts": ["api.example.com", "cdn.example.com"],
  "imports": {
    "@/": "./src/"
  }
}

Config keys mirror CLI option names (e.g. --mode -> "mode", --max-memory -> "max-memory"). A config value is the value assigned to a config key and the setting it carries: boolean flags use true/false, and array-valued options like alias and allowed-hosts use JSON arrays. warning-unsupported-features is a parser diagnostic policy flag, not a compatibility flag: it restores warning/recovery behavior for disabled syntax without enabling that syntax's runtime semantics. The imports object is handled by the module resolver and coexists with CLI option keys.

`extends` — A config file can inherit from a base config using the extends key. The path is resolved relative to the config file's directory. Child values override parent values:

{
  "extends": "../goccia.json",
  "mode": "bytecode"
}

This is useful for test subdirectories that need specific flags. For example, tests/language/asi/goccia.json can enable ASI for all tests in that folder:

{
  "compat-asi": true
}

Likewise, tests/built-ins/FFI/goccia.json enables FFI only for the FFI tests:

{
  "unsafe-ffi": true
}

TOML equivalent (goccia.toml):

compat-asi = true
mode = "bytecode"
unsafe-ffi = true
timeout = 5000
max-memory = 10485760
stack-size = 2900
inspect-depth = 5

CLI vs. embedding — Config file discovery is automatic for all CLI applications (GocciaScriptLoader, GocciaTestRunner, GocciaBenchmarkRunner, GocciaBundler, GocciaREPL) because they inherit from TGocciaCLIApplication. When embedding the engine directly, config file loading is not automatic. Use the shared CLI.ConfigFile unit to get the same behavior.

Note: ApplyConfigFile only handles .json out of the box. To support .json5 and .toml config files, you must register their parsers first — the same way TGocciaCLIApplication.Execute does via EnsureConfigParsersRegistered. See Goccia.CLI.Application.pas for the registration pattern using RegisterConfigParser.

uses CLI.ConfigFile, CLI.Options;

// Register parsers for JSON5 and TOML (required before discovery)
RegisterConfigParser('.json5', @ParseJSON5Config);
RegisterConfigParser('.toml', @ParseTOMLConfig);

// Discover a config file from a starting directory
ConfigPath := DiscoverConfigFile(EntryDir,
  ['goccia'], ['.toml', '.json5', '.json']);

// Parse it (with extends resolution) and apply to your options
if ConfigPath <> '' then
  ApplyConfigFile(ConfigPath, YourOptions);

For import-map resolution only (without CLI option loading), use the module resolver API directly:

uses Goccia.Modules.Resolver;

ConfigPath := TGocciaModuleResolver.DiscoverProjectConfig(EntryDir);
if ConfigPath <> '' then
  Resolver.LoadImportMap(ConfigPath);

GocciaBundler (Bundler)#

GocciaBundler is a dedicated tool for compiling source files to .gbc bytecode without executing them. It accepts the same input modes as GocciaScriptLoader (file, multiple files, directory, stdin):

# Compile a single file (output: example.gbc alongside the source)
./build/GocciaBundler example.js

# Custom output path
./build/GocciaBundler example.js --output=dist/example.gbc

# Compile all source files in a directory (1:1 .gbc output alongside each source)
./build/GocciaBundler src/

# Compile a directory to a specific output directory
./build/GocciaBundler src/ --output=dist/

# Compile from stdin (--output required)
printf "const x = 2 + 2; x;" | ./build/GocciaBundler --output=out.gbc

# Multiple positional input files
./build/GocciaBundler a.js b.js c.js

# Write source map alongside .gbc
./build/GocciaBundler example.jsx --source-map

# Explicit source map path (single file only)
./build/GocciaBundler example.jsx --source-map=out.map

# Enable automatic semicolon insertion during parsing
./build/GocciaBundler example.js --compat-asi

# Parallel compilation (default: CPU count)
./build/GocciaBundler src/ --jobs=4

See bytecode-vm.md for the bytecode VM architecture and binary format.

GocciaSandboxRunner (Virtual Filesystem Sandbox)#

GocciaSandboxRunner executes a sandbox entry path after populating an isolated virtual filesystem. Seed paths are import baselines: the runner copies data into the sandbox before execution, captures that filesystem as the baseline, and never treats the host path as a live mount.

./build.pas sandboxrunner

# Import a host directory into the sandbox root and run /main.js.
./build/GocciaSandboxRunner /main.js --seed=./sandbox-root=/

# Seed from JSON, execute as a module, and write a diff to the host.
./build/GocciaSandboxRunner /main.js --seed-config=seed.json --source-type=module --diff-output=changes.json

# Use the same sandbox runtime modules through the bytecode executor.
./build/GocciaSandboxRunner /main.js --seed-config=seed.json --mode=bytecode

--seed=<host>[=<sandbox>] resolves the host path relative to the invocation current working directory. When the sandbox target is omitted, directories import their contents to / and files import as /<filename>.

Host seed imports reject symlinks. A symlink in a --seed path or anywhere inside a --seed-config from directory aborts the run with exit code 1 and Seed path is a symlink (not supported): <path>, so seeding never dereferences host data from outside the seed root into the sandbox.

--seed-config=<file.json> resolves each from path relative to the seed config file. The JSON shape is:

{
  "files": [
    { "from": "./project", "to": "/" },
    { "path": "/main.js", "text": "import fs from \"fs\";\nconsole.log(fs.readdirSync('/'));" },
    { "path": "/data.bin", "base64": "AQID" }
  ]
}

The sandbox runtime installs import-only modules, not globals:

import fs from "fs";
import { $, runScript } from "goccia";

fs.writeFileSync("/hello.txt", "hello");
console.log(await $`cat /hello.txt`.text());
runScript("/child.js");

Nested scripts share the current sandbox filesystem unless child isolation is requested:

const child = runScript("/child.js", {
  sandbox: true,
  seed: ["/child.js", { from: "/fixtures", to: "/fixtures" }],
  diff: true
});

const shellChild = await $`goccia --sandbox --seed /child.js --diff /child.js`.text();

Child seed entries are copied from the parent virtual filesystem, not the host filesystem. Child writes are discarded with the child VFS; request diff: true or shell --diff to inspect them.

Diff output is explicit. --diff prints the diff after execution, --diff-output=<host-path> writes it to a host file, and --diff-format=json|unified selects the format. JSON is the default.

Build Output#

All compiled binaries go to the build/ directory:

BinarySourceDescription
build/GocciaREPLsource/app/GocciaREPL.dprInteractive read-eval-print loop
build/GocciaScriptLoadersource/app/GocciaScriptLoader.dprExecute .js files or stdin input, with optional JSON output
build/GocciaScriptLoaderBaresource/app/GocciaScriptLoaderBare.dprExecute file or stdin source with the core engine and CLI-local print; no loader runtime profile. --test262-host exposes private conformance hooks for the test262 runner
build/GocciaSandboxRunnersource/app/GocciaSandboxRunner.dprExecute sandbox entry paths inside a seeded virtual filesystem
build/GocciaTestRunnersource/app/GocciaTestRunner.dprJavaScript test runner
build/GocciaBenchmarkRunnersource/app/GocciaBenchmarkRunner.dprPerformance benchmark runner for files or stdin input
build/GocciaBundlersource/app/GocciaBundler.dprBundler (source to .gbc)
build/Goccia.Values.Primitives.Test*.Test.pasPascal unit test binaries

Intermediate files (.o, .or, .ppu, generated resource lists) go to build/compiled/ to keep the source tree clean. Each executable target compiles into an isolated build/compiled/targets/<target>/ unit cache, and Pascal unit test programs compile into isolated build/compiled/tests/<test-program>/ subdirectories so one program's FPC unit cache cannot poison the next.

Compiler Configuration#

config.cfg (Shared Flags)#

-Fu./source/units    # Engine unit search path
-Fu./source/generated # Generated runtime data unit search path
-Fu./source/shared   # Shared infrastructure unit search path
-Fu./source/app      # CLI application unit search path
-Fi./source/units    # Engine include file search path
-Fi./source/shared   # Shared include file search path
-FUbuild/compiled    # Default unit output directory
-FEbuild             # Executable output directory

These path flags are shared by both build modes. Mode-specific flags are added by build.pas. Each build target overrides -FU to isolate its own Pascal unit cache.

Build Mode Flags#

FlagDevelopment (--dev)Production (--prod)
Optimization-O- (disabled)-O4 (aggressive)
Debug info-gw -godwarfsets (DWARF)— (none)
Line info-gl (debug line numbers)
Stack checking-Ct
Range checking-Cr
Assertions-Sa
Strip symbols-Xs
Smart linking-CX -XX
Define-dPRODUCTION

Shared.inc and Goccia.inc#

Common compiler settings live in source/shared/Shared.inc, which is included by source/units/Goccia.inc. Engine units include Goccia.inc (which pulls in Shared.inc and adds Goccia-specific defines); shared infrastructure units include Shared.inc directly.

Shared.inc provides:

{$mode delphi}                    // Delphi-compatible syntax
{$H+}                              // Long strings (`string` = `AnsiString`) by default
{$IFNDEF PRODUCTION}
  {$overflowchecks on}            // Runtime arithmetic overflow detection
  {$rangechecks on}               // Runtime array bounds checking
{$ENDIF}
{$modeswitch advancedrecords}     // Records with methods

Under the current project settings, FreePascal behaves like this:

  • string is an AnsiString alias, not UnicodeString.
  • UTF8String is also an AnsiString, but tagged with code page CP_UTF8 (65001).
  • UnicodeString remains the explicit UTF-16 string type.
  • Char is a single-byte AnsiChar, so Length, Copy, indexing, and similar operations on string/UTF8String count bytes, not Unicode code points.

That distinction matters for parser and file-loading code: a plain string temporary does not preserve “this text is UTF-8” on its own. If raw UTF-8 file text needs to survive byte-for-byte until parsing, keep it in UTF8String (or retag it explicitly) until the parser consumes it.

Overflow and range checks are enabled in development mode for safety. In production builds, the -dPRODUCTION define disables these checks for maximum performance.

How build.pas Works#

The build script is a FreePascal program that runs via the instantfpc shebang:

#!/usr/bin/env instantfpc

It:

1. Creates the build/ directory if it doesn't exist. 2. Parses --dev/--prod flags to determine the build mode (defaults to --dev). 3. Parses remaining arguments to determine which components to build. 4. Counts and prints source statistics: total lines of code (LOC), significant lines of code (SLOC, excluding blanks and comments), and file count. 5. Calls fpc with @config.cfg, a target-specific -FUbuild/compiled/... override, mode-specific flags, and suppressed verbose output flags (-vw-n-h-i-l-d-u-t-p-c-x-). 6. If the FPC_TARGET_CPU environment variable is set, prepends -P<arch> to the compiler arguments (used by CI to target x86_64 on Windows where the FPC package defaults to i386). 7. For the tests target, auto-discovers all *.Test.pas files in source/units/ and source/shared/, compiling each program with its own build/compiled/tests/<test-program>/ unit-output directory.

The GitHub Actions cross-compilation workflow uses a reduced cached FPC toolchain rather than a full target-side FCL install. It prebuilds the RTL, rtl-objpas, rtl-generics, and fcl-process, and also caches the official fcl-base sources so cross builds can resolve units such as Base64 on demand from the shipped FPC packages.

Project Structure for Compilation#

GocciaScript/
├── build.pas          # Build script (entry point)
├── config.cfg         # Shared FPC configuration
├── source/
│   ├── app/              # CLI applications
│   │   ├── GocciaREPL.dpr              # REPL program source
│   │   ├── GocciaScriptLoader.dpr      # Script loader program source
│   │   ├── GocciaScriptLoaderBare.dpr  # Core-engine-only script loader
│   │   ├── GocciaTestRunner.dpr        # Test runner program source
│   │   ├── GocciaBenchmarkRunner.dpr   # Benchmark runner program source
│   │   ├── GocciaBundler.dpr     # Bundler
│   │   ├── Goccia.CLI.Application.pas  # CLI application base class
│   │   ├── Goccia.CLI.Help.pas         # CLI help generation
│   ├── shared/           # Shared infrastructure
│   │   ├── Shared.inc    # Common compiler settings
│   │   ├── TestingPascalLibrary.pas  # Pascal test framework (TTestSuite)
│   │   ├── TimingUtils.pas   # Cross-platform timing and duration formatting
│   │   ├── HashMap.pas       # Purpose-built hash maps
│   │   ├── OrderedMap.pas    # Ordered hash maps
│   │   ├── OrderedStringMap.pas  # String-keyed ordered maps
│   │   ├── StringBuffer.pas  # String buffer
│   │   ├── FileUtils.pas     # File utilities
│   │   ├── BaseMap.pas       # Base map type
│   │   └── JSONParser.pas    # JSON parser
│   ├── generated/        # Generated runtime data
│   │   ├── Generated.TimeZoneData.pas # Timezone resource metadata/link unit
│   │   └── Generated.TimeZoneData.res # Embedded IANA TZif resource
│   └── units/            # Goccia engine units
│       ├── Goccia.inc    # Goccia compiler directives (includes Shared.inc)
│       ├── Goccia.Bytecode*.pas # Bytecode definitions, templates, modules, binary I/O
│       ├── Goccia.VM*.pas       # Bytecode VM, frames, closures, upvalues, exceptions
│       ├── *.pas         # All engine unit source files
│       └── *.Test.pas    # Pascal unit test programs
└── build/             # All output (gitignored)
    ├── compiled/      # Intermediate files
    │   ├── *.o        # Object files
    │   ├── *.or       # Resource object files
    │   ├── *.ppu      # Compiled unit files
    │   ├── targets/   # Isolated executable target unit caches
    │   └── tests/     # Isolated Pascal test unit caches
    └── (binaries)     # Executable output

Generated Timezone Data#

source/generated/Generated.TimeZoneData.pas and source/generated/Generated.TimeZoneData.res are produced by scripts/generate-timezone-data.js. By default, the generator downloads the latest IANA tzdata-latest.tar.gz, compiles it with zic, packs the resulting TZif files into a single resource payload, and emits a small Pascal unit that links the resource. Pass a local zoneinfo directory, a local tzdata tarball, or an explicit URL to generate from a different source.

The generator requires zic, tar, and fpcres. fpcres writes the FreePascal resource consumed by {$R Generated.TimeZoneData.res}.

Temporal embeds this generated timezone resource by default through source/units/Goccia.inc. Define GOCCIA_TEMPORAL_NO_EMBEDDED_TZDATA to build without the resource fallback.

Generated Intl Data#

source/generated/Generated.IntlData.pas and source/generated/Generated.IntlData.res are produced by scripts/generate-intl-data.js. By default, the generator downloads CLDR JSON data, packs it into a single resource payload, and emits a small Pascal unit that links the resource.

The generator requires fpcres. fpcres writes the FreePascal resource consumed by {$R Generated.IntlData.res}.

Intl embeds this generated CLDR resource by default through source/units/Goccia.inc. Define GOCCIA_INTL_NO_EMBEDDED_CLDR to build without the resource fallback.

Generated Unicode Data#

source/generated/Generated.UnicodeData.pas and source/generated/Generated.UnicodeData.res are produced by scripts/generate-unicode-data.js. By default, the generator downloads Unicode Character Database (UCD) files for a given Unicode version, packs General_Category, Script, Script_Extensions, and binary property range tables into a single resource payload, and emits a small Pascal unit that links the resource.

The generator requires fpcres. fpcres writes the FreePascal resource consumed by {$R Generated.UnicodeData.res}.

RegExp embeds this generated UCD resource by default through source/units/Goccia.inc. Define GOCCIA_REGEXP_NO_EMBEDDED_UCD to build without the resource fallback. When system ICU is available, the engine queries ICU directly for Unicode property data; the embedded resource serves as a fallback for systems without ICU.

CI/CD#

GitHub Actions CI is split into two workflow files:

ci.yml — Push to main + tags#

build -> test / toml-compliance / json5-compliance / test262 / awfy / web-tooling / benchmark / cli -> artifacts/release

All matrix strategies use fail-fast: false, so one platform failing does not cancel other platforms. The post-build jobs (test, toml-compliance, json5-compliance, test262, awfy, web-tooling, benchmark, cli) are independent.

Runs on the full platform matrix:

OSArchitecture
Ubuntu Latestx64, ARM64
macOS Latestx64, ARM64
Windows Latestx86 (i386-win32)

`build` — Installs FPC, compiles the source/app/*.dpr programs with --prod, stages those binaries plus Pascal test executables, and uploads that staged set as intermediate artifacts.

`test` (needs build) — Runs all JavaScript tests and Pascal unit tests on all platforms.

`toml-compliance` — Downloads the prebuilt GocciaTOMLCheck harness from each matrix build artifact, runs the official toml-test TOML 1.1.0 suite (pinned to a specific SHA) on every CI platform via python3 scripts/run_toml_test_suite.py --harness=..., validates that the JSON summary reports zero failures, and uploads the per-platform JSON report. The pin is bumped weekly by .github/workflows/toml-test-bump.yml.

`json5-compliance` — Downloads the prebuilt GocciaJSON5Check harness and GocciaTestRunner binary from each matrix build artifact, runs python3 scripts/run_json5_test_suite.py --harness=... --test-runner=... on every CI platform, validates both the parser and stringify summaries, and uploads the per-platform JSON report.

`test262` (needs build, ubuntu-latest x64 only, non-blocking) — Downloads the gocciascript-x86_64-linux build, checks out `tc39/test262` at the SHA pinned in scripts/test262-suite-sha.txt, runs bun scripts/run_test262_suite.ts --suite-dir test262-suite --mode=bytecode --jobs=4 --timeout-ms=20000 --output=test262-results.json, and uploads the report as a 30-day workflow artifact. The run step uses continue-on-error: true because the conformance lane is allowed to carry known steady-state failures while the engine closes compatibility gaps. On main, the JSON is also stashed via actions/cache/save under test262-baseline-<sha> so the PR workflow can compute Δ vs main, and cd website && bun run publish-test262 ../test262-results.json publishes the compressed run report plus its UTC daily dashboard pointer to Vercel Blob when BLOB_READ_WRITE_TOKEN is configured. Main runs also upload the test262-profile artifact and publish matching aggregate/detail profile payloads under the separate test262-profiles/ Blob namespace for weekly performance review. The one-off cd website && bun run backfill-test262 command seeds retained artifact reports and reruns expired historical days directly into Blob. The pin is bumped weekly by .github/workflows/test262-bump.yml. See docs/test262.md for the harness and profile report contracts.

`awfy` (needs build, ubuntu-latest x64 only) — Downloads the gocciascript-x86_64-linux build, installs QuickJS, installs the latest Node Current release via actions/setup-node for the Node reference engine, checks out smarr/are-we-fast-yet at the SHA pinned in perf/awfy/manifest.json, and runs node scripts/awfy-ci-report.js --awfy-dir awfy-suite/benchmarks/JavaScript --output awfy-report.json. The report set is ciReport from the manifest: all pinned AWFY JavaScript benchmarks under Goccia bytecode, QuickJS, and Node Current, with five interleaved samples per engine. The job uploads awfy-report as a 30-day artifact. On main, cd website && bun run publish-awfy ../awfy-report.json publishes the compressed report plus its UTC daily pointer to the separate awfy/ Vercel Blob namespace when BLOB_READ_WRITE_TOKEN is configured.

`web-tooling-workload` / `web-tooling` (needs build, ubuntu-latest x64 only) — The workload matrix downloads the gocciascript-x86_64-linux build, installs the latest Node Current release as the upstream build runtime, checks out v8/web-tooling-benchmark at the SHA pinned in perf/web-tooling/manifest.json, and installs upstream dependencies without running postinstall. Each matrix job runs one pinned workload under Goccia bytecode and uploads its report shard. Each workload bundle contains one upstream benchmark module and only its declared third_party payloads; the Goccia harness times one direct fn() invocation per process sample. The web-tooling merge job requires one structurally complete shard for every manifest workload and uploads the normalized all-workload web-tooling-report as a 30-day artifact. On main, cd website && bun run publish-web-tooling ../web-tooling-report.json publishes the compressed report plus its UTC daily pointer to the separate web-tooling/ Vercel Blob namespace when BLOB_READ_WRITE_TOKEN is configured.

`benchmark` (needs build) — Runs all benchmarks on all platforms. On main (ubuntu-latest x64), it additionally emits JSON and validates the report shape. PR comparison no longer reads a cached baseline from here — each PR builds and benchmarks main on its own runner (ADR 0076). The main bytecode benchmark lane also captures deterministic VM profile details, uploads the benchmark-profile artifact, and publishes aggregate/detail profile payloads under the separate benchmark-profiles/ Blob namespace when BLOB_READ_WRITE_TOKEN is configured.

`cli` (needs build) — Downloads pre-built binaries and runs CLI behavior smoke tests on all platforms via Bun: options across all apps, lexer numeric-separator rejection, parser error display, config-file loading, and app-specific features including Sandbox Runner seed baselines. Windows runs additionally assert that the loader binary does not link OpenSSL DLLs (HTTPS must use the platform TLS stack statically).

`artifacts` (needs test + toml-compliance + json5-compliance + awfy + web-tooling + benchmark + cli, main only) — Uploads production binaries after all checks pass, deriving the executable names from the source/app/*.dpr entrypoints.

`release` (needs test + toml-compliance + json5-compliance + awfy + web-tooling + benchmark + cli, tags only) — Downloads all platform build artifacts, stages only the shipped binaries derived from the source/app/*.dpr entrypoints, bundles them with tests/, benchmarks/, and examples/ into per-platform archives (.tar.gz for Linux/macOS, .zip for Windows), generates categorized release notes via git-cliff (cliff.toml), and creates a GitHub release using softprops/action-gh-release.

pr.yml — Pull requests#

build -> test / benchmark -> comment / test262 -> comment / awfy -> comment / web-tooling -> comment / cli

Runs on ubuntu-latest x64 only (single runner, no matrix).

`build` — Installs FPC, compiles all binaries with --prod, stages the source/app/*.dpr binaries plus Pascal test executables, and uploads that staged set as build-pr.

`test` (needs build) — Runs all JavaScript tests and Pascal unit tests.

`benchmark` (needs build + build-main) — Compares the PR against a main build measured on the same runner. A separate build-main job builds the benchmark runner from the PR's base commit (github.event.pull_request.base.sha); the benchmark job then warms up the runner (discarded) and benchmarks the main build and the PR build back-to-back, so deltas reflect the diff rather than cross-runner variance (issue #815, ADR 0076). It posts a collapsible comparison comment grouped by file: each file section shows the same-runner main and PR opsPerSec point estimates side by side, each carrying its min/max ops/sec range in brackets. Classification (in `scripts/benchmark-compare.js`, unit-tested by scripts/test-benchmark-compare.ts) uses range overlap: fully above the main range is an improvement, fully below is a regression, and overlapping ranges are treated as unchanged noise. The comment also reports the measured median per-run variance as a noise floor. Percentage deltas are secondary context, files with significant changes are auto-expanded, and the comparison is advisory (comment-only, never merge-blocking). The deterministic profile diff (opcode/allocation counts) is regenerated from the same main build. If no main build is available, shows results without comparison.

`test262` (needs build, non-blocking) — Checks out tc39/test262 at the pinned SHA, runs bun scripts/run_test262_suite.ts --suite-dir test262-suite --mode=bytecode --jobs=4 --timeout-ms=20000 --output=test262-results.json, and uploads the JSON report. Failing tests do not fail the job. The downstream test262-comment job (if: always()) restores the most recent test262-baseline- cache entry from main, then bun scripts/run_test262_suite.ts --comment test262-results.json <baseline> builds the markdown body and the workflow posts/updates a comment using marker <!-- test262-results -->. The comment shows a per-category breakdown (built-ins, harness, intl402, language, staging) with Δ-vs-main columns when a baseline is cached, an "Areas closest to 100%" sub-table, and a collapsible per-test delta list. PR CI does not generate or upload the full-corpus test262 profile artifact.

`awfy` (needs build) — Runs the same ciReport AWFY set as full CI on the PR x64 build, using Goccia bytecode, QuickJS, and the latest Node Current release with five interleaved samples per engine. It uploads the normalized awfy-report JSON artifact. The downstream awfy-comment job posts or updates an AWFY Results comment with median timings and geomean ratios; min/max/CV and raw samples remain in the artifact.

`web-tooling-workload` / `web-tooling-report` (needs build) — Runs the same direct-invocation workload matrix as full CI on the PR x64 build, using Goccia bytecode only, then validates and merges the shards into the normalized web-tooling-report JSON artifact. The downstream web-tooling-comment job posts or updates a Web Tooling Benchmark comment with per-workload build/execution status and Goccia runs/s where available; full stdout/stderr for failures and min/max/CV remain in the artifact.

`cli` (needs build) — Runs CLI behavior smoke tests via Bun (scripts/test-cli.ts, scripts/test-cli-lexer.ts, scripts/test-cli-parser.ts, scripts/test-cli-config.ts, scripts/test-cli-apps.ts). test-cli-apps.ts includes GocciaScriptLoaderBare coverage for stdin, -, input files, CLI-local print, module source type, absence of the loader runtime profile, and --mode=interpreted|bytecode (both values plus invalid-value rejection), plus GocciaSandboxRunner coverage for seed config imports, inline text/base64 files, virtual fs, $, shared and child-sandbox runScript / shell goccia, bytecode mode, and diff output.

FPC is only installed once per platform in the build job. In ci.yml, the test, AWFY, Web Tooling, benchmark, cli, TOML, JSON5, and test262 conformance jobs reuse the pre-built binaries and artifacts from that job; in pr.yml, the test, AWFY, Web Tooling, benchmark, test262, and cli jobs do the same.

Changelog#

The project maintains a CHANGELOG.md generated from git history using git-cliff. The configuration lives in cliff.toml at the project root.

How It Works#

Commits are categorized by their leading verb into groups:

Verb prefixCategory
Add, Implement, Support, Allow, Create, Enable🚀 Added
Fix, Fixes, Route, Thread, Harden, Handle🐛 Fixed
Improve, Include, Make✨ Improved
Replace, Optimize, Promote, Eliminate, Bypass, Inline⚡ Performance
Remove🗑️ Removed
Refactor, Extract, Update, Strip🏗️ Internal
Commits touching website/🌐 Website

This table shows common prefixes. See cliff.toml for the complete list of patterns, which includes additional verb-specific and phrase-specific matchers. Website commits are detected by changed path before prefix grouping so site changes stay separate from language/runtime changes. Release sections are generated only for semver-style tags such as 0.7.0 or v0.7.0; moving tags such as nightly are ignored.

Generating the Changelog#

# Install git-cliff
brew install git-cliff           # macOS
cargo install git-cliff          # Any platform with Rust

# Generate full changelog
git-cliff -o CHANGELOG.md

# Preview unreleased changes
git-cliff --unreleased

# Preview the latest release notes
git-cliff --latest --strip header

CI Integration#

Releases are cut with the /create-release flow, and publishing is owned by CI. /create-release's job ends at pushing the tag: the changelog and version bump land on a release/<version> branch through a squash-merged release PR before the tag is created, so the tag always contains its own changelog (the release commit is chore(release): <version>, which cliff.toml skips). Do not run /create-release's own publish step (gh release create) for this repo — pushing the tag triggers the ci.yml release job, which is the only place the built platform archives exist. That tag-triggered job only publishes: it renders the latest release section with `orhun/git-cliff-action` (--latest) and creates the GitHub release with the archives via softprops/action-gh-release. It does not regenerate or commit the changelog — that already happened before the tag, which keeps each release self-contained. GitHub's auto-generated release notes (.github/release.yml) provide an additional PR-label-based view.

PR Labels#

PRs should be labeled for the GitHub release notes categorization:

LabelCategory
new feature🚀 New Features
spec compliance📐 Compliance
bug, os specific🐛 Bug Fixes
performance⚡ Performance
documentation📖 Documentation
internal🏗️ Internal

Auto-Formatter#

The project includes ./format.pas, an instantfpc script that auto-fixes Pascal source files. It enforces uses clause ordering, PascalCase function names, parameter A prefix naming, and stray space removal (spurious spaces before ;, ), ,). No build step is needed — it runs directly via the instantfpc shebang.

./format.pas              # Format all project Pascal files
./format.pas --check      # Check only (exit 1 if changes needed)
./format.pas file.pas     # Format specific files

Pre-Commit Hook#

The formatter runs automatically on staged .pas/.dpr files before each commit via Lefthook. To enable this after cloning:

# Install Lefthook (see CONTRIBUTING.md for all platforms)
brew install lefthook     # macOS
sudo snap install lefthook  # Linux
scoop install lefthook    # Windows

# Register git hooks
lefthook install

The hook configuration is in lefthook.yml. It auto-stages formatting fixes (stage_fixed: true) so you don't need to git add after the hook reformats files.

Direct FPC Compilation#

If you need to bypass the build script:

fpc @config.cfg -vw-n-h-i-l-d-u-t-p-c-x- source/app/GocciaREPL.dpr
fpc @config.cfg -vw-n-h-i-l-d-u-t-p-c-x- source/app/GocciaScriptLoader.dpr
fpc @config.cfg -vw-n-h-i-l-d-u-t-p-c-x- source/app/GocciaTestRunner.dpr
fpc @config.cfg -vw-n-h-i-l-d-u-t-p-c-x- source/app/GocciaBenchmarkRunner.dpr
fpc @config.cfg -vw-n-h-i-l-d-u-t-p-c-x- source/app/GocciaBundler.dpr

The -v flags suppress verbose output to keep the build clean. Use fpc @config.cfg source/app/GocciaREPL.dpr for full verbose output during debugging.

Build System Design Decision#

The build script (build.pas) is a FreePascal script executed via instantfpc — a cross-platform, out-of-the-box solution within the FreePascal ecosystem that requires no external build tools.