Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8ad7494
feat: classify elephc-only builtins in the registry
nahime0 Jul 15, 2026
44a2357
feat: add --strict-php flag with PHP-compatibility audit
nahime0 Jul 15, 2026
f97d8b1
feat: hide extension builtins from user programs under --strict-php
nahime0 Jul 15, 2026
773aa5a
test: strict-php error, resolver, and CLI end-to-end suites
nahime0 Jul 15, 2026
a45be8d
docs: document --strict-php and regenerate builtin pages
nahime0 Jul 15, 2026
f634ef9
feat: hide extension builtins from runtime eval under --strict-php
nahime0 Jul 15, 2026
50c6864
feat: route extension syntax in literal eval to the bridge under stri…
nahime0 Jul 15, 2026
e961bfd
test: strict-php eval end-to-end suites
nahime0 Jul 15, 2026
d07542e
docs: document strict-php eval semantics
nahime0 Jul 15, 2026
4409cf9
docs: add --strict-php changelog entry
nahime0 Jul 15, 2026
afb8bc6
docs: encode roadmap/changelog policy in AGENTS
nahime0 Jul 15, 2026
8dadc12
docs: cover strict-php in eval, pipeline, and conditional-compilation…
nahime0 Jul 15, 2026
f024d3e
docs: mark extension builtins on generated pages
nahime0 Jul 15, 2026
9711260
fix: audit PHP attribute arguments under --strict-php
nahime0 Jul 16, 2026
4c80780
docs: repair truncated strict-php changelog bullet
nahime0 Jul 16, 2026
bd47f3a
fix: keep php_visible_builtin_names independent of strict mode
nahime0 Jul 16, 2026
2fb7642
refactor: align strict audit ordering and guard test strict state
nahime0 Jul 16, 2026
1d6b7f4
docs: note the strict audit covers attribute arguments
nahime0 Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,14 @@ Key invariants:
- **Separate surfaces still need hand-wiring** when relevant: the EIR emitter/runtime
routine, optimizer effects (`src/optimize/effects/builtins.rs`), and the
runtime-callable wrapper exclusion (`src/codegen/callable_dispatch.rs`).
- `isset`/`unset`/`empty`/`exit`/`die`/`buffer_*` are language constructs that stay
- `isset`/`unset`/`empty`/`exit`/`die` are language constructs that stay
checker-resident (`numeric`/`arrays` `check_builtin`), not in the registry.
`buffer_new` is catalog-name-only (its call form is dedicated syntax); `buffer_len`
and `buffer_free` are ordinary registry builtins.
- **elephc-only builtins declare `extension: true`** so `--strict-php` hides them from
user programs (pinned in `src/builtins/parity_tests.rs`). Injected preludes must call
`internal: true` `__elephc_*` aliases instead of PHP-visible extension builtins; a
parity gate scans the prelude sources to enforce this.
- Add codegen + error tests (include a case-insensitive or namespaced call for
PHP-visible builtins); keep the parity gates in `src/builtins/parity_tests.rs` green.
- Before opening a PR that adds, removes, or changes PHP-visible builtins, run the
Expand Down Expand Up @@ -523,16 +529,23 @@ sidebar:

## Roadmap management

`ROADMAP.md` tracks all planned and completed work, organized by version.
`ROADMAP.md` is the planning document, organized by version. It stays as it is:

- **Do not add entries to record implemented work.** Implementations are documented in `CHANGELOG.md` under `[Unreleased]` (see below). The roadmap only gains new items when work is being *planned*, under the appropriate future version.
- When an implementation completes an item **already present** in the roadmap, mark it `[x]` in place. If no matching item exists, the roadmap is left untouched.
- **Never remove completed items** from a version section. Mark them as `[x]` and leave them under the version they belong to. This preserves the history of what was delivered in each release.
- New work items go under the appropriate future version.
- When all items in a version are completed, the version is considered done — do not move items elsewhere.

## Changelog management

`CHANGELOG.md` records every released version, newest first, in *Keep a Changelog* style.

**Every implementation lands a bullet under the `## [Unreleased]` section in the same PR** — one terse, user-facing entry describing what shipped, not how it was implemented. If the `[Unreleased]` section does not exist, add it at the top of the file (under the header, above the newest version section) together with its compare link at the top of the link list at the end of the file:

