Compiler architecture defines the representations, pass boundaries and shared FAST migration contracts. The control-flow audit maps completion, continuation inputs and transfer owners to the existing representations and conformance proofs.
The front end: a grammar-based tokenizer over fjs/ebnf/lib/js, the parser, shared FAST, and the transpiler behind fjs compile. It moved here from fjs/djs when the parsers and serializers were restructured, and its issues followed into todo/ when the old serializer was retired and fjs/djs emptied. Module interpretation uses EDAG values; data outputs convert explicitly to ordinary runtime data before serialization. fjs compile writes the language its output name declares: a .data.js document through fjs/media/datajs/serializer in normalized form, a .js module through serializer from the linked graph, and a .json output that refuses what JSON cannot spell rather than approximating it — see module.f.mjs.
File-module resolution uses the host's module identity and loading path. In the Node profile, resolved files are read and diagnosed by their absolute canonical filesystem path, with symlinks resolved. For example, a syntax error in an input spelled main.f.js is reported as /project/main.f.js:line:column; imported modules likewise use the path of the file actually loaded. Tools consuming CLI diagnostics must accept absolute paths and symlink targets instead of expecting the original input spelling. If the entry cannot resolve, its diagnostic keeps that spelling; failure to resolve an import names the canonical importer.
What the compiler accepts is the specification's to say, and the roadmap is spec/todo/'s. What follows is how the compiler holds it. Property access, a.b, a[0] and [1].length, on any value, a numeric literal included, since - is an operator the grammar reads and -1 .x is the negation of the access as JavaScript has it, reads an own property of the base, never the prototype chain, as spec: property accessor has it — a name a built-in prototype gives a value, a.toString or a.push, is refused at the key rather than read as undefined where JavaScript finds a function, length excepted, since a value owns it (fjs/js/prototype) — a method call is the exception the other way, a.at(0) and a.toString() being calls the VM answers by the receiver's type, and only the member functions the same module's prohibitedCalls names, a.push(1) or a.valueOf(), are refused (its README has the table). It answers undefined where there is no such property, and a null or undefined base fails, reported as JavaScript's throw is. A function with fixed names and optional final rest, such as (a, b, c, ...x) => [a, b, c, x], is written by the EDAG, FunctionalScript and Rust outputs and retained by EDAG interpretation. A selected function is refused by data outputs; functions used during initialization can compute data. Source resolution gives declarations stable identities and retains parameter records. FAST normalization preserves the fixed parameter count, even for unused parameters, and adds an invocation rest binding when expansion or the body needs it. Empty and rest-only functions both have length zero.
The classical grammars this package once held were deleted rather than kept: nothing imported them, no proof covered them, and their FunctionalScript half separated statements by newline where the language requires ;. Do not restore them; git history has them.
Every output fjs compile writes — .json, .data.js, .js, .edag.data.js and .rs — is also available as marked text: the same text with the kind of each keyword, literal, string and number its writer spelled, which the demo pages colour from. _compileMarked is compile without creating the directory and writing the file, and compileFile writes toText of its result, so the file never holds markup. There is no command line option for it. See ../text/marked/README.md.
The syntax tree in parser/syntax retains source constructs and token locations. Name resolution and source admission produce ResolvedModule: references identify original declarations, assignments retain their target identity, and functions and loops remain structured. Complete initialization/read/write facts accompany the module; ordinary functions establish capture boundaries and lexical regions do not. Classic for (init; condition; step) statements retain a loop-local header scope and lower to while, running the step on normal body completion and owning continues. Empty clauses are allowed, with an omitted condition meaning true; for…of, for…in and update operators remain separate features.
js/ast/sugar owns pattern/default expansion and normalization into immutable FAST values. It keeps one required initializer, creates fresh versions for assignments, and supplies explicit loop inputs and results. A const snapshot retains its own source identity even when its current value is another declaration's value. FAST references name binding objects, whose numeric indices are presentation metadata; separate equal operations stay separate unless they refer to one established binding.
Object literals retain their ordered member and spread entries, so repeated keys are evaluated before the constructor determines the final properties. A module's final value is its complete export object. Import records retain specifier, attribute and selected-export information; records selecting the same loaded module receive that module's same export object. Empty import lists still require module initialization. The graph-construction convenience API composes normalization with edag/from_fast.
interpret(load)(path) in transpiler loads imports, then evaluates each module's unresolved EDAG with their complete represented export objects as arguments. load is the runtime the initializations run under, a RuntimeLoad run once the root is known to be a module: packageRuntime(packageRoot) for the package's own fjs/js/runtime, whose bodies answer the member functions the interpreter has no native implementation of; pureOk(runtime) for a runtime the caller holds; or noRuntime, under which a module whose initialization calls such a name fails to initialize. Its successful result is an EdagValue; each module identity is evaluated once, and imported values keep their identity. An initialization failure retains its represented thrown payload and source path. The CLI reports module initialization failed at that path; parse and resolution errors keep their existing diagnostics.
transpile(load)(path) composes interpret with toUnknown and returns the complete ordinary runtime value, including callable exports, with EDAG reflection erased:
Effect<ReadWhole | ResolveFileModule | CompileValue, unknown, SourceError | IoChannel>
The runner supplies CompileValue for callable results. Data-only results need no compile/load operation. Source failures retain their paths and represented payloads; runtime compilation failures use IoChannel, including ['notImplemented', 'compileValue'] when a partial runner lacks the operation.
JSON/DataJS output uses _transpileDefault(load), which initializes the complete module, under the same load, before selecting its represented default and applying synchronous toData. Every required dependency and initializer runs, including an unused const or an unselected named export. Its inner Result separates a conversion refusal from a source failure. This allows export const f = x => x; export default 1; to produce JSON 1, while a callable in the selected data is refused at the output path. Both APIs keep direct JSON documents unwrapped. The transpiler proofs cover calls/operators, projection and unused initialization. The obsolete Denotation wrapper is removed.
serializer/value provides the code-generation step for callable runtime compilation: stringify(value) emits a JavaScript module whose default export constructs a closed EdagValue as ordinary runtime data and functions. Evaluated captures may be primitive, repeated or unused; shared containers and functions keep their identity, and function bodies use memo semantics with fresh invocation-local allocations. The emitter trusts constructed values and reuses the function-body renderer. Its output promises runtime behavior, not a structural FJS source round trip. factoryStringify emits the same construction inside a default-exported factory, which the effectful toUnknown uses for runtime loading. Supply its javascriptOperationMap alongside read/resolve handlers to run transpile with callable exports.
JSON/DataJS output selects the default; FunctionalScript output emits individual named/default exports. EDAG and generated Rust retain the complete result. A missing default is refused at an import, but a named-only root projects to undefined for value output. Direct JSON roots remain raw documents in every compiler path, without the module wrapper or projection. The source writer's tryModuleSerialize / tryModuleStringify take that complete export object. Its value writers trySerialize / tryStringify remain the boundary for a direct JSON document, emitting it as a default export. Named exports use existing const binding rules and form the result object in lexicographic key order. Source initializers remain ordered in the AST; the EDAG preserves dependencies and required evaluations under the specification's failure-equivalence rule.
See examples/input.f.js.
A parsed module also compiles to an EDAG — edag/module.f.mjs, Stage 1 of compile-modules-to-edag. unresolved compiles it over its imports, before any import is read: import i is the parameter selection ['.', ['.', ['args'], i], 'default'], a const is one node however many references reach it, and the export object is the root; the specifiers ride beside the graph as Unresolved, a compiler's structure and no part of EDAG. resolve links a program from its root path into one EDAG: each import is read, parsed and resolved the same way, recursively, and bound in its parameter's place — the binding happens where a reference is lowered, so the graph is built once with the imported module's node where its parameter would be — and a module met twice in one link is one node, so a diamond of imports joins where it should. resolve and interpret load a program by one walk, _walk in source, and differ only in what a module becomes. A property access, a.b or a[0], is the EDAG's ['.', base, key], its key a constant the parser admitted — __proto__ and constructor refused at the key. A JSON module, imported with { type: "json" }, exposes its document under default; the import binding selects that tree, as transpile does; a .json file imported without the attribute, or another file imported with it, is refused as JavaScript refuses it. fjs compile writes the linked graph when the output name ends with .edag.data.js or .edag.data.mjs, as a DataJS document with its shared nodes hoisted as the DataJS output's are, and writes it back as source under any other .js or .mjs name, through serializer, retaining function code. What the export does not reach is anchored by the comma operation rather than dropped, [',', [...roots, exported]]: interpretation reads every dependency and establishes every required initializer, so a failure behind an unused one fails value output. FunctionalScript, EDAG and Rust output preserve that computation without executing module initialization. The graph's operands are the roots of the unreached part in source order, an entry another unreached entry reaches being anchored through it, an alias being the node it names, and two imports of one module being one node. A module the export reaches entirely has no comma. A function is ['=>', length, slots, body], where length is nonnegative integer metadata (negative zero is invalid), not an operand. The body opens its own scope: ['arg', N] reads fixed position N < length, and ['rest'] reads the invocation's rest array. Repeated rest reads share that array. ['args'] remains the module import binding and is invalid in a function body. A function's frame is evaluated in the enclosing scope. A reference to a const, an import, an enclosing function's parameter or an enclosing body's const is a capture: the slots are ['=>', length, slots, body]'s array operand, each slot the enclosing scope's own node for a captured value, one per node — a const and its alias, or a parameter read twice, being one, and two nodes spelled alike, o[0] read by two consts, two, since sharing is node identity (fjs/edag/sharing) — in the order the body first names them, and the body reads slot i as ['frame', i] — so no outside node is ever shared into a body, only read through its frame. A captured primitive is written into the body rather than captured, and a function that captures nothing else has no slots. A nested function captures through its parent, its slot a read of the parent's frame. The body is any value except an object, since => { opens a block in JavaScript — or that block, in which an object is a value again: any number of statements, consts and guards, and then one return or one throw, which lowers to the node ['throw', v], an operation of one operand that always fails, in place of the value returned. A guard, if (c) { … }, retains direct lexical regions until sugar writes a conditional value block. Its selected arm performs that arm's declarations and returns its result; a region creates no function or invocation boundary. Switch normalization establishes its discriminant once and tests case labels against that binding. The final alternate is the default block or the source continuation (spec: switch).
Required FAST definitions retain source initializers whose results are unused or reached only conditionally. from_fast keeps their graph roots through a comma. The comma operator's final operand supplies its value, with required work from discarded operands retained in its original region; a discarded reference, literal or function value adds no required computation (spec: comma). A - before a value is the unary minus, ['-', exp] — op12 of one operand — and was once the language's only operator. It is no part of the literal after it, so -1 is the negation of 1 in the parser's tree, and the lowering folds that one case back into the leaf: negating a numeric literal is exact arithmetic, so the graph holds the number and rust prints it. A negation of anything else stays a node — folding one would mean saying what a container converts to. Code outputs retain that operation; value outputs interpret it, including container conversion and represented failures (negation in the language proofs). It binds looser than a step, as it does in JavaScript, so -1 .x is -(1 .x) and -1() is -(1()) — which is what retired the two refusals the old fold needed, an access and a call on a numeric literal alike. What it takes is JavaScript's UnaryExpression, so not a function: -(...a) => 1 is a syntax error in both. A group is that expression, though, so the prefix reaches a function through one, -((...a) => 1), and the refusal falls on the ... where JavaScript's does rather than on the (. The writer spells that group, for a negated function and for a negation under an access, (-[1])[0], alike; a negated number is the number by the time the writer sees it, and a number under an access is grouped, export default (-1)[0];.
Stage A of spec/operators/README.md gave the language the rest: arithmetic, strict comparison, and bitwise — + - * / % **, === !== > >= < <=, & | ^ ~ << >> >>>. Each parses as [tag, left, right] (['~', operand] for the one other prefix), the tag the EDAG's own — op2Id, op12Id's - at two operands this time, told from the unary one by length — so the lowering carries every one of them straight across, both operands lowered and nothing folded: only the unary - above is exact enough to fold without knowing anything else about the program. JSON and DataJS outputs interpret the operators and calls before serializing the resulting data. A function is no operand of any of them unparenthesized, for the reason above; the grammar spells this without wrapping a shared primary the ladder above negation once tried and retired, since func's body is unbounded and a wrapped primary would leak the ladder's own follow set into it — unary, negation's own narrow operand, is every layer's operand instead, ./parser/README.md has the argument. rust already spelled every one of these EDAG nodes but !==, which this front end had no path to produce before — the front end is what was missing, not the code generator.
The source operators && || ?? and the conditional ?: preserve their conditional evaluation regions through FAST. The grammar keeps ?? apart from unparenthesized &&/|| as JavaScript does. Required definition placement belongs to edag/from_fast: an initializer read only in a lazy arm still executes when its declaration executes, so const c = null.x; export default [a && c, b && c]; fails during initialization. The shared logical lowering establishes the left value once and constructs conditionals; canonical EDAG rejects the former logical tags. Source operators retain their grammar.
! and typeof came after both stages, the third and fourth prefixes beside - and ~: the grammar gives each unaryOperand as the other two have, so !2 ** 2 is refused where JavaScript refuses it and !(...a) => 1 where -(...a) => 1 is, the lowering carries each across as the EDAG's own op1Id node, folding nothing, as ~ does — truthiness and the type tag are the interpreter's questions — and the writer spells them as it spells the other prefixes, bare under anything and an operator's text grouped under them, !(1+2), a space after the word for typeof. ! was a token the tokenizer refused; typeof is a keyword the tokenizer hands over as an id, so the grammar gives it a symbol of its own, as it gives return and throw, and since it opens a value it is the one such keyword identifier leaves out — a reference cannot be a reserved word anyway, and a key or a property name may still be one. rust and both interpreters already answered both nodes; the front end was what was missing, as for !== above.
A call is a step after a value, as an access is, and the callee picks which of the EDAG's two forms it lowers to: an access as the callee is a method call, a.b(c), whose receiver is that access's base, so the access owns the call and the two are one node, ['.', a, 'b', ['|()', args]]; any other callee is the plain ['()', callee, args], its arguments an item list, the one an array literal holds. The plain form over an access calls the selected function without its receiver. (a.b)(c) still keeps the receiver, parentheses preserving the property reference: it lowers to the same method node as a.b(c), and the writer emits a method call. For a plain-call node whose EDAG callee is an access, the structural writer names the selected function first, const f = a.b, then calls f(c). A call mints identity — two calls are two nodes and a const naming one is one — which is what a body's const keeps. One call lowers to no call at all: a parameterless function written at the call and called with no arguments, (() => { const x = f(); return [x, x]; })(), becomes a normalized value block in js/ast/sugar, with the enclosing binding values and required evaluations retained at that point. No invocation boundary is added by this block; spec: functions states the source rule. The writer spells the same idiom back where a lazy operand shares a value nothing else reaches. serializer writes both ordinary and method calls, including chained calls, spreads and calls shared through a const. Common graph placement establishes computed callees before the call, as in const f=x+1;export default f();. A function callee takes a binding so reading the output does not inline away the original call node. A negative callee needs the same care as an access base: -1() is -(1()), so a negative numeric leaf is grouped, export default (-1)();, while a computed negation is established by a temporary. The calls, neg and operators groups in the serializer proofs pin these spellings and their structural round trips. Calls and arithmetic can therefore reach source, EDAG and Rust output, and can compute data for JSON/DataJS output.
The structural FJS writer remains partial: unsupported operations, capture layouts and lazy sharing can be refused. Total code-only default function text and runtime-value compilation have separate contracts and may emit JavaScript beyond FJS; they do not make structural source serialization total. Common graph-to-FAST conversion, preparation and JavaScript syntax rendering now use explicit work stacks for expression chains and final result regions. Their direct stage proofs cover 20,000-deep inputs, including required definitions in final result blocks. This does not make the public raw-graph boundary stack-safe: identityAnalysis still has recursive walks and can overflow before those output stages run. Remaining input and graph-admission limits are tracked in deep-nesting-recursion.
Outside optional chaining, a group, (x), is the value it holds: no node in the AST or the graph, and nothing downstream can tell one was written — the steps after the ) read the value inside, which is why (a.b)(c) is the node a.b(c) is, and the sharing a module spells survives the parentheses. A group closes an optional-chain region, so (a?.b).c and a?.b.c are distinct programs (optional chaining). What ordinary grouping adds is spelling: a function returning an object, (...a) => ({ x: 1 }), an access or a call on a value written in place, ([1]).length, and the two the prefix cannot say without it — the access on a negation, (-1).x against -1 .x, and a negated function, -((...a) => 1). The writer spells both as that group, and every operator under an access or a prefix the same way, (1+2).x and -(1+2); a number, a bigint or a function an access reads still takes a const, since 1.x and 1 .x are spellings it does not keep. A member a later duplicate shadows is in the graph, since the constructor applies every member written, so a reference in it is reached here, where a value output, which reads the value the constructor built, never sees it.
The tokenizer's grammar, fjs/ebnf/lib/js, and the parser's, parser/grammar, are read by fjs/ebnf/ll1, which refuses a grammar that one symbol of lookahead cannot decide, before any input. The classical grammars they replaced were read by a backtracking backend, and neither was LL(1) as spelled. Measured before the ports — the classical rule sets bridged into the EBNF form, every rule's closure run through parserRuleSet, each conflict masked once found so the next surfaced — they refused in eight shapes. The table is the record of what each port changed and why; the ports' own issues closed with them.
| grammar | rule | refusal | class |
|---|---|---|---|
| tokenizer | multilineContent: end: ['*', '/'] beside more: [char, …] | first/first on * | grammar: left-factor the * |
| tokenizer | the operator variant, 56 literals | first/first on =, and on every shared prefix behind it | grammar: a prefix tree, built from the list — literals in fjs/ebnf |
| tokenizer | the token variant: comment, ['/', { oneline, multiline }], beside operator, which holds / and /= | first/first on / | grammar: left-factor the /, the shape of the * above. Not removed by the prefix tree — checked with the operator's first set kept whole — and masking a rule by replacement erased / from that set, which is how a first count missed it |
| tokenizer | number: digits0, then the option({ bigint, frac }) | first/follow on the digits, and on e, E, n | the numError poison: [idChar] follows every optional part of a number, and idChar holds digits and letters. The number boundary is decided one layer up, over the token stream |
| tokenizer | jsGrammar = repeat0Plus(token): the idChar repeat of id, the body of a // comment, its option(newLine), the number's { numError: [idChar], ok: none } itself, its fracPart on ., the exponent's sign option on + and -, and multilineContent | first/follow on the next token's first set | inherent to a whole-file grammar: a greedy token against the token after it. With the entry a single token the seven vanish — verified on the bridged set and on a two-rule grammar. The poison is the row above seen from outside the number: nullable, so its follow set is the next token's |
| parser | statementEnd: [trivia, ';', …] beside [lineTrivia, 'nl', …] | first/first on the trivia symbols | trivia leads both branches. Trivia follows every token instead, and ; ends every statement — the newline terminator is gone. The ; later became optional, end ::= [ ';' t ], and the grammar stayed LL(1): ; begins no statement and no statement's continuation, and whether a newline preceded the token after an omitted ; is a fact the token carries, which the fold reads — the rule spec/README.md states now. Later still, trivia left the alphabet altogether: the tokenizer drops it and marks each token with whether a newline preceded it, so no rule spells a t |
| parser | delimited: repeat0Plus([',', trivia, element, trivia]) then option([',', trivia]), once for arrays and once for objects | first/follow on , | the trailing comma, which rested on a failed round rewinding; spelled right-recursively, item t [ ',' t [ items ] ]; with trivia out of the alphabet it is item [ ',' [ items ] ] |
| parser | the module's final { semicolon: [trivia, ';'], none: [] }, then trivia | first/follow on the trivia symbols | trivia leads the option and follows it; gone with the ; after every statement. The option returned as end, trivia inside it rather than after: [ ';' t ] is followed by a statement's first token or eof, never by trivia, so nothing leads and follows it at once; with trivia out of the alphabet it is [ ';' ] |
So each port was a grammar rewrite plus a backend swap, not a swap alone, and the tokenizer also needed a token layer — a parser resumable at an index, the loop over it being the tokenizer's — since a whole-file token grammar is not LL(1) under a first/follow check. Maximal munch inside a token comes for free, an optional round starting whenever the lookahead is in the item's first set, once the punctuators are a prefix tree built from the list. An earlier count through the classical dispatchMap, which had no first/follow check, found three of the eight.
- use JS tokenizer
- identifiers
{a:5} - computed keys
{["a"]:5}, the only keyed spelling of a__proto__key; the short form{ __proto__ }below is the other (spec: the__proto__key) — and{[k]:5}, a key computed from any value (spec: property keys) - big int
-
export default ... - constantsSerialization
const a = [3] export default = { a: a, b: a }const _0=[3]; export default {a:_0,b:_0}; - import
import a from 'c.f.js' export default { a: a, b: a} - short form
const a = 5; export default { a }
Optional, for fun, syntax sugar:
- comments. Ignore them. Not an error.
- double/single quote strings
- using operator and functions
const a = 2+2+Math.abs(5) export default { a: a } - decidable functions?
const f = a => b => a + b export default f(1)(2)
The FunctionalScript repository uses extensions to separate runtime source, type-only source, source-language migration, and compatibility with the current FunctionalScript compiler.
| Extension | Meaning |
|---|---|
.f.ts | Authored FunctionalScript-intent TypeScript implementation/proof source. No longer used: stage 1 removed the last one, and new source must not use this extension. It appears below only to describe that completed migration. |
.f.mjs | Authored FunctionalScript-intent ESM JavaScript with JSDoc types. It may use FunctionalScript features the current parser/compiler does not support yet. |
.f.js | Authored FunctionalScript that the parser/compiler in the same revision accepts: the stage-2 compatibility marker (below). Its proof stays proof.f.mjs, because a proof's functions end without a return and many loop with for…of, neither of which the compiler accepts — its assertion calls are call statements it does (spec: call statements); fjs/asserts itself is one. Every repository module the compiler accepts whole is one, fjs/js/prototype and fjs/types/range among them, and none of them imports an .f.mjs; the packed-package check closing the node26 CI job imports one from a clean consumer of the packed package (fjs/ci/package). No build or packaging step produces one: stage 1's TypeScript runtime emission did, and that pass is gone (#1520). fjs compile <input> <output>.f.js does still write one, to a path the caller names — that is the compiler's output for a user, not repository source. fjs compile with no arguments checks every authored one against this compiler, and CI runs it (spec: checking every .f.js). |
types.ts | Authored TypeScript source for a type-level API. It may coexist with .f.mjs or later .f.js and holds no runtime implementation. |
.d.ts, .d.mts | Generated TypeScript declarations. |
The migration is deliberately split into two implementation stages, both described below; this file is the repository-wide plan now that stage 1 is done and its issue deleted. The package conventions are documented in fjs/ci/todo/publishing-packages.md.
Stage 1 source conversion is complete — no authored implementation or proof .f.ts remains, so every rename described in this section has already happened. It is kept as the record of what the extensions mean and why; write new source as .f.mjs plus, where a type-level API is separately useful, types.ts.
Two prerequisites were written to gate the first real conversion. Neither was met as written; both were de-scoped, and what replaced them is the record:
- authored
.mjspackage support —allowJs/checkJs, authoredtypes.ts, declaration emission, Deno validation, package inclusion, and clean-consumer tests. What the migration needed from it was the validation itself, performed once and recorded inpacked-consumer-validation.md; the committed CI fixture stays open as regression work in that issue, not as a migration gate. .f.mjstest and coverage fixtures — moot once every conversion had happened. The repository itself now supplies the evidence the synthetic fixture was designed to give in advance: everymodule.f.mjsis loaded through its proof under Node and Deno coverage.
Package and publish jobs run from a clean CI checkout, so neither prerequisite required developer-worktree cleanup or tracking ignored outputs from earlier revisions.
The renames went runtime dependency leaves first:
module.ts -> module.mjs
module.f.ts -> module.f.mjs
proof.f.ts -> proof.f.mjs
Type-only source may stay in TypeScript:
module.f.ts -> types.ts
A runtime module may also split its type-level API before the implementation migration and keep that source path unchanged throughout:
types.ts + module.f.ts
types.ts + module.f.mjs
types.ts + module.f.js
This stage was independent of FunctionalScript parser coverage, and the meaning survives it: .f.mjs means FunctionalScript-intent JavaScript, not a compiler-compatibility promise. A .f.ts implementation moved once its authored runtime dependencies could, even where the current compiler cannot parse every feature it uses.
The transition was asymmetric for runtime dependencies: a remaining .f.ts could depend on already migrated .f.mjs, while migrated .f.mjs could not runtime import a remaining implementation .f.ts. Cycles migrated as coherent groups. Type-only APIs stayed in authored types.ts and did not participate in that runtime ordering.
Both TypeScript and JavaScript source reference the same real types.ts file. TypeScript source uses import type:
import type { Phantom } from './types.ts'
JavaScript source uses JSDoc @import:
/** @import { Phantom } from './types.ts' */
Both forms are type-only. Unlike the rejected authored-.d.ts convention, this does not depend on TypeScript resolving a nonexistent .ts or .js specifier to a declaration file: types.ts exists as authored source, so Deno can resolve the same path directly.
Migrated JavaScript could not retain a type-only source edge to a remaining implementation .ts / .f.ts either, and no such file is left to point at. The placement rule it enforced is the part that outlives stage 1: a type that should survive independently of one implementation belongs in types.ts; one that is naturally local to the implementation and expressible in JSDoc stays with it.
A declaration-only file is types.ts rather than .f.mjs, and an existing .f.mjs that turns out to be runtime-empty should move the same way. fjs/types/phantom, whose Phantom type uses a type-only declare const phantomKey: unique symbol, is the worked example: module.f.ts became types.ts with no runtime Symbol() value invented for it. The repository has no runtime-empty .mjs left — the files with no export are executables (fjs/module.mjs, fjs/emergent_testing/all.test.mjs), not declaration modules — so the rule now applies to new source only.
types.ts is ordinary TypeScript source, so the normal TypeScript check validates it even while skipLibCheck remains enabled for .d.ts dependencies. No .gitignore exception or declaration-file checking policy is needed for authored type source.
The package behavior of permanent types.ts was validated before the first real migration. With rewriteRelativeImportExtensions: true, the package fixture had to establish how references to ./types.ts from both .ts and .mjs appear in emitted declarations, which generated types.js / types.d.ts files are required, and that TypeScript, Node, Deno, and Bun can consume the packed result. That experiment ran in #1520: only types.d.ts is required, generated types.js is not, and the runtime-emission pass is gone — prepack emits declarations and then re-checks the tree with them present, so declaration-emit degradation still fails packaging.
Proofs followed the same runtime source-language rule and completed the same move, so a module.f.mjs is accompanied by a proof.f.mjs. Type-only APIs may remain in types.ts. Current FunctionalScript compiler support was never a condition for that rename.
Authored .mjs files carry no file-scope JSDoc @typedef — anywhere in the repository (see the repository-wide rule in the root AGENTS.md and fjs/AGENTS.md §3.2). A named type migrating out of a .f.ts therefore lands in the sibling types.ts (when it is part of the public declaration closure), in an optional sibling private.ts (implementation-private types outside that closure), inline in the annotations that use it, or — for compile-time proof types — function-local in a proof.
Private types and private runtime constants keep a leading _, even when linkage requires an export. The underscore is an API contract, not declaration-level visibility: generated .d.ts / .d.mts may still contain export type _Type = number, but names that begin with _ are private FunctionalScript implementation details. Consumers must not rely on those names directly, so renaming or removing a _-prefixed name is not a breaking change solely because TypeScript emitted it. The public contract still governs transitive effects: if a public type depends on _Type, changing _Type in a way that changes that public type's assignability is a breaking change. Explain that API change and update importers; breaking-change notices are optional before 1.0; policy after 1.0 remains undecided.
For example, suppose the generated declaration initially contains:
export type _Internal = number
export type Public = readonly [_Internal]
Changing it to this is not a breaking change:
export type Public = readonly [number]
_Internal disappeared, but the expanded public contract of Public is still readonly [number]. A consumer that imported _Internal directly was depending on a private implementation detail.
By contrast, changing it to this is a breaking change:
export type _Internal = string
export type Public = readonly [_Internal]
The emitted private alias is still private, but the expanded public contract of Public changed from readonly [number] to readonly [string].
Public types keep ordinary names without the _ prefix. Which types are public is an API design decision, not a mechanical restatement of what the pre-migration .f.ts file happened to export: a helper that belongs to the module's public vocabulary may be published under an ordinary name even though its TypeScript alias was module-private, and a former export may become _ when it only ever described an implementation detail.
No generated private.d.ts ships: package.json's files excludes them with a !**/private.d.ts negation.
What CI checks is the consequence, not the exclusion. Every declaration the package does carry is type-checked as an outside consumer installs it, so a public declaration that came to depend on a private module is a red build rather than a broken package. Losing the negation itself is not caught: the private declarations come back, every reference to them resolves, and that job stays green. It is one line, and losing it is a visible diff in review — see ../ci/todo/f-mjs-package-support.md for why an assertion over the packed listing was written for that and then removed.
The _ contract is permanent and independent of that. _ helpers retained in types.ts by the public declaration closure, and exported _ constants, keep shipping in emitted declarations; they are still not API.
Both end-of-stage-1 cleanups are done. The TypeScript runtime-emission pass is removed (#1520 measured that package resolution does not require a generated types.js), while prepack keeps a no-emit re-check with declarations present: tsc --noEmit false --emitDeclarationOnly && tsc. The blanket **/*.js rule is gone from .gitignore (#1545) — no build or packaging command generates repository .js any more, so it guarded only stale artifacts in pre-existing working trees. fjs compile writing a .js or .f.js to a path inside the checkout is now visible as an untracked file rather than silently ignored, which is the intended behavior for output a caller asked for. **/*.js deliberately stays in package.json's files, because the extension may be used again later; a publish must come from a clean checkout either way. Authored types.ts files remain.
The repository compiler-compatibility migration is todo/fjs-nanvm-integration.md. Stage 1 was its first blocker and is complete; a standalone .f.js is directly type-checked, receives a .d.ts, is packed in the clean CI package build and is covered, and the packed-package check closing the node26 CI job imports a published one from a clean consumer, runs it and type-checks a use of its declaration with a negative control (fjs/ci/package); and the first renames followed — every module the compiler accepted whole, fjs/js/prototype and fjs/types/range among them. What keeps them honest is fjs compile with no arguments, which CI runs.
Compiler-supported dependency-closed groups migrate incrementally:
module.f.mjs -> module.f.js
An authored .f.js is a compatibility commitment: the FunctionalScript parser and compiler in the same repository revision must accept the complete module, and its runtime and declaration dependencies must satisfy the compiler migration rules. fjs compile with no arguments is what holds every .f.js to it, and CI runs that check once per revision. Unsupported modules remain .f.mjs until the required compiler features land. A sibling authored types.ts remains unchanged across this rename. The rewrites that bring a module to what the compiler accepts, one per construct, are doc/CONVERTING.md.
A synthetic JavaScript compiler fixture may be used before repository migration; it does not change the extension contract for repository source.
Decided with the retirement of the hand-written scanner, and not to reopen without a reason. The grammar's tokens, fjs/ebnf/lib/js read by fjs/js/tokenizer, grow toward the whole JavaScript lexical surface, because everything that reads a .f.mjs — this compiler, the website's highlighter, a linter — needs the same tokens, and a token that is recognised is not thereby accepted: the compiler's fold and grammar refuse what the language does not admit, at the token, as they refuse -- — the decrement operator, one token the fold turns into an error because the language has no rule for it. The rules the grammar shares with JSON flow the other way — it imports JSON's digit and string rules from fjs/ebnf/lib/json, and no codec reads this grammar — so widening it regresses no codec. The parser stays the FunctionalScript grammar, LL(1) over those tokens, and grows one production at a time as the EDAG stages ask.
A full ECMAScript parser with a filter behind it — accept everything, then decide from the tree what is FunctionalScript — was considered and refused. It is not LL(1) (ASI, cover grammars, contextual keywords, regex-or-division), so it would be the hand-written surface the grammar route exists to avoid; the subset law needs only that what is accepted means what JavaScript means, which the LL(1) grammar and the engine-as-oracle proofs give; and nothing open needs a JavaScript parse tree, the views and the linter needing tokens. What a full parser would buy — a message naming the construct refused rather than the token — is an error production in the subset grammar, where it earns its place. Where the rest of fjs/js lives afterwards is a later rename and no part of this decision.
!- logicalNot!=- nonStrictNotEqual!==- notEqual
"- doubleStringBegin$- idBegin%- remainder%=- remainder assignment
&- bitwiseAnd&&- logicalAnd&&=- logicalAndAssignment&=- bitwiseAndAssignment
'- singleStringBegin(- groupingBegin)- groupingEnd*- multiplication**- exponential**=- exponentialAssignment*/- commentEnd*=- multiplicationAssignment
+- addition++- increment+=- additionAssignment
,- comma-- subtraction--- decrement-=- subtractionAssignment
.- dot...- spread
/- division/*- commentBegin//- oneLineComment/=- divisionAssignment
0..9- numberBegin:- colon;- semicolon<- less<<- leftShift<<=- leftShiftAssignment<=- lessEqual
=- assignment==- nonStrictEqual ?===- equal=>- arrow
>- greater>=- greaterEqual>>- rightShift>>=- rightShiftAssignment>>>- unsignedRightShift>>>=unsignedRightShiftAssignment
?- question?.- optional chaining??- nullish coalescing??=- nullishAssignment
A..Z- idBeginInfinity- infinityNaN- nan
[- propertyBegin]- propertyEnd^- bitwiseXor^=- bitwiseXorAssignment
_- idBegin`- templateBegina..z- idBeginasync?await?breakcasecatchclassconstcontinuedebuggerdelete?do?elseexportfalsefunction?globalThis?forifimportininstanceofletnew?nullofreturnsuperswitchthis?throwtruetrytypeofundefinedvarvoidwhileyield
{- objectBegin|- bitwiseOr|=- bitwiseOrAssignment||- logicalOr||=- logicalOrAssignment
}- objectEnd~- bitwiseNot~=- bitwiseNotAssignment