This guide covers how to work in the repository. The contract of the library is in packages/maple/docs.
nvm use # .nvmrc → lts/*
pnpm install # also installs the lefthook git hooks| Command | Does |
|---|---|
pnpm start |
Alias for pnpm dev |
pnpm dev |
Serve the playground on :4200 |
pnpm build |
Build the library (tsdown) and the playground |
pnpm test |
Run the library and parity tests (Vitest) |
pnpm typecheck |
Typecheck all projects |
pnpm lint |
oxlint (type-aware) |
pnpm format |
Format with oxfmt (format:check in CI) |
Before you push, run the checks CI runs:
pnpm format && pnpm lint && pnpm typecheck && pnpm testCI (.github/workflows/push.yml) runs format:check, lint, typecheck and build on every push.
It runs test on Node 22, 24 and 26: the minimum from the library's engines field and every newer even release.
Commits must follow Conventional Commits, which commitlint enforces in the commit-msg hook.
Every commit needs a scope: the area it changes, such as evaluator, boundary, builtins or json in the library, playground or parity, or * when it spans the repository.
Older commits name the library's package, maple, instead; new ones name the area, so the changelog says what changed.
Pull requests target increment, the default branch, and are merged with a merge commit or rebased, never squashed, so their commits reach master as they are.
A release is a merge of increment into master, with a merge commit as well.
After it, the sync job in .github/workflows/push.yml merges master back into increment, so the release commit and its tag are there for the next pull requests.
On every push to master, semantic-release derives the version and changelog of @netwin/maple from them, so never edit its version or CHANGELOG.md by hand.
Tags are plain versions (1.2.3).
While Maple is on 0.x, breaking changes bump the minor version:
| Commit | 0.x | ≥ 1.0 |
|---|---|---|
fix(…): … / perf(…): … / refactor(…): … |
patch | patch |
build(deps): … |
patch | patch |
feat(…): … |
minor | minor |
feat(…)!: … / BREAKING CHANGE: footer |
minor | major |
Every other type, such as docs, test, ci or build without the deps scope, releases nothing and waits for the next release.
On every pull request, .github/workflows/changelog-preview.yml runs semantic-release in dry-run mode against the PR branch and posts the changelog it would produce as a sticky comment.
Every interpreter feature and every rejected escape needs a test. A change in behaviour updates the docs in the same commit.
The library compiles against lib: ["ES2024", "ES2025.Collection", "ES2025.Iterator"], the parts of ES2025 that the minimum Node version implements.
It has neither DOM nor Node type definitions, so a host API cannot be used by accident.
The declared exceptions are the guarded performance.now() behind durationMs, and atob and btoa.
The evaluator in packages/maple/src/evaluator runs a script by walking the syntax tree acorn parsed from it.
Evaluator in evaluator.ts is only a switchboard: evaluateStatement and evaluateExpression charge an operation for each node and hand it, by its node type, to the file in language/ that implements that part of JavaScript.
language/ |
JavaScript it implements |
|---|---|
hoisting.ts |
which names a block or function declares, before its statements run |
blocks.ts |
statement lists, hoisting, var / let / const |
loops.ts |
while, do…while, for, for…of, for…in |
control-flow.ts |
if, return, break, continue, labels, switch, throw, try |
literals.ts |
literals, template literals, object and array literals, spread |
member-access.ts |
a.b, a[b], optional chains, delete |
references.ts |
reading and assigning variables |
patterns.ts |
destructuring into names, as in declarations, parameters and assignments |
operators.ts |
unary, binary, logical, conditional and comma operators |
assignments.ts |
=, compound and logical assignment, ++ / -- |
closures.ts |
creating function values from function and arrow syntax |
calls.ts |
calls, new, tagged templates, running a script function |
Next to language/ is what every part of JavaScript runs on:
run-context.ts: the run's budget, call stack and run state, and how errors are raised: a script error the script can catch, or aMapleErrorthat ends the run.host-bridge.ts: every call into host functions and intrinsics, which is where values are copied and host exceptions are hidden from the script.scope.ts,completion.ts,budget.ts,describe.ts: scopes, completions (how areturn,breakorcontinuetravels up), the operation budget, and the text error messages quote from the script.
Three rules keep the language files apart, so a function can be understood from its own file:
- A language file is a set of plain functions that take the
Interpreter(seeinterpreter.ts) as their first argument. Whenever it evaluates a piece of syntax it does not handle itself, such as the default in{ size = 10 }, it calls the interpreter, which dispatches it. - A language file may call plain helpers of the language files below it, as
patterns.tsusesresolveMemberfromreferences.ts, but never of one above, so the imports form no cycle. - A function that takes only a
RunContext, not the interpreter, cannot evaluate anything.
Every language file starts with a comment that explains its part of JavaScript with examples, written for a reader who does not know Maple.
To support a new piece of syntax, add its case to the dispatcher in evaluator.ts, implement it as a function in the language file it belongs to, test it through Maple.run in the spec in language/ for that part of the language, such as control-flow.spec.ts or destructuring.spec.ts, and list it in language.md.
Hot paths are measured with pnpm --filter @netwin/maple-playground measure-run <script.js>; compare before and after a change to the evaluator.
- Register the method under its name in the prototype of the receiver type as
method(String.prototype.name.length, (receiver, args, context) => value), or a static asnew NativeFunction('isArray', Array.isArray.length, (args, context) => value)in themembersof the namespace. The length is read off the host's own function, so it is JavaScript's. Only where TypeScript'slibhas no typing for it (Annex B members such asgetYear) is it written out, with a comment saying why. A member thelibmarks as deprecated, such assubstrorescape, is still the host's, under anoxlint-disablefortypescript/no-deprecatedthat says why. - Convert every argument hook-free first (
toString,toNumber,optionalNumberArgument, …) so the host only ever sees primitives. If the method takes a callback, invoke it throughcontext.callso it runs under the budget. Never hand a script closure to a host implementation. - If the method needs no callback and cannot observe prototypes, it may delegate to the host implementation on a Maple-owned value (for example
Array.prototype.with). Call it through the prototype, asArray.prototype.with.call(a, …), never asa.with(…): a script can give its array an own property of that name. Wrap it inguardHostErrorswhen the spec requires aRangeError,URIErrororSyntaxErrorfor it, or when it can build a string or array longer than the host allows. - Return only sandbox values: primitives, Maple-created objects and arrays, or wrappers.
Never return a native
Date,RegExp,Map,Setor function. - Add the method to the list in builtins.md and write its spec file. If it departs from JavaScript, add it to divergences.md.