```
[Unreleased]: https://github.com/illegalstudio/elephc/compare/v<latest>...HEAD
```

When cutting a release:

- Add a new section at the top (under the header), above the previous version:
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ All notable changes to elephc, a PHP-to-native compiler written in Rust.
Releases are listed newest first.

## [Unreleased]
- Added the `--strict-php` flag: the compiler accepts only PHP-compatible constructs. Extension syntax (`ifdef`, `packed class`, `extern`, `ptr_cast<T>`, `buffer_new<T>`, typed local declarations, `ptr`/`buffer<T>` annotations) is rejected at compile time with per-violation diagnostics across the main file, includes, and autoloaded files, while extension builtins (`ptr_*`, `zval_*`, `buffer_*`, `class_attribute_*`) behave exactly as under the PHP interpreter — `function_exists()` reports `false`, calling one is an undefined function with a hint naming the disabled extension, and user code may declare its own functions with those names. Strict mode also reaches `eval()` with PHP's execute-time semantics: extension builtins do not exist inside eval'd fragments (runtime fatal on call, coherent `function_exists`/`is_callable`), extension syntax in a fragment is a runtime parse error, and user functions shadowing extension names stay callable. Programs using compiler preludes (PDO, timezone, image, web) keep compiling; `--define` cannot be combined with the flag.
- Added PHP-compatible sessions to `--web` across PHP 8.2–8.5 and every supported target: `$_SESSION`, the complete `session_*()` API and interfaces, file persistence with `flock`, the `php`/`php_binary`/`php_serialize` wire formats, custom save handlers, strict mode, lazy writes, GC, SID and runtime INI configuration, cookies and cache limiters, auto-start, multipart upload progress, and trans-SID rewriting. Session state, locks, and bridge buffers are reset between requests, while invalid names, IDs, serialized input, and every supported callable-handler form follow PHP-compatible validation and failure paths. The web prelude now retains session helpers by reachability, omits the legacy callable-handler adapter when unused, uses a compact auto-start path, and shares callable descriptors and invokers module-wide, keeping ordinary `--web` compilation and assembly size close to the pre-session baseline.
- Fixed checked-arithmetic Mixed boxes leaking when consumed directly by a parent operation (issue #500): the boxed `int|float` result of runtime `+`/`-`/`*` (and unary `-`) is now released after it is coerced for `%`, bitwise/shift operators, `**`, comparisons, and int-coerced or mixed-key array indexes, so shapes like `($i * 7 + 1) & 0xFFFF` or `$SIN[($i * 7 + 5) & 1023]` in tight loops no longer exhaust the heap. Assignment, call-argument, chained mixed-op, and string-coercion releases stay balanced (no double frees), overflow-to-float promotion is unchanged, and heap-debug stays clean with the EIR optimizer enabled or disabled.
- Added PHP-compatible `mb_strlen()` to both native compilation and the Magician `eval()` runtime, including the nullable optional `$encoding` argument, UTF-8 malformed-sequence handling, byte-count aliases, iconv-backed multibyte encodings, callable dispatch, and catchable `ValueError` for unknown encodings on every supported target.
Expand Down
28 changes: 23 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ omitted:

`name`, `area`, `params`, `variadic?`, `min_args?`, `max_args?`, `arity_error?`,
`returns`, `returns_fresh_storage?`, `by_ref_return?`, `check?`, `lazy_check?`, `lower`,
`summary`, `examples?`, `php_manual?`, `deprecation?`, `internal?`.
`summary`, `examples?`, `php_manual?`, `deprecation?`, `extension?`, `internal?`.

- **`params`** — `[name: TypeSpec, name: TypeSpec = DefaultSpec::Variant, ...]`. A
parameter with `= DefaultSpec::…` is optional; without it, required. Prefix a
Expand All @@ -353,6 +353,9 @@ omitted:
tighter/looser than its declared parameter list, or needs a verbatim error message.
- **`summary` / `examples` / `php_manual` / `deprecation`** — documentation metadata
surfaced by the `gen_builtins` exporter.
- **`extension: true`** — an elephc extension with no PHP equivalent (`ptr_*`,
`zval_*`, `buffer_*`, `class_attribute_*`, …). `--strict-php` hides it from user
programs; update `EXPECTED_EXTENSION_BUILTINS` in `src/builtins/parity_tests.rs`.
- **`internal: true`** — a compiler-internal builtin that is not PHP-visible and is
excluded from catalogs and docs.

Expand Down Expand Up @@ -447,10 +450,25 @@ when relevant:

### 8. Not every "builtin" is a function

A small set of PHP language constructs — `isset`, `unset`, `empty`, `exit`, `die`, plus
the `buffer_*` intrinsics — are l-value/lazy constructs with dedicated EIR paths and are
intentionally kept in the checker (`numeric`/`arrays` `check_builtin`), not in the
registry. Do not migrate those into `builtin!`.
A small set of PHP language constructs — `isset`, `unset`, `empty`, `exit`, `die` — are
l-value/lazy constructs with dedicated EIR paths and are intentionally kept in the
checker (`numeric`/`arrays` `check_builtin`), not in the registry. Do not migrate those
into `builtin!`. `buffer_new` is similar (its call form is dedicated syntax lowered as
`ExprKind::BufferNew`; only its name lives in the catalog), while `buffer_len` and
`buffer_free` are ordinary registry builtins under `src/builtins/pointers/`.

Builtins that are elephc extensions with no PHP equivalent must declare
`extension: true` in `builtin!` so `--strict-php` hides them from user programs; the
pinned set lives in `src/builtins/parity_tests.rs` (`EXPECTED_EXTENSION_BUILTINS`).
Injected compiler preludes must never call a PHP-visible extension builtin — use an
`internal: true` `__elephc_*` alias instead (see `src/builtins/pointers/__elephc_ptr_read_string.rs`);
the `preludes_never_call_php_visible_extension_builtins` gate enforces this.

On the eval side, magician derives its extension set from `EvalArea::RawMemory`
plus the `SYMBOLS_EXTENSION_BUILTINS` list (`crates/elephc-magician/src/interpreter/builtins/spec.rs`)
instead of a per-declaration flag; the `extension_builtin_sets_agree_across_registries`
gate in `tests/builtin_parity_tests.rs` pins that derivation against the compiler
registry, so adding an extension builtin to either registry forces both sides to agree.

## Adding functionality via a Rust crate (bridge crates)

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ elephc --gc-stats heavy.php
# Enable compile-time feature branches
elephc --define DEBUG app.php

# Accept only PHP-compatible constructs (reject every elephc extension)
elephc --strict-php app.php

# Print per-phase compiler timings
elephc --timings hello.php

Expand Down
13 changes: 13 additions & 0 deletions crates/elephc-magician/src/ffi/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ pub extern "C" fn __elephc_eval_context_new() -> *mut ElephcEvalContext {
Box::into_raw(Box::new(ElephcEvalContext::new()))
}

/// Marks this program's eval bridge as strict-PHP: extension builtins
/// (`ptr_*`, `buffer_*`, `class_attribute_*`) disappear from eval dispatch and
/// introspection, matching the PHP interpreter where those names do not exist.
///
/// Generated code emits this call while initializing the eval context, only in
/// binaries compiled with `elephc --strict-php`. The flag is thread-local and
/// elephc programs run every eval on the initializing thread, so one call
/// covers the program lifetime.
#[no_mangle]
pub extern "C" fn __elephc_eval_set_strict_php(enabled: u8) {
crate::strict_php_mode::set_strict_php_mode(enabled != 0);
}

/// Frees a process-level eval context handle allocated by the eval bridge.
///
/// # Safety
Expand Down
13 changes: 11 additions & 2 deletions crates/elephc-magician/src/interpreter/builtin_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@

use super::builtins::{
eval_builtin_param_names, eval_builtin_signature_shape, eval_date_procedural_alias_names,
eval_declared_builtin_exists, eval_declared_builtin_spec, eval_php_visible_builtin_exists,
eval_php_visible_builtin_function_names, EvalBuiltinDefaultValue,
eval_declared_builtin_exists, eval_declared_builtin_spec, eval_extension_builtin_names,
eval_php_visible_builtin_exists, eval_php_visible_builtin_function_names,
EvalBuiltinDefaultValue,
};

/// A compact, comparison-friendly view of an eval builtin call signature.
Expand Down Expand Up @@ -48,6 +49,14 @@ pub fn php_visible_builtin_is_registry_declared(name: &str) -> bool {
eval_declared_builtin_exists(&canonical)
}

/// Returns the eval builtins that are elephc extensions (no PHP equivalent),
/// in stable sorted order. Strict-PHP binaries hide exactly this set from eval
/// dispatch and introspection; parity gates compare it against the compiler
/// registry's `extension`-flagged builtins.
pub fn extension_builtin_names() -> &'static [&'static str] {
eval_extension_builtin_names()
}

/// Returns comparison metadata for one eval builtin signature, when named calls are tracked.
pub fn builtin_signature_metadata(name: &str) -> Option<BuiltinSignatureMetadata> {
let canonical = php_symbol_key(name);
Expand Down
22 changes: 22 additions & 0 deletions crates/elephc-magician/src/interpreter/builtins/registry/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,30 @@ fn declared_builtin_registry() -> &'static DeclaredBuiltinRegistry {
}

/// Looks up a declaratively migrated eval builtin with PHP case-insensitive matching.
///
/// This is the single resolution choke point for eval builtin dispatch and
/// introspection (`function_exists`/`is_callable` probes), so the strict-PHP
/// filter lives here: in binaries compiled with `--strict-php`, extension
/// builtins resolve to `None` and eval'd code behaves as if the names did not
/// exist, exactly like the PHP interpreter.
pub(in crate::interpreter) fn eval_declared_builtin_spec(
name: &str,
) -> Option<&'static EvalBuiltinSpec> {
let key = name.trim_start_matches('\\').to_ascii_lowercase();
let spec = declared_builtin_registry().by_name.get(&key).copied()?;
if crate::strict_php_mode::strict_php_mode() && spec.is_extension() {
return None;
}
Some(spec)
}

/// Looks up an eval builtin spec WITHOUT the strict-PHP filter.
///
/// Metadata derivations (the extension-name list itself, docs exporters) need
/// the raw registry regardless of the thread's strict state; every dispatch or
/// introspection path must use `eval_declared_builtin_spec` instead.
pub(in crate::interpreter) fn eval_raw_declared_builtin_spec(
name: &str,
) -> Option<&'static EvalBuiltinSpec> {
let key = name.trim_start_matches('\\').to_ascii_lowercase();
declared_builtin_registry().by_name.get(&key).copied()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
//! - Declarative specs are the source of truth for PHP-visible eval builtin names.
//! - Lookup callers pass canonical lowercase PHP symbol names.

use super::{eval_declared_builtin_exists, eval_declared_builtin_function_names};
use super::{
eval_declared_builtin_exists, eval_declared_builtin_function_names,
eval_raw_declared_builtin_spec,
};

/// Returns the eval interpreter's PHP-visible builtin names.
pub(in crate::interpreter) fn eval_php_visible_builtin_function_names() -> &'static [&'static str] {
Expand All @@ -19,3 +22,23 @@ pub(in crate::interpreter) fn eval_php_visible_builtin_function_names() -> &'sta
pub(in crate::interpreter) fn eval_php_visible_builtin_exists(name: &str) -> bool {
eval_declared_builtin_exists(name)
}

/// Returns the eval builtins that are elephc extensions (no PHP equivalent),
/// in stable sorted order. Strict-PHP binaries hide exactly this set from eval
/// dispatch and introspection. Derived from the RAW registry so the snapshot
/// is independent of the thread's strict-mode state.
pub(in crate::interpreter) fn eval_extension_builtin_names() -> &'static [&'static str] {
static NAMES: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
NAMES
.get_or_init(|| {
eval_declared_builtin_function_names()
.iter()
.copied()
.filter(|name| {
eval_raw_declared_builtin_spec(name)
.is_some_and(|spec| spec.is_extension())
})
.collect()
})
.as_slice()
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ mod metadata_misc;
mod metadata_regex;
mod metadata_streams;
mod metadata_time_and_env;
mod strict_mode;

use super::*;
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//! Purpose:
//! Registry tests for strict-PHP mode: extension builtins must disappear from
//! declarative lookup (and therefore from dispatch, `function_exists`, and
//! `is_callable`) when the compiled binary was built with `--strict-php`.
//!
//! Called from:
//! - `cargo test -p elephc-magician` through Rust's test harness.
//!
//! Key details:
//! - The strict flag is thread-local: strictness is a property of the whole
//! compiled binary, but elephc programs run eval on a single thread, and the
//! thread-local keeps parallel unit tests isolated from each other.

use super::*;
use crate::strict_php_mode::{scoped_enable, strict_php_mode};

/// Verifies strict mode hides extension builtins from declarative lookup while
/// genuine PHP builtins stay resolvable. The RAII guard restores the previous
/// state even if an assertion panics mid-test.
#[test]
fn strict_mode_hides_extension_builtins_from_lookup() {
let _guard = scoped_enable();
assert!(
eval_declared_builtin_spec("ptr_get").is_none(),
"strict mode must hide ptr_get from eval lookup"
);
assert!(
eval_declared_builtin_spec("buffer_new").is_none(),
"strict mode must hide buffer_new from eval lookup"
);
assert!(
eval_declared_builtin_spec("class_attribute_names").is_none(),
"strict mode must hide class_attribute_names from eval lookup"
);
assert!(
eval_declared_builtin_spec("strlen").is_some(),
"strlen must stay resolvable in strict mode"
);
assert!(
!eval_php_visible_builtin_exists("ptr_read8"),
"existence probes must honor strict mode"
);
}

/// Verifies the flag defaults to off and the guard restores it, so non-strict
/// binaries keep the full extension surface.
#[test]
fn strict_mode_defaults_off_and_guard_restores() {
assert!(!strict_php_mode(), "strict mode must default to off");
assert!(
eval_declared_builtin_spec("ptr_get").is_some(),
"extension builtins stay visible without strict mode"
);
{
let _guard = scoped_enable();
assert!(strict_php_mode(), "scoped_enable must be observable");
}
assert!(!strict_php_mode(), "dropping the guard must restore the state");
}
21 changes: 21 additions & 0 deletions crates/elephc-magician/src/interpreter/builtins/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,33 @@ pub(in crate::interpreter) struct EvalBuiltinSpec {
pub(in crate::interpreter) home_file: &'static str,
}

/// Symbols-area builtins that are elephc extensions (attribute introspection
/// without a PHP equivalent). The rest of the extension surface is exactly the
/// `RawMemory` area, so `is_extension` derives from area + this list instead of
/// growing every `eval_builtin!` arm with a new field. The cross-registry gate
/// `extension_builtin_sets_agree_across_registries` in the elephc workspace
/// pins this derivation against the compiler registry's `extension` flags.
const SYMBOLS_EXTENSION_BUILTINS: &[&str] = &[
"class_attribute_args",
"class_attribute_names",
"class_get_attributes",
];

impl EvalBuiltinSpec {
/// Returns this builtin's file-layout area.
pub(in crate::interpreter) fn area(&self) -> EvalArea {
self.area
}

/// Returns whether this builtin is an elephc extension with no PHP
/// equivalent. Strict-PHP binaries hide extension builtins from eval
/// dispatch and introspection so eval'd code behaves like the PHP
/// interpreter, where these names do not exist.
pub(in crate::interpreter) fn is_extension(&self) -> bool {
matches!(self.area, EvalArea::RawMemory)
|| SYMBOLS_EXTENSION_BUILTINS.contains(&self.name)
}

/// Returns the number of required leading parameters.
pub(in crate::interpreter) fn required_param_count(&self) -> usize {
if let Some(required_param_count) = self.required_param_count {
Expand Down
1 change: 1 addition & 0 deletions crates/elephc-magician/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ mod parse_cache;
pub mod parser;
pub mod runtime_hooks;
pub mod scope;
mod strict_php_mode;
mod stream_resources;
mod stream_wrappers;
pub mod value;
Expand Down
Loading
Loading