Added
.github/workflows/:ci.yml,docs.yml,release.ymlandflake-update.yml, plus
the sharednix-setupcomposite action. This repository previously had no CI at all.flake.lock. The flake declared ten inputs and locked none of them, so no two builds
resolved to the same dependency tree.CHANGELOG.mdand thedocs/site (MkDocs Material), neither of which existed.checks.formatting(treefmt/nixfmt) andchecks.docs(mkdocs --strict) alongside
the existing compile and test checks, and anapps.testentry point.packages.coverage-reportandchecks.coverage:scripts/run-coverage.lisp
(SB-COVER + cl-weave) existed but was never wired intoflake.nix, so no CI run or
localnix buildever produced a coverage report.checks.coverageasserts the
report exists; it does not gate on a percentage yet (nocheck-coverage.pl-equivalent
exists in this repository, unlike some siblings).- A
cl-weavecustom matcher,:to-have-set-values, replacing a helper function
(%jr-assert-set-has-all) that issued multiple disconnectedexpectcalls internally
with one properly-reported assertion. - A second
cl-weavecustom matcher,:to-be-js-undefined(t/runtime-core-test.lisp,
loaded first so everyruntime-*-test.lispfile can use it), replacing the verbose
(expect ... :to-be cl-cc/javascript::+js-undefined+)spelling repeated at 43 call
sites across 13 test files with(expect ... :to-be-js-undefined). Defined via the
publicdefmatchermacro (not cl-weave's internal, unexporteddefpredicate-matcher
that its own built-in zero-argument matchers like:to-be-truthyuse) — same pattern
as the pre-existing:to-have-set-valuesmatcher above. No behavior change:eq
against+js-undefined+is exactly what the old:to-bespelling already checked
(:to-beitself iseql, and this runtime's+js-undefined+is a single interned
sentinel, soeq/eqlagree here). - Two new
cl-weaveit-propertytests, extending this codebase's very sparse use of
property-based testing (previously exactly oneit-property, forbtoa/atob) to
two more genuine round-trip functions:js-rt-encode-decode-uri-component-roundtrip- property(decodeURIComponent(encodeURIComponent(s)) = s,
t/runtime-collections-weak-test.lisp) andjs-rt-text-encoder-decoder-roundtrip- property(TextDecoder().decode(TextEncoder().encode(s)) = s,
t/runtime-builtins-platform-test.lisp, exercising 1/2/3-byte UTF-8 sequences via a
mixed ASCII/é/€ generator alphabet). Both complement pre-existing fixed-example tests
covering the same functions rather than replacing them — the generated cases cover
many more character-run orderings and lengths than a handful of hand-picked strings
can. cl-concurrent-kit(pinnedv0.1.0), anerima-lispdependency-free SBCL concurrency
toolkit, adopted directly — not through an adapter — for the generator runtime's
suspend/resume coroutine hand-off (src/runtime-generator.lisp).%js-generator- channelpreviously hand-rolled a mutex, condition variable, and an explicit:body/
:driverturn flag to enforce that exactly one of the two threads runs at a time.
Replaced with a pair ofcl-concurrent-kit:channels (make-channel's default,
unbuffered/rendezvous mode): an unbuffered channel'ssendalready blocks until the
matchingrecvtakes the value, so eachsend/recvpair below is itself the
synchronization point — no separate turn-tracking is needed at all. Wired through the
standard four points othernerima-lispsource-tree dependencies in this repo use:
flake.nix(input pinned to the tag,CL_CC_JAVASCRIPT_CL_CONCURRENT_KIT_ROOTenv
var),cl-cc-javascript.asd(:depends-on),scripts/dependency-roots.lisp
(env-var → sibling-dir mapping),src/package.lisp(:import-from). This was
specifically evaluated on its own merits (not as a side effect of another dependency
bump) per a standing note inflake.nix'scl-log-kitcomment flagging it as worth
revisiting. No observable behavior change — verified via two consecutivenix build .#checks.aarch64-darwin.defaultruns (concurrency-sensitive code warrants more than
one pass): 1319 passed both times, 0 failed.docs/src/architecture.md/docs/src/development.mdcorrected to match this session's
changes: stale file/line counts (t/file count,package.lisp's line count), a gate
table missing the newchecks.coverage, the coverage section not mentioning the
nix build .#coverage-reportpath, and a reference tot/parser-stmt-test.lisp,
which no longer exists after this session's test-file split.%jr-assert-string-props(a test helper checking several object properties in one
call, e.g. every URL component) now wraps its checks incl-weave's
with-soft-assertions, so a mismatch on one property no longer hides mismatches on
the others — previously the first failingexpectaborted the whole helper.cl-date-kit(pinnedv0.2.0), adopted
directly (no adapter) for real IANA time zone support in the Temporal runtime, which was
previously silently hardcoded to"UTC"everywhere — an undisclosed gap this now both
fixes and documents honestly.Temporal.Now.timeZoneIdreports the host's actual IANA
zone (viaTZor/etc/localtime) instead of a literal"UTC", and
Temporal.Now.zonedDateTimeISO,Temporal.Instant.prototype.toZonedDateTimeISO, and the
Temporal.ZonedDateTimeepoch-based constructor now project an absolute instant into any
IANA zonecl-date-kit's tzdata recognizes — an unambiguous instant → local-zone
projection, not the reverse (constructing from local wall-clock fields in a non-UTC zone,
which needs a DST gap/overlap disambiguation policy, is explicitly out of scope and
documented as such indocs/src/compatibility.md).flake.nix'schecks.default,
apps.test, anddevShells.defaultnow pointTZDIRat nixpkgs'tzdatapackage so
this is exercised for real inside the Nix sandbox, not silently skipped.Date.prototype.getTimezoneOffset()now uses the samecl-date-kithost-zone
discovery and instant → local-zone projection as Temporal (above), reporting genuine
minutes-west-of-UTC for the host's discovered IANA zone instead of a hardcoded0.
Unlike the Temporal gap, this one already had an honest docstring ("UTC assumed"), so
this is a feature addition built on newly-adopted infrastructure, not a disclosure fix.
Falls back to0under the same conditions Temporal's zone functions do (unresolvable
host zone, no readable tzdata).cl-json-kit(pinnedv1.0.1), adopted
directly (no adapter, through its own:null-value/:false-value/:true-value/
:number-encoderparse and write hooks) to replaceruntime-json.lisp's ~100-line
hand-rolled JSON parser/writer with an RFC-8259-conformant one (95/95 JSONTestSuite
must-accept, 188/188 must-reject, per its own README) — comparing the two surfaced
three real, previously undisclosedJSON.parse/JSON.stringifybugs, all now fixed:\uXXXXescapes were never decoded duringJSON.parse. The hand-rolled string
scanner's escapecasehad no\uclause at all, soJSON.parse("\"\\u0041\"")
produced the six literal charactersu0041prefixed by nothing sensible, not"A".JSON.parseon malformed input silently returnedundefinedinstead of
throwing — realJSON.parsethrows aSyntaxError.%js-json-parsenow signals a
genuine*js-syntax-error-class*instance via%js-throw, JS-catchable like any
other thrown error.JSON.stringifynever omittedundefined-valued object properties. The
omission check (unless (string= vs "undefined") ...) compared against the
stringified text of the value, butundefineditself always stringified to the
text"null"first — so the comparison could never be true, and
JSON.stringify({a: undefined})produced{"a":null}instead of{}. Fixed with
genuinely context-aware handling (%js-json-stringify-normalize): an
undefined/function/Symbol value is omitted from an object property, becomes
nullin an array, and makes a top-levelJSON.stringifycall returnundefined
itself (not a string) — three different real-JS behaviors the old code conflated
into one wrong one.
JSON.rawJSON's raw-fragment splicing (json-kit has no native "write this text
verbatim" leaf type) is done via a marker-substitution pass — normalize each raw
wrapper to a uniquegensym-named string, letjson-kit:stringifywrite it as an
ordinary JSON string, then replace that string's own quoted form in the output text
with the real raw text. Previously implemented but never actually tested end-to-end;
now covered by the existingjs-rt-json-raw-jsontest, unchanged and still green.
Number formatting (bare digits for a whole number,~Fotherwise) is preserved
exactly via:number-encoder—json-kit's own default keeps a float's decimal
point unconditionally (for CL integer/float round-trip fidelity, a distinction JS
numbers don't make), which would have regressed every whole-numberJSON.stringify
call from"42"to"42.0"without this hook. Verified vianix build .#checks.aarch64-darwin.default: 1333 → 1336 passed (one case removed — subsumed by
a dedicated new test — one existing test's assertion corrected, four new), 0 failed —
including the E2E tests that exerciseJSON.parse/stringify/rawJSONthrough
actually-compiled JS source.
scripts/coverage-summary.lisp: parses the LCOV report (see below) and prints an
aggregate line-coverage percentage for this repository's ownsrc/*.lispfiles —
filtered by absolute path, unlike the HTML report's basename-keyed per-file links,
which collide across sibling packages that happen to share a generic filename.- A
cl-weaveit-propertytest (js-rt-btoa-atob-roundtrip-property) asserting
atob(btoa(s)) = sfor generated binary strings, alongside the existing single-example
test — covers every base64 padding remainder (string lengths not divisible by 3) across
many generated inputs instead of one hand-picked one. - A
cl-weaveit-fuzztest (js-rt-regex-fuzz-compile-and-exec-never-crashes,
t/runtime-regex-test.lisp) generating 300 random (pattern, subject) pairs from an
alphabet weighted toward regex metacharacters (groups, classes, quantifiers, anchors,
escapes) and asserting compiling and testing each pair never signals an unhandled Lisp
error.%js-make-regexalready catches a compile-time error (an invalid pattern
just becomes an uncompiledRegExp, matching nothing); this fuzzes the previously-
unverified other failure mode — a pattern that compiles "successfully" but whose
matcher closure crashes at match time against adversarial input (an out-of-bounds
aref, for instance) — across the regex engine as extended this session (capturing
groups,{n,m}quantifiers). Found no crashes across 300 trials;it-fuzzwas
previously unused in this test suite despite fitting this exact "does this ever throw
something it shouldn't" question better than a fixed set of hand-picked inputs could. - Regex capturing groups:
(expr)and(?<name>expr)now record their matched span
in agroupsvector threaded through the whole matcher (src/runtime-regex.lisp's
%js-regex-capturing-group-matcher), numbered by opening-parenthesis order, and
%js-regex-exec(src/runtime-regex-api.lisp) surfaces them as"1".."N"on the
match object plus a null-prototypegroupsobject for named captures —
match(/(\d+)-(\d+)/)[1]no longer returnsundefined.regex-replace-placeholders
expands$1-$99,$<name>, and$$alongside the pre-existing$&. Previously
the compiled matcher was always called withgroupsbound toniland this data
simply didn't exist anywhere in the engine. - Regex bounded-repetition quantifiers
{n}/{n,}/{n,m}(and their lazy?suffix)
via%js-regex-parse-brace-quantifier/%js-regex-bounded-repeat-matcher
(src/runtime-regex.lisp).{...}that isn't a well-formed quantifier (no digits, a
missing minimum before the comma, an unclosed brace) falls back to matching its
characters literally — the same Annex-B-style leniency real JS engines apply outside
Unicode mode — rather than erroring. Previously*/+/?were the only recognized
quantifiers anda{3}matched the four literal charactersa,{,3,}. See
docs/src/compatibility.md's regex section (updated) for what's still genuinely
unimplemented (lookbehind).
Changed
-
js-parse-function-expr(src/parser-expr-literal.lisp) — found via aparedit inspect clone-classessweep, whose top-ranked "duplication" in this file turned out
to be a self-similarity artifact (the same 5-level-deepmultiple-value-bindchain
matched against itself at every nesting level, not real cross-location copy-paste) —
investigating it anyway surfaced a genuine, separate readability problem the false
"clone" was pointing at: 8 levels of nesting (5multiple-value-binds, 2lets, a
cond) in one function, the same shape an earlier session already fixed once for
regex-replace-placeholders. Split the AST-building tail (everything after parsing
finishes needing the stream — splitting params by defaults, building the rest
binding, wrapping async/generator, the letrec self-recursion binding for named
function expressions) into a new%js-build-function-expr-ast, taking plain values
(params optionals rest-sym body-forms async-p is-generator name) instead of
threading through nested stream-parsing binds.js-parse-function-expritself drops
from 8 nesting levels to 3 (just the stream-threadingmultiple-value-binds a
recursive-descent parser genuinely needs). Pure extraction, no behavior change —
verified vianix build .#checks.aarch64-darwin.default: 1328 passed both before and
after, 0 failed, exercising every branch this function has (named/anonymous,
async/generator/async-generator/plain). -
%js-compile-pattern'scompile-atom(src/runtime-regex.lisp) — the regex
compiler's atom parser, and by extension this codebase's single largest function
(previously ~230 lines: onedefunwrapping alabelsform for
compile-atom/compile-seq/compile-alt's mutual recursion) — had the exact same
4-line "adjust END past a group's closing), if present" computation duplicated
verbatim across all four parenthesized-group branches (lookahead, non-capturing,
named-capturing, plain capturing). Extracted to a newcompile-group-closefunction,
added as a fifth sibling inside the SAMElabelsform (not a top-leveldefun) so it
can still close overpatthe same way its callers already do, with no calling-
convention change and none of the risk a full top-level extraction of
compile-atom/-seq/-altinto separatedefuns would carry (each closes over
severallet-bound locals —pat,ic,ml,group-count,group-names,
compile-depth— that a top-level function would need explicitly threaded through
every call; deliberately still not attempted here, a separate, larger, riskier
decision from this purely-internal helper extraction). Pure deduplication, no behavior
change — verified vianix build .#checks.aarch64-darwin.default: 1328 passed both
before and after, 0 failed, including the existing 300-trial regex fuzz test that
exercises every one of these branches. -
%js-char-set(src/runtime-ops-encoding.lisp) built its membership hash-table with
an explicit:test #'eql—eqlis alreadymake-hash-table's default test, so this
was pure noise, found via a freshparedit inspect lintsweep. Simplified to
(make-hash-table). No behavior change, verified vianix build .#checks.aarch64-darwin.default: 1328 passed both before and after, 0 failed. -
cl-boundary-kit(transitive-only: needed forcl-cc's own build to resolve,
cl-cc-javascriptnever imports it directly)v1.0.0→v2.0.0: a real breaking
release per its ownCHANGELOG.md— native-environmentset-fn/unset-fnnow
default to a realcl-host-kit-backed implementation instead ofnil,
hostname-fn/username-fn/exit-fn/args-source defaults moved ontocl-host-kit
(no observable behavior change, implementation only), and the optional
cl-boundary-kit/process-kit/cl-boundary-kit/jsonsubsystems were removed. None of
this touches surfacecl-cc-javascriptis exposed to. The blocker to bumping at all
wascl-host-kitbecoming a new required dependency ofcl-boundary-kit's core
system — added as a new flake input (v0.2.1, latest tag) purely to satisfy this
transitive requirement (not adopted for direct use — already evaluated and rejected
on its own merits earlier this session) and wired through the same
dependencyEnv/scripts/dependency-roots.lispmechanism every other source-tree
dependency here uses.flake.nix'scl-log-kitpin comment updated to reflect that
cl-host-kitis now available as an input (removing that specific blocker to a
futurecl-log-kitv2.0.0 evaluation) without bumpingcl-log-kititself in this
pass — that pin's own breaking-change surface is a separate decision, not touched
here. Verified vianix flake check(compile, tests, docs, formatting, coverage all
green) andnix build .#checks.aarch64-darwin.default: 1328 passed, 0 failed. -
regex-replace-placeholders(src/runtime-regex-api.lisp) — the$-escape parser
behindString.prototype.replace's replacement-string handling — was 6 levels deep
at its most nested point (aloopinside acondinside anifinside alet
inside anothercond...). Extracted each escape kind's own logic into a focused
helper (%js-regex-named-escape-textfor$<name>,
%js-regex-numbered-escape-textfor$1-$99) called from a new
%js-regex-dollar-escape-textdispatcher, so the main loop now reads as a flat
"does TEMPLATE have a recognized escape at this position? emit its text and jump
past it, or emit the literal character and advance by one" — no behavior change, all
5 existing replacement-placeholder tests pass unchanged. Verified vianix build .#checks.aarch64-darwin.default: 1332 passed (unchanged count), 0 failed. -
%js-emit-object-pattern-bindings/%js-emit-array-pattern-bindings
(src/parser-stmt-binding.lisp) — the two functions that turn a parsed
destructuring pattern into alet*bindings alist were the deepest-nested
functions in the codebase (indent depth 60 and 48 respectively), each inlining
the AST-construction for every field/element kind directly in acond. Extracted
%js-emit-rest-property-binding/%js-emit-named-property-bindings(object
patterns) and%js-emit-array-rest-binding/%js-emit-array-element-bindings
(array patterns). The array-pattern extraction also collapsed two branches that
were previously separate (:defaultvs. plain element) into one shared
%js-emit-array-element-bindingscall, since%js-default-accessalready
returns its access expression unchanged when passed anildefault — the two
branches differed only in whether that argument was present. No behavior change. -
js-parse-function-decl(src/parser-stmt-fn.lisp) — the generator/async
body-wrappingcond(deciding whether the compiled defun's body becomes
(%js-make-generator (lambda () ...)),(%js-async (lambda () ...)), or the
plain body) was inlined at the function's deepest nesting point. Extracted as
%js-wrap-callable-body, a self-contained named helper with no change to the
token-stream-threadingmultiple-value-bindchain around it, which mirrors the
same sequential-parse idiom used throughout this file's sibling parsers
(%js-parse-if-tail,js-parse-while-stmt,js-parse-do-while-stmt) and was
left alone. No behavior change. -
js-parse-try-stmt(src/parser-stmt-flow.lisp) — parsing catch clause(s),
the optional finally clause, and lowering the result to the
%js-try-catch-finallycall were all inlined in one function reaching indent
depth 54. Extracted%js-parse-catch-binding(the optional(e)after
catch),%js-parse-catch-clauses(the catch-clause loop),%js-parse-finally- clause, and%js-build-catch-dispatch(the catch-clause-to-AST lowering),
leavingjs-parse-try-stmtitself as a flatmultiple-value-bindchain over
the four pieces. No behavior change. -
%js-parse-decorator(src/parser-class-helpers.lisp) — parsing the dotted
member-access chain (@foo.bar.baz) and the optional argument list
(@decorator(args...)) were both inlined in one function. Extracted
%js-parse-decorator-member-chainand%js-parse-decorator-args, the
latter's docstring now explicitly documenting why it builds an unbound
_decorator-arg_placeholder per argument instead of a real parsed
expression:%js-lower-class-to-astdiscards its wholedecorators
argument today, so no decorator argument AST is ever actually evaluated —
a real latent bug, deliberately not fixed here since it's inert until
decorator execution is wired up, a separate, larger feature. No behavior
change. -
%js-make-url-search-params's"set"method (src/runtime-builtins- platform-url.lisp) inlined its whole rebuild-the-pairs-list algorithm (keep
the first matching entry, overwrite its value, drop every later duplicate,
append if absent) in a 15-linelet/dolist/cond. Extracted as
%js-url-search-params-replace-first, a pure function overpairs/key/
valuewith no closure over mutable state, leaving the method itself a
3-linesetf+call. No behavior change. -
%js-lower-incdec(src/parser-expr-postfix.lisp) built the identical
(setq var (op var 1))AST twice — once for prefix, once inside postfix's
temp-binding body — differing only in whether the left operand was the
originalexprnode or a freshly built(make-ast-var :name var-sym)
(the same thing, since this branch only runs whenexpralready satisfies
ast-var-pwith that name). Extracted%js-incdec-setq. No behavior
change. -
Three genuinely quadratic accumulation patterns, found via a fresh
paredit inspect lintsweep ofsrc/*.lispafter this session's own
extractions (quadratic-accumulationrule, 7 findings total): (1)
%js-emit-object-pattern-bindings/%js-emit-array-pattern-bindings
(src/parser-stmt-binding.lisp) rebuilt their whole bindings list on every
destructured field/element via(setf bindings (append bindings ...))—
O(n²) in field count. Rewritten asloop ... nconc, which splices each
field's fresh bindings onto an accumulated tail in O(1) instead of copying
the whole list-so-far on every iteration. (2)%js-parse-tagged-template
(src/parser-expr-unary.lisp) built up a tagged template's cooked string
via repeated(concatenate 'string current part)for every consecutive
string part — collected into a list and joined once per flush instead. (3)
js-parse-var-decl(src/parser-stmt.lisp) rebuilt the whole bindings
list per comma-separated declarator (let a=1, b=2, ...) the same way as
(1) — each declarator's bindings now collected into a list and flattened
once after the loop. All three arefor/dolist-adjacent patterns where
the accumulated result only grows a small, bounded amount per iteration in
typical code (a handful of destructured fields or declarators), so the
practical severity was low, but the fix is free and the pattern is worth
keeping out of the codebase regardless. No behavior change — verified via
nix build .#checks.aarch64-darwin.default,paredit inspect lint
confirms 0 remainingquadratic-accumulationfindings. -
%js-percent-encode(src/runtime-ops-encoding.lisp, backing
encodeURIComponent/encodeURI) tested each input character against its
safe-chars set withmember— an O(9) or O(20) linear scan repeated once
per input character, found via the samelinear-search-in-looplint sweep
as the entry below.+uri-component-safe-chars+/+uri-safe-chars+are
small, fixed, load-time constants reused across every call, so precomputing
them once as%js-char-sethash-tables (O(1)gethashper character
instead of an O(k)memberwalk) is a clean, unconditional win with no
per-call construction cost. Investigated 9 otherlinear-search-in-loop
findings from the same sweep and did NOT apply the same fix to any of them:
most search genuinely tiny fixed lists (regex flags, a handful of escape
characters, function parameter/optional-default alists) where a hash-table
adds allocation overhead with no realistic win; one
(%js-destructure-object'sexcluded-keys check,
src/runtime-object-ops.lisp) rebuilds its collection fresh on every call,
so hash-table construction cost could plausibly offset the asymptotic gain
for the small key counts real destructuring code exercises — left as
member, not a clear improvement either way; and one
(%js-split-string-on-char'spositioncall,
src/runtime-builtins-platform-url.lisp) is a false positive — its
:startargument advances every iteration, so the loop is already O(n)
total (each character is scanned at most once across all iterations), not
the O(n·k) the lint rule's pattern-match assumed. No behavior change,
verified vianix build .#checks.aarch64-darwin.default. -
performance.now()(src/runtime-builtins-table-specs.lisp) computed
(- (get-internal-real-time) 0)— subtracting the literal 0 is a no-op
(identity-arithmeticlint finding). No behavior change. -
flake.nix'sapps.default/apps.testoutputs gained ameta.description—
nix flake checkpreviously warned "app ... lacks attribute 'meta'" for
both; a fresh full-file review offlake.nix/cl-cc-javascript.asd(goal:
catch drift, not routine — the version-sync comment's own claim
("flake.nix reads this form... refuses to publish a tag that disagrees")
and the:depends-on/dependency-roots.lisp/package.lisp wiring were all
independently re-verified correct, no other issues found) turned this up
as the one genuine, fixable finding.nix flake check --no-buildnow
reports zero warnings besides the expected "dirty tree"/"omitted systems"
ones. -
%js-make-regexwas exported twice fromsrc/package.lisp— once (stray)
under the "Class / accessor helpers" heading between%js-accessorand
%js-assign-pattern, and once (correctly placed) under "RegExp (ES2015+
native engine)" alongside%js-regexp-p/%js-regex-exec. Found while
reconcilingdocs/src/index.md's "320 exported symbols" claim against an
actual count — the raw symbol list had 321 entries but only 320 unique
ones, anddocs/src/api-reference.md(320###entries, one per unique
exported symbol) was already correct, confirming the doc's "320" was right
and the duplicate export was the bug. Removed the stray copy. Also fixed
docs/src/installation.md's dependency-resolution env-var table, which
still listed only the original 9 entries and said "sets all nine" — missing
thecl-date-kit/cl-json-kit/cl-concurrent-kitentries this session's
three dependency adoptions added; now lists all 12 and updated the
introductory paragraph to note that some of these are direct dependencies
of this frontend, not only transitive ones pulled in viacl-cc. No
behavior change either way — verified vianix build .#checks.aarch64-darwin.defaultand.#checks.aarch64-darwin.docs. -
js-rt-url-search-params-set-appends-new-key(t/runtime-builtins- platform-test.lisp):URLSearchParams#seton a key not already present
behaves like.append()per spec — the existing.set()tests only
exercised overwriting an already-present key (including the
collapse-duplicates case), never this branch of
%js-url-search-params-replace-first(extracted from.set()earlier
this session). Found while checking this session's own extracted helpers
for real test coverage, not just an unchanged aggregate pass count. -
A
try { } catch { }(ES2019+ optional catch binding — no(e)parameter)
case added tojs-e2e-try-catch-finally's test table
(t/e2e-core-test.lisp). This exercises%js-build-catch-dispatch's
bindless-catch branch (the(make-ast-progn :forms body)path, as opposed
to the(make-ast-let ...)path when a catch variable is present) — a
grep across the wholet/tree found no prior test with a barecatch {
anywhere, despite the parser/lowering explicitly supporting it
(%js-parse-catch-binding's own docstring: "VAR-SYM is nil for a bindless
catch {}"). A real end-to-end coverage gap dating back well before this
session'sjs-parse-try-stmtextraction (the original, un-extracted
function had the identical branch, equally untested) — passes, confirming
this was an unexercised-but-correct path rather than a hidden bug.
Verified vianix build .#checks.aarch64-darwin.default: 1320 passed
(unchanged — this case joined an existing batch test rather than adding a
new standalone one), 0 failed. -
js-e2e-generator-return-and-throw(t/e2e-advanced-builtins-test.lisp):
explicit.return()/.throw()calls on a suspended generator — genuinely
untested anywhere in this suite before now (a grep for.throw(/.return(
or the string literals"throw"/"return"across all oft/found
nothing generator-related), despite%js-make-generator's coroutine
channel being rewritten ontocl-concurrent-kitthis same session. Two of
the three cases found the real*standard-output*bug documented in the
### Fixedentry above — this is what found it, not a targeted hunt for
it. -
js-parser-decorators-with-nested-call-arg(t/parser-stmt-misc-test.lisp)
checks the rest-stream position after a decorator whose argument is itself
a call — the pre-existingjs-parser-decorators-with-argstest only
checks the parsed argument count, never the returned rest-stream, which is
exactly what let the parser-corruption bug documented in the### Fixed
entry above go unnoticed. -
A class-method case added to
js-e2e-class-getters-setters
(t/e2e-advanced-test.lisp) checking that a method after one with a
nested-call default parameter value still parses and runs — the same
"check the tests actually assert on what follows, not just the immediate
parse result" gap as the decorator entry above, and what caught the
### Fixedentry directly below it. -
js-e2e-class-static-blocks(t/e2e-advanced-test.lisp): the first tests
anywhere in this suite for ES2022static { ... }class initialisation
blocks, despite the feature being explicitly listed as supported in
src/parser-class.lisp's own header comment — this is what found the
"never actually runs at all" bug documented in the### Fixedentry
below. -
js-e2e-private-fields-and-methods(t/e2e-advanced-test.lisp): the first
tests anywhere in this suite that actually call a private method or read/
write a private field through real execution, rather than only checking
that#namesyntax parses into class slots — this is what found the
broken/not-actually-private private-methods bug documented in the
### Fixedentry below (private fields turned out already correct). -
Two more cases in
js-e2e-private-fields-and-methods
(t/e2e-advanced-test.lisp) coveringstatic #name()private methods
specifically, added proactively right after fixing the instance-method
version above, on the hypothesis that the same missing-privacy bug would
exist in the static path too — it did, confirmed by these cases failing
against the pre-fix code with the identical error before any fix was
attempted; see the corresponding### Fixedentry below. -
src/runtime-temporal.lisp(398 lines, above the org's 300-line file
guideline — its siblingsruntime-temporal-duration.lisp/-parse.lisp/
-global.lispare 71/31/144 lines) split by concern:%js-temporal-plain- time/%js-temporal-plain-datetime/%js-temporal-zoned-datetime— the
three Temporal types that carry a time-of-day component — moved to a new
src/runtime-temporal-datetime.lisp(133 lines).runtime-temporal.lisp
keeps the shared helpers, IANA time-zone support,Temporal.Now, and the
types with no time-of-day field (Instant,PlainDate,PlainYearMonth,
PlainMonthDay), now 281 lines. Also fixed a stale comment in the original
file claimingPlainYearMonth/PlainMonthDay"live in
runtime-temporal-duration.lisp" when they were defined right there in the
same file the whole time. No behavior change — a pure move,nix build .#checks.aarch64-darwin.defaultstill reports 1317 passed, 0 failed. -
src/parser-class-lower.lisp(400 lines, above the org's 300-line file
guideline, and grown further by this same session's bug #4/#5/#6 class
fixes above) split by concern into classification vs. lowering: the
slot/key accessor helpers (%js-slot-method-p,%js-slot-to-method- lambda,%js-super-ref,%js-wrap-method-super,%js-class-member-key,
%js-class-member-key-ast) and the eight%js-class-*-slotspredicates
that partition a class's parsed member list by role (constructor, public/
private instance methods, public/private static methods, static fields,
instance fields) moved to a newsrc/parser-class-lower-classify.lisp
(answers "which slot is this," no%js-make-class-argument building) —
parser-class-lower.lispkeeps the per-role lowering functions (turning a
classified slot into%js-make-classarguments),%js-lower-class-to-ast,
and the publicjs-parse-class-declentry point, now 220 lines. Three
stale filename references in doc comments (parser-class.lisp,
runtime-property.lisp,t/e2e-advanced-test.lisp) pointing at functions
that moved were updated to match. No behavior change — a pure move,
verified vianix build .#checks.aarch64-darwin.default: 1326 passed
both before and after, 0 failed. -
Real
String.raw/tagged-template-.rawcoverage:js-e2e-string-static- methods(t/e2e-advanced-builtins-test.lisp) gained two cases checking
actual raw (escapes-untouched) output, replacing a comment that had
explained why it was "intentionally not covered";js-e2e-tagged-templates
(t/e2e-advanced-test.lisp) gained two cases where a CUSTOM tag function
(not the String.raw built-in) reads.rawdirectly, proving the fix is
the real general TC39 protocol and not a shortcut special-cased to one
built-in. Newjs-e2e-array-extra-properties
(t/e2e-advanced-builtins-test.lisp) coversarr.foo = 1/'foo' in arr/
.length/method-still-works-after — the more general capability the
.rawfix needed and that turned out to be a genuine, separate,
previously-untested gap; see the### Fixedentry above. Four existing
lexer-internals unit tests int/lexer-test.lisp(lex-template-simple,
-escaped-cook,-interpolated,-nested-interpolation) updated for the
template lexer's new(:text cooked raw)part shape (previously a bare
cooked string) — a legitimate representation-change update, not a
weakened assertion; each still checks the same cooked value plus a new
raw-value assertion. -
js-rt-array-flat-infinity-depth(t/runtime-array-test.lisp): the first
test forarr.flat(Infinity)— the idiomatic "flatten however deep it
goes" call — which had never been exercised with anything but a small
integer depth. Written to check a specific hypothesis (found while
auditing test coverage across common Array methods after thesort/
undefinedbug below): thatInfinitymight reach%js-array-flat's
depth countdown as the SEPARATE:js-infinitykeyword sentinel some other
code paths use, which would signal a realTYPE-ERRORon(plusp d). The
hypothesis was wrong before any code was touched —Infinitythe global
identifier resolves to*js-inf-float*, a genuine IEEE-754 double-float
bit pattern (src/runtime.lisp), whichplusp/1-handle correctly as
an ordinary (if unusual) number — confirmed by writing the test with the
CORRECT value and it passing cleanly, not by reasoning alone. No bug, no
code change; a real regression test for previously-untested-but-correct
behavior, and a documented reminder that this codebase has two distinct
"infinity" representations for different purposes, easy to conflate. -
Two new cases in
js-e2e-object-static-methods(t/e2e-modern-test.lisp):
a 5-key object literal checking that string-key insertion order survives
past 2 keys (it does), andObject.keys({2:'b',foo:'bar',1:'a'})checking
the array-index-keys-sort-numerically-first ordering rule — the case that
found (and, combined with a unit-levelnix buildrun against the fix, is
what verified) the two### Fixedentries directly above. -
A third case in
js-e2e-for-in(t/e2e-advanced-builtins-test.lisp)
checking the same array-index-keys-first ordering rule through
for...in— the case that found the%js-for-inbug documented in the
### Fixedentry above. -
Two new tests in
t/runtime-json-test.lisp:js-rt-json-stringify- object-key-order(exact-string check, not the pre-existing substring-
inclusion style, which can't distinguish "order was applied" from
"nothing needed reordering") andjs-rt-json-stringify-object-getter- property— the two cases that found theJSON.stringifybugs
documented in the### Fixedentry above. -
Two new cases in
js-e2e-regex-string-methods(t/e2e-modern-test.lisp):
"2023-01-15".split(/(-)/)(the classic capturing-group splice example)
and an optional non-participating group splicingundefined— the cases
that found theString.prototype.splitbug documented in the### Fixed
entry above. -
A fourth
cl-weaveit-propertytest,js-rt-json-stringify-parse- roundtrip-property(t/runtime-json-test.lisp), assertingJSON.parse (JSON.stringify(v)) = vfor arbitrarily nested trees (up to 3 levels,
built viagen-recursive) of every JSON-representable JS value kind —
numbers, strings, booleans, null, arrays, and objects — rather than the
one hand-picked flat{name, age}object the existing
js-rt-json-roundtripexample covers. Uses cl-weave's:to-equalp
matcher (CLEQUALP), not the more common:to-equal(CLEQUAL) —
EQUALonly compares general vectors/hash-tables by object identity, so
it would have silently accepted a completely wrong roundtrip;EQUALP
recurses into both, which is what a structural-equality property
actually needs. Extends this codebase's still-sparse use of property-
based testing (4it-propertytests total now, up from the 3 documented
earlier in this file, plus one separateit-fuzztest) with a fourth,
chosen because the JSON engine was
itself swapped tocl-json-kitearlier this session and previously had
no test exercising structural nesting beyond one level. -
%js-iterator-zip/%js-iterator-zip-keyed(src/runtime-collections-zip.lisp) had
identical 4-line terminal decision logic — done vs."strict"-mode type error vs.
"shortest"-mode truncation vs. wrap-and-continue — differing only in whether the
collected row was an array or a keyed object. Extracted to a shared
%js-zip-finish-stephelper; each call site now differs only in what container it
passes through asrow-or-result. No behavior change — verified vianix build .#checks.aarch64-darwin.defaultat an unchanged 1336 passed / 0 failed. -
Split
t/e2e-advanced-test.lisp(506 lines, one over the org's 500-line cap — grown
past it incrementally over several sessions' regression tests) at its existing
"Class features" section boundary into itself (240 lines: optional chaining through
standalone global builtins) and a newt/e2e-advanced-builtins-test.lisp(280 lines:
class features, contextual keywords/Symbols/globals, for-in/of, generators, and every
builtin static-method family). Pure move, verified vianix build .#checks.aarch64-darwin.defaultat an unchanged 1336 passed / 0 failed. Also fixed
while auditing this:docs/src/architecture.md's file/line-count table and "Known
issue: the duplicate definition in cl-cc" section had drifted stale (93 files → 95,
packages/cl-cc-javascript/→ the actualpackages/javascript/, "46 differ" → a
freshly re-verified 71-of-88-common-files-differ plus 7 files unique to this checkout
and 1 unique to the monorepo copy, re-checked by actually cloning the pinnedcl-cc
commit and diffingsrc/against it rather than trusting the old numbers). -
flake.lock: adding thecl-json-kitinput moved several other floating-tag inputs'
locked revisions forward too (cl-boundary-kit,cl-cli,cl-dataflow,
cl-nix-forge,cl-parser-kit,cl-prolog,cl-tty-kit,cl-weave) —nix flake update cl-json-kit(the correctly-scoped, non-deprecated form;--update-inputwarns
it is now just an alias for a fullflake update) still re-resolved every ref-pinned
input, not only the one named. Each of those tags'urlinflake.nixis unchanged;
only the commit each tag currently points to moved, which for a set of siblings this
actively developed (every one tagged within roughly the same week) plausibly reflects
a tag that was re-pushed upstream after this repo's lock file was first written, not
a version bump this repo asked for. Accepted rather than fought afternix build .#checks.aarch64-darwin.defaultcame back green against the refreshed lock — the
correctness contract is "these pinned tags build and pass," not "these exact commit
hashes never move without aflake.nixedit," andnix flake update <name>appears
unable to scope more narrowly than this in the pinnedcl-nix-forge-era Nix. Worth
knowing before assuming a future single-input update touched only that input. -
9 hand-rolled
(let ((signaled nil)) (handler-case ... (js-exception () (setf signaled t))) (expect signaled :to-be-truthy))blocks, acrosst/runtime-collections- iterators-test.lisp(6),t/runtime-method-resolver-dispatch-test.lisp(1), and
t/runtime-collections-values-test.lisp(1), replaced withcl-weave's own
expect-rejects+:to-be-instance-of—(expect-rejects (lambda () ...) :to-be-instance-of 'cl-cc/javascript:js-exception).expect-rejectswas previously
unused in this test suite despite fitting this exact pattern natively: unlike
signals(see thefeedback-cl-weave-signals-error-onlynote — hardcoded to catch
onlyerrorsubtypes, whichjs-exceptionis not),expect-rejects's own
implementation (call-rejecting-expectation-thunk,cl-weave/src/expect-runtime.lisp)
catches plain CLcondition, so it works correctly against this codebase's own
non-errorjs-exceptionwith no adapter or workaround needed. Also asserts more
precisely than before — the old pattern'shandler-casehad exactly one clause
(js-exception), so a wrong-type condition would propagate uncaught straight through
letand crash the whole test-runner process rather than fail just that test (the
same failure modefeedback-cl-weave-signals-error-onlydescribes forsignals);
expect-rejectscatches anyconditionfirst and only then applies the
:to-be-instance-ofmatcher, so a wrong-type rejection becomes a normal, readable
assertion failure reporting what was actually thrown, not a process crash. Pure
test-code refactor — verified
vianix build .#checks.aarch64-darwin.defaultat the same 1333 passed / 0 failed. -
cl-parser-kit(transitive-only:cl-cc's own build resolves it foroptimize's
e-graph rules,cl-cc-javascriptnever imports it directly)v1.0.1→v1.0.2:
two upstream bug fixes (a dropped:positiononparse-pratt's token-limit failure
path, and a decimal-literal tokenizer overflow on very long integer parts), no public
API change — checked againstcl-parser-kit's ownCHANGELOG.mdbefore bumping, per
this file's existing per-dependency-pin convention. Audited every other pinned
nerima-lisp/*input's tags (git ls-remote --tags) againstflake.nix's current
pins this session:cl-weave,cl-prolog,cl-dataflow,cl-boundary-kit,
cl-cli,cl-tty-kit,cl-date-kit, andcl-nix-forgewere all already at their
latest tag.cl-log-kit(v1.0.0, latestv2.0.0) was deliberately left alone —
flake.nix's own comment on that pin already explains it's transitive-only and
needs its ownscripts/dependency-roots.lispwiring evaluated on its own merits
before bumping, a prior decision this session found no new reason to revisit.cl-cc
stays pinned to a specific commit (not a tag) for the documented reason already in
flake.nix: its ownv0.1.0release's test suite currently fails (55 failures, 31
errors), so the commit pin is the actually-working reference, not an oversight. -
paredit fix applysweptt/*.lisp(never covered by the earliersrc/*.lisp
sweep above) with the same rule set and--no-destructive-fixessafety flag: 51
rewrites across 17 files — mostlysign-comparison((= 0 x)→(zerop x),
(< x 0)/(> x 0)→(minusp x)/(plusp x)), plusredundant-let-star(a
single-bindinglet*has no binding to depend on the others) and
redundant-progn/de-morgan/one-step-arithmetic/nil-comparisonsingles. Given
the prior sweep's quote/backquote-stripping incident (see the entry below), every
hunk was read by hand before applying and re-checked withparedit inspect lint --category malformedafter (0 findings) — none of the 51 touch either oft/'s two
defmacroforms (t/e2e-core-test.lisp'sdeftest-js-run/deftest-js-run-isolated- batch,t/runtime-builtins-promises-test.lisp'swith-fresh-js-module-exports) or
any quoted/backquoted template; every rewrite lands in an ordinaryit-sequential
test body or plaindefun. Pure mechanical cleanup — verified vianix build .#checks.aarch64-darwin.defaultat the same 1333 passed / 0 failed as before. -
Split
src/runtime-regex.lisp(its data tables, matcher-closure combinators, and
character-class parser — everything%js-compile-pattern's recursive-descent parser
assembles but doesn't itself decide the control flow for) into a new sibling
src/runtime-regex-combinators.lisp, leavingruntime-regex.lispholding only
%js-compile-patternitself. The regex work above had grown the file to 448 lines,
the largest non-defpackagesource file in the tree; the split follows this
codebase's existing "core + helpers" convention (runtime-array-core/
runtime-array-transforms,parser-class/parser-class-helpers, ...) rather than
introducing a new one. Pure reorganization — verified vianix build .#checks.aarch64-darwin.defaultat the exact same 1332 passed / 0 failed as
immediately before the split. Two stale file-location references caught and fixed
while at it: a comment insrc/runtime-ops.lispclaiming%js-make-regexlives in
runtime-regex.lisp(it's always been inruntime-regex-api.lisp, predating this
split) anddocs/src/api-reference.md's%js-regexp-pentry, which did move. -
paredit inspect lint/fix applysweptsrc/*.lispfor its full mechanical-fix
rule set — 130 rewrites across 45 files (sign-comparison,cons-to-list,
negated-if,nil-comparison,format-to-string,one-step-arithmetic,
de-morgan,explicit-nil-return,list-star-to-cons,redundant-if-nil,
redundant-progn,if-to-or,if-to-unless,redundant-let-star,
negated-when-unless,redundant-body-progn,redundant-funcall,
single-value-bind,constant-if-test), followed byparedit edit format --write
on every touched file to clean up the branch-reordering rewrites' line-wrapping,
plus a scope-awareparedit refactor rename-binding(setq→setq-astin
src/parser-expr.lisp's%js-lower-logical-assign, a local variable shadowing the
CL special-operator name closely enough that it triggered two of the lint tool's
ownsetf-arity/setq-non-variablefalse positives). Performance/security/
concurrency findings (quadratic-accumulation,eval-of-non-constant,
implementation-package-symbol, and so on) need human judgment and were correctly
left out of the auto-fix set — not applied, not otherwise addressed this pass.
Six of the 130 mechanical rewrites were wrong, silently dropping aquoteor
backquote from macro-template code that a purely syntactic rewrite couldn't
distinguish from live code — caught by the mandatory full-suitenix build
(four as hard compile errors: "the variable I is unbound" from aone-step-arithmetic
rewrite of a quoted'(- i 1)template indefine-js-array-reducer
(src/runtime-array-core.lisp); "comma not inside a backquote" from
redundant-progn/redundant-body-prognstripping the backquote off a`(progn (defun ...) (defun ...))macro body indefine-js-map-like-get-or-insert
(src/runtime-map.lisp),define-js-weak-membership-ops
(src/runtime-weak-collections.lisp), anddefine-js-error-subclasses
(src/runtime-class.lisp); one silently producing wrong runtime behavior with no
compile error at all —define-js-type-resolver's (src/runtime-method-resolver- core.lisp),@(loop ...)splice lost its,@, turning acondclause list built
fromspecial-propsinto one malformed clause, which actively broke.length/
.sizeresolution for Array/String/Map/Set (every one of those types passes a
non-emptyspecial-props) until reconstructed by hand; and one dropped a necessary
prognin anifthen-branch in%with-private-ht(src/runtime-class.lisp),
whereif— unlike aloop'sdoclause or a function-call argument list, both of
which natively splice multiple forms — has a fixed two-or-three-argument arity. All
six were found by first tracing the specific compile failure to its rule, then
(once the failure mode recurred across different rules) exhaustively reading every
one of the 31defmacrobodies across the 45 touched files by hand, not by
trusting the tool's own "fixable"/safe categorization. Verified clean afterward via
nix build .#checks.aarch64-darwin.default(1315 passed, matching the pre-batch
baseline exactly — same count, 0 failed, 0 errored). See thefeedback-paredit- lint-quote-strippingsession note for the full forensic detail; this is the kind
of thing worth knowing before trusting a bulkparedit fix applyin a
macro-code-generation-heavy codebase like this one'sdefine-js-*family again. -
%js-array-group,%js-array-group-to-map,%js-object-group-by, and
%js-map-group-by(src/runtime-array-es2023.lisp,
src/runtime-object-ops.lisp,src/runtime-builtins-globals.lisp) each
repeated the same "find this key's bucket array, lazily creating it if this
is the key's first item, then push" branch against their own container API
(hash-table vs js-map). A new CPS-style%js-group-into
(src/runtime-array-core.lisp, next to%js-make-array, which every
caller's bucket-creator still uses directly) unifies that shared part: each
caller supplies aniterate-fnthat walks its own source with its own
key-fn arity/coercion (the two Array methods use an index loop and a 3-arg
key-fn;Object.groupBy/Map.groupByuse%js-for-ofand a 2-/1-arg
key-fn) and calls avisitcontinuation with(key item), plus a
get-bucketcontinuation that returns — creating first if needed — the
bucket array for a key. What stayed different (iteration mechanism, key-fn
arity, string-coercion, container type) stayed different; only the
duplicated get-or-create-then-push shape moved into one place. -
%js-date-time-format-option-stringand%js-number-format-option-integer
(src/runtime-builtins-intl-date-time-format.lisp/
-intl-number-format.lisp) shared the same "missing means DEFAULT, present
means coerce" shape, found viaparedit inspect similarity— now both call
a new%js-intl-coerced-option(src/runtime-builtins-intl-core.lisp, next
to%js-intl-optionwhich it wraps), each supplying only its own coercion
function (#'%js-to-string/ a integer-clamping lambda). -
13 of
t/*.lisp's 21 hand-rolled(let ((sig nil)) (handler-case (progn body) (COND () (setf sig t))) (expect sig :to-be-truthy))exception-testing sites — the
ones catching plainerror— now use cl-weave's ownsignalsmacro:
(signals error body). Found viaparedit query findwith a structural pattern
((let ((?sig nil)) (handler-case (progn ?body...) (?cond () (setf ?sig t))) (expect ?sig :to-be-truthy))), converted withparedit query replace, across
t/parser-stmt-module-test.lisp,t/parser-stmt-pattern-internals-test.lisp,
t/runtime-builtins-platform-object-test.lisp,t/runtime-builtins-platform-test.lisp,
t/runtime-json-test.lisp, andt/runtime-string-number-test.lisp. The other 8
sites (catchingcl-cc/javascript:js-exception) were converted too on the first
pass, then reverted:js-exceptionis a plaincondition, not anerrorsubtype,
and cl-weave'ssignals/:to-throwonly ever catcheserror
(cl-weave/src/matcher-runtime.lisp'sthrown-conditionhardcodes(handler-case ... (error (c) c))) — so those 8 sites crashed the whole test runner instead of
failing one test the first timenix buildran ("Unhandled
CL-CC/JAVASCRIPT:JS-EXCEPTION in thread ... main thread"), caught by the mandatory
full-suite verification step and reverted to the originalhandler-caseform.
Recorded as a cl-weave constraint worth knowing before attempting this again. -
%js-compile-pattern'scompile-atom/compile-seq(src/runtime-regex.lisp) no
longer repeat two pieces of logic inline at every call site: the case-insensitive
character read ((if ic (char-downcase (char str i)) (char str i)), previously
written out identically at three separate atom-matcher sites) is now
%js-regex-char-at, and the near-identical greedy-loop bodies inside the*/+
quantifier wrappers are now one shared%js-regex-greedy-match—*calls it
directly (zero or more matches),+calls it only after its one required match
already succeeded (one or more). Also dropped a stale(declare (ignore groups))
on the*wrapper's lambda that was inaccurate even before this change (the loop
it wrapped already passedgroupsthrough tofn). Behavior-preserving —
verified vianix build .#checks.aarch64-darwin.default(1313 passed) — the
quantifier and case-insensitive-flag tests int/runtime-regex-test.lispalready
covered every call site touched. -
src/macros.lisp(new, loaded straight afterpackage) holds
define-builder-table, which replaces the hand-written
let/dolist/setf (gethash ...)block that eight dispatch tables each
carried their own copy of:*js-keywords*,*js-op-infix-prec*,
*js-direct-binop-keywords*,*js-binop-runtime-helpers*,
*js-coercion-call-helpers*, the two unary builder tables, and the Map and
RegExp constructor globals. Only the data is left at each call site. -
lex-js-operatoris a maximal-munch lookup in a new*js-operator-tokens*
table rather than a 15-armcaseover the first character with the 2-, 3-
and 4-character checks written out by hand. Every JS operator spelling now
appears exactly once, as data. -
lex-js-number's three radix branches (0x,0o,0b), which differed only
in a digit reader and a radix, became one lookup in*js-radix-prefix-readers*. -
The base64 arithmetic is shared:
%js-base64-encode-bytesand
%js-base64-decode-bytesback bothatob/btoaand
Uint8Array.toBase64/fromBase64, which previously carried independent
copies of the same RFC 4648 bit shuffling and=padding rules. -
with-js-loop-tagsestablishes the loop-control specials for all five loop
parsers (while,do-while,for,for-in,for-of), which each spelled
out the same six bindings. -
%js-comma-list-step,%js-chain-step,%js-parse-as-alias-specifiers,
%js-array-relative-start/-end,%js-regex-escape-literal,%js-notand
js-at-op-peach replace a block that had been open-coded in three or more
places.js-parse-import-specifiersandjs-parse-export-specifiersalso
lose acondwhose two branches had become identical, and now share their
entire specifier-list loop (not just onename [as alias]pair) through
%js-parse-as-alias-specifiers's builder-callback parameter. -
define-js-type-resolvernow generates%js-resolve-promise-method,
%js-resolve-number-methodand%js-resolve-weak-ref-method, the three
resolvers that were still written out by hand, and
define-js-weak-membership-opsgenerates the WeakMap and WeakSet
has/deletepairs. -
Test files are named
t/<source>-test.lispafter the source file they cover,
perCODING_STANDARD.md. The redundantjs-prefix is gone (no source file
carries it) and the plural-testsis now singular, sojs-lexer-tests.lisp
islexer-test.lisp. Three names that did not identify a source file were
changed to ones that do. No test content changed. -
src/package.lispno longer:usescl-cc/ast,cl-cc/bootstrapor
cl-cc/parse; the 55 symbols actually borrowed are now listed in
:import-from. The exported symbol set is unchanged. -
The test system moved from a separate
cl-cc-javascript-test.asdinto
cl-cc-javascript.asdascl-cc-javascript/test, and bothdefsystemnames are now
strings rather than keywords. -
Test sources moved from
tests/tot/, and the test entry point from
scripts/run-tests.lisptorun-tests.lispat the repository root. -
flake.nixnow derives the package version from the:versionform in
cl-cc-javascript.asdinstead of hardcoding it, tracksnixos-unstablerather than
nixpkgs-unstable, and pins every sibling input to a release tag exceptcl-cc. -
systemsnarrowed tox86_64-linuxandaarch64-darwin.aarch64-linuxand
x86_64-darwinwere declared but never built or tested by anything. -
.asdmetadata completed to the eight required fields, with:authorand
:maintainerset to the canonicaltakeokunn <bararararatty@gmail.com>. -
flake.nixadopts cl-nix-forge (pinned
v0.4.0) for the two pieces that fit without forcing an adapter:fromAsdSystem(the
:versionsingle source of truth, replacing a hand-rolled regex) andmkDocsSite
(the MkDocs Material site, byte-for-byte the same derivation this file used to
hand-roll).mkPackageFlakeand thelispDerivationdependency-graph model it is
built on were deliberately NOT adopted for the main package/checks/devShell: this
repository's production system depends on fourcl-ccsub-systems that only exist
becausecl-cc.asdregisters them as a load-time side effect, a bootstrap step
lispDerivation's fixed(asdf:load-system ...)build phase has no hook to express.
Every hand-rolledtimeout Ninflake.nixis nowtimeout --kill-after=30 N,
matching cl-nix-forge's own documented grace-period default. -
Seven sibling dependency pins bumped after individually confirming no breaking change
from each release's own CHANGELOG.md:cl-weavev1.0.0→v1.1.0,cl-prolog
v1.0.1→v1.1.0,cl-parser-kitv1.0.0→v1.0.1,cl-dataflowv1.0.0→v1.1.0,cl-cli
v1.0.1→v1.1.0,cl-tty-kitv1.0.0→v1.0.3,cl-boundary-kitv0.6.0→v1.0.0.
cl-log-kitstays at v1.0.0: its v2.0.0 adds three new runtime dependencies
(cl-date-kit,cl-concurrent-kit,cl-host-kit) not wired into this repository's
flake, a real breaking change rather than a safe bump. -
Five test files that had grown past the org's 500-line cap (
runtime-builtins-test.lisp
695 lines,runtime-collections-test.lisp669,runtime-date-json-test.lisp594,
parser-stmt-test.lisp538,runtime-method-resolver-test.lisp527) split into 18
focused files along their own existing section boundaries, each under 300 lines. No
test content changed; the.asdcomponent list was updated in place. -
Several exact/near-exact code duplications consolidated into shared helpers, each
verified behavior-preserving by the full test suite: base64 encode/decode (previously
duplicated between the globalbtoa/atobandUint8Array.toBase64/fromBase64),
for-in/for-ofstatement parsing,yield/yield*expression parsing (previously
byte-for-byte duplicated across two parser files), the Temporaladd/subtract
encode-shift-decode shape, and the regex/g-flag match-collection loop. -
Both
defpackageforms (src/package.lisp,t/package.lisp) now use#:designators
throughout — matching the org standard several sibling packages already conform to —
instead of a mix of:(keyword, interns into theKEYWORDpackage as a side effect)
and#:(uninterned symbol) for the package name,:use,:import-from's package
name, and:shadowing-import-from.src/package.lisp's body already used#:
consistently; only its own package name on line 1 was a straggler.t/package.lisp
mixed both styles within the same form. -
%js-compile-pattern's (src/runtime-regex.lisp) mutually-recursive
compile-atom/compile-seq/compile-altclosures moved from a
forward-declare-then-setfidiom toLABELS, which supports mutual recursion
natively — no signature or behavior change, just direct calls
((compile-alt pos)) instead of(funcall compile-alt pos)on a variable. -
js-parse-primary's six near-identical constant-literal branches (true/false/
null/undefined/this/super) now dispatch through a data table
(*js-primary-constant-builders*) plus a CPS-styleconsume-and-buildhelper,
mirroring the existing*js-unary-kw-builders*precedent in
parser-expr-unary.lisp.js-parse-import-specifiers/js-parse-export-specifiers's
duplicated{ name [as alias], ... }parse loop is now one CPS-style shared loop
(%js-parse-as-alias-specifiers) taking a per-caller builder callback — collapsing a
deadcond(both branches did the same thing) along the way. -
scripts/run-coverage.lispalso emitscoverage.lcov(best-effort — see Known
issues) alongside the existing HTML report.
Known issues
-
SB-COVER:LCOV-REPORT(SBCL 2.6.0) signals an internalTYPE-ERRORon this
codebase's coverage data (confirmed via backtrace to be inside SB-COVER's own code,
not this repository's).run-coverage.lispcatches it and continues — the HTML
report still builds — socoverage.lcovandscripts/coverage-summary.lisp's
aggregate percentage are unavailable until this is fixed upstream or the pinned SBCL
moves past 2.6.0. -
Date-time string parsing always assumes UTC, never local time. A date-TIME string
with no explicit timezone suffix should parse as the host's LOCAL time (only a
date-ONLY string is UTC), and an explicitZ/+HH:MM/-HH:MMsuffix is currently
ignored entirely if present —%js-date-parse-string(src/runtime-date.lisp)
always encodes viaencode-universal-time's zone fixed at0(UTC) regardless of
which ISO form was given. A correct fix needs the same host-timezone projection
Date.prototype.getTimezoneOffset/the Temporal runtime already use, applied
conditionally on the parsed string's own shape. Seedocs/src/compatibility.md. (The
sibling "no Invalid Date representation" gap this entry used to also describe is
FIXED — see the### Fixedsection.)
Fixed
Datehad no "Invalid Date" representation at all — any NaN-producing path
(new Date(NaN),new Date("garbage"),new Date(2020, NaN, 1),d.setTime(NaN),
d.setFullYear(NaN), ...) crashed the whole process with a real
FLOATING-POINT-INVALID-OPERATION, not a graceful JS NaN result. Found while
investigating the (initially assumed lower-severity, deferred) "date-time parsing
assumes UTC" gap below — writing a diagnostic test fornew Date(NaN).getTime()
turned up an actual crash, not just wrong output, which raised this from "documented
gap" to "fix now."js-date's millisecond slot was declared:type integer
(src/runtime-date.lisp), structurally incapable of holdingNaN— everyTRUNCATE/
FLOOR/MOD/ENCODE-UNIVERSAL-TIMEcall reachable from a Date constructor, getter,
or setter would trap on a NaN argument instead of the IEEE double-float NaN silently
propagating the way JS expects. Fixed by widening the slot to:type realand adding
%js-date-invalid-p/%js-date-truncate-or-nan— the latter now used at every
component-parsing site (%js-make-date,%js-date-utc,%js-date-set-time, every
%js-date-set-*setter, thedefine-js-date-cascading-settermacro) instead of a
baretruncate, and%js-date-components-to-msitself short-circuits to NaN before
running any of its arithmetic if any component it receives already is NaN.
%js-date-parse-stringnow returns NaN on a parse failure instead of silently
substituting(%js-date-now)(the CURRENT time) — the real bug the original,
lower-severity investigation had found, now fixed alongside the crash. Every getter
(thedefine-js-date-gettermacro,getMilliseconds,getTimezoneOffset) and every
toString-family formatter insrc/runtime-date-methods.lispnow checks
%js-date-invalid-pfirst, returning NaN or the literal string"Invalid Date"
("null"fortoJSON, per spec) instead of decoding a NaN time value. A genuinely
separate, pre-existing bug surfaced while testing this fix, fixed alongside it:
%js-to-string(src/runtime-property.lisp) had no case for ajs-datevalue at
all, soString(date)/template-literal interpolation/string concatenation of ANY
Date (valid or not) fell through to a catch-allPRINC-TO-STRING, dumping the raw
#S(JS-DATE :MS ...)struct instead of callingtoString()— added a%js-date-p
branch delegating to%js-date-to-string. Verified vianix build .#checks.aarch64-darwin.default: 1342 → 1351 passed (a real crash reproduced with a
failing/errored test before the fix, per the established discipline), 0 failed, 0
errored — including setters, getters, both constructor paths, and every
toString-family method exercised against an Invalid Date.console.log/.error/.warncalled directly from inside a generator body
(as opposed to by the driver/consumer after receiving a yielded value — e.g.
a log statement inside afinallyorcatchblock wrapping ayield)
silently wrote to the wrong stream and never appeared in the caller's
captured output.%js-make-generator's body runs on its own OS thread
(src/runtime-generator.lisp); that thread's spawn site
(%js-gen-chan-ensure-started) already captured and rebound the driver
thread'scl-cc/vm:*vm-state*and%js-this— its own docstring explains
why: "a new SBCL thread does not inherit another thread's dynamic
bindings" — but never*standard-output*/*error-output*, so
%js-console-log's(format t ...)(tmeaning "whatever
*standard-output*is right now") resolved to the body thread's own
default stream instead of the one the caller is actually capturing (e.g. a
test's%js-run-capture, or in production, whatever redirection the
embedding application set up). Found by adding the first tests in this
suite to callconsole.logdirectly from within a generator body (see the
js-e2e-generator-return-and-throwentry below) — the two cases exercising
this crashed with an empty captured string instead of the expected
console.log output, not a compile error, so nothing short of an actual
execution assertion would have caught it. This predates this session's
cl-concurrent-kitchannel rewrite entirely: the thread-spawning
architecture is unchanged, only the driver/body hand-off mechanism moved
from mutex/condvar to channels, and the original code had exactly the same
gap. Fixed by capturing*standard-output*/*error-output*alongside
*vm-state*/thisat the same spawn site and rebinding them in
%%js-run-generator-body. Verified vianix build .#checks.aarch64-darwin.default: 1320 → 1323 passed (the 3 new test cases
below — the first two failed against the pre-fix code, confirming this is
a real fix rather than a speculative hardening), 0 failed.@decorator(...)with an argument that is itself a call or bracketed
literal (@dec(foo(1,2)),@dec([1,2])) failed to parse at all — not
merely "the decorator has no effect" (already documented as deliberate),
but a genuine parser corruption breaking the rest of the file.
%js-parse-decorator-args's (src/parser-class-helpers.lisp)
argument-skipping loop scanned for the next comma or)with no nesting
tracking, so it stopped at the nested call's own inner)instead of the
decorator's outer one, leaving the returned rest-stream pointing at a
stray, unconsumed)— everything parsed after the decorator (theclass
keyword, in the test that found this) saw that leftover token first and
failed. Found the same way as the entry above: adding
js-parser-decorators-with-nested-call-arg(t/parser-stmt-misc-test.lisp)
to check the returned rest-stream position (which the pre-existing
js-parser-decorators-with-argstest never did) failed immediately
against the unfixed parser. Fixed by tracking paren/bracket/brace nesting
depth in the skip loop and only treating a comma/)as an argument
separator/terminator at depth 0. Verified vianix build .#checks.aarch64-darwin.default: 1323 → 1324 passed (the one new test
case — failed against the pre-fix code), 0 failed.- The identical unguarded-nesting parse bug as the decorator entry directly
above, in the far more commonly-hit%js-parse-method-params-body
(src/parser-class-helpers.lisp, every class method/getter/setter/
constructor's parameter list): a default parameter value that is itself a
call or bracketed literal (class C{m(a,b=foo(1,2)){...}}) corrupted the
parser position the same way, producingJS parse error: expected :T-LBRACE but got :T-RPARENfor the method body that should have parsed
fine — not a silently-wrong result, a hard failure for the whole class
declaration. Found the same way: adding a class-method case to the
existingjs-e2e-class-getters-settersbatch (t/e2e-advanced-test.lisp)
checking that a second, unrelated method still parses and runs correctly
after one with a nested-call default — this is what caught it, not a
targeted hunt (the search for other instances of the decorator bug's exact
code shape,grep -rn "skip tokens until\|until comma" src/*.lisp, is what
led to checking this function at all). Both this and the decorator fix now
share one%js-skip-balanced-untilhelper (paren/bracket/brace
nesting-depth-aware token skip to a target delimiter set) instead of
duplicating the same tracking logic twice in the same file. Note: the
parameter's default value is still discarded rather than actually
applied at the call site (b's default in the test above never runsfoo
— the whole default-value expression is skipped, not evaluated) — that is
a separate, much larger, pre-existing gap (class methods use a simpler,
incomplete parameter parser than regular functions and arrow functions,
which do apply defaults correctly via%js-split-params-by-defaults
inparser-stmt-fn.lisp), deliberately NOT attempted in this pass; this
fix only stops the parser from corrupting on such a default's syntax.
Documented for a future dedicated pass in theproject_known_gapsmemory
note. Verified vianix build .#checks.aarch64-darwin.default: 1324
passed (the earlier decorator fix's count, since the new class-method test
case had already been added and was failing against the pre-fix parser in
the immediately preceding build attempt — this build is the one where it
first passes), 0 failed. static { ... }class initialisation blocks (ES2022) parsed correctly and
claimed support insrc/parser-class.lisp's own header comment, but never
ran at all — not a subtly wrong result, a complete no-op.
%js-class-static-field-slots(src/parser-class-lower.lisp), the only
place that threads a static member's initform into the class's
%js-make-classcall, filtered for:js-member-kind :fieldspecifically;
a static block's slot was tagged:js-member-kind :static-block(a
distinct kind, correctly, since it isn't a real named field) and simply
never matched, so it was silently excluded — its initform (the parsed,
real, executable block body) was built and then discarded, exactly the
class of bug this codebase's ownremove-unused-definitions-driven
investigations elsewhere in this file kept finding: correct code that
nothing ever calls. Found by adding the first tests anywhere in this suite
for static blocks (js-e2e-class-static-blocks,t/e2e-advanced-test.lisp)
— a coverage gap noticed after finding the two nesting-depth bugs directly
above, not a targeted hunt for this specific issue. Also found, separately:
the static-block slot's:importsnever set:js-static tat all (hand-
built inline instead of going through the%js-member-kind-metadata
helper every other class member uses), which would have kept it excluded
even after widening the:js-member-kindfilter. Fixed both: the slot now
uses%js-member-kind-metadata, and%js-class-static-field-slots
accepts:static-blockalongside:field. A separate, deeper
limitation surfaced while testing the fix and is NOT fixed here:
referencing the class by its own name from inside a static field or static
block initializer (class C { static { C.x = 1; } }) signals an unbound-
variable error, because static field/block initforms are evaluated as
plain argument expressions to%js-make-classitself — before its result
is ever bound to the class's name — unlike a method body, which is a
lambda invoked lazily after the binding exists (this is exactly why the
pre-existingjs-e2e-static-fields/js-e2e-class-self-referencetests,
which do reference the class by name, only ever do so from inside method
bodies). The new tests observe a static block's side effect through an
outer variable instead, sidestepping this separate issue rather than
fixing it; documented indocs/src/compatibility.mdand the
project_known_gapsmemory note for a future pass. Verified vianix build .#checks.aarch64-darwin.default: 1324 → 1325 passed (failed with
"Unbound global variable: C" against the first version of this fix using
class-name references, then passed once the tests were rewritten to avoid
that separate issue — both failure and eventual pass confirmed against a
real build, not assumed), 0 failed.- Private methods (
class C { #secret() {...} }) were fully broken and,
worse, not actually private at all.%js-lower-class-method-args
(src/parser-class-lower.lisp) registered every instance method on
__prototype__under its bare name regardless of:js-private, so a
#secretmethod became reachable as ordinary publicobj.secret(its
intended access-control silently defeated) while the correct syntax,
this.#secret(), failed outright —%js-class-private-field-getlooks in
the per-instance__private__table, which a method wired only onto
__prototype__was never added to, so the lookup returnedundefinedand
the subsequent call attempt signalledUndefined function: :JS-UNDEFINED.
Found the same way as the entries above: adding the first tests anywhere
in this suite that actually CALL a private method or read/write a private
field through real execution (js-e2e-private-fields-and-methods,
t/e2e-advanced-test.lisp) — private fields already worked correctly
(no bug there); only private methods were affected, isolated by removing
test cases one at a time until a single-case build reproduced the failure
in isolation, then confirmed by reading%js-class-method-slots's filter
(excludes static/constructor but never checked:js-private) alongside
%js-class-private-field-set's existing per-instance private-field
mechanism. Fixed by excluding private methods from
%js-class-method-slotsentirely (new docstring explains why) and adding
a parallel%js-class-private-method-slots/%js-lower-class-private- method-initsthat stores each private method's closure into the
instance's own__private__table via%js-class-private-field-set,
prepended to the constructor body alongside the pre-existing field inits
(the exact same per-instance-initialization mechanism, extended to cover
methods too — a private method's closure is rebuilt once per instance
rather than shared on the prototype, the natural consequence of using the
private-field storage). The new test suite includes a case specifically
assertingtypeof obj.secret === "undefined"— verifying the fix restores
genuine privacy, not merely that the#name()call syntax works. Verified
vianix build .#checks.aarch64-darwin.default: 1325 → 1326 passed (the
new test batch — the isolated single-case reproduction failed against the
pre-fix code with the exact error above, confirmed via a real build before
attempting the fix), 0 failed. - The identical public-leakage bug as the private instance-method entry
directly above, in static private methods
(class C { static #secret() {...} }).%js-class-static-slots
(src/parser-class-lower.lisp) had the same missing:js-privatefilter
as the instance-method version — a proactive check added right after
fixing that one, not a separately-discovered gap. Fixing this one needed a
different mechanism than the instance case, though: a static method's
closure can't be stored via a constructor-prologue call (there is no
per-instance constructor step for STATIC members — they're set once,
directly on the class object, as part of building it).%js-make-class
(src/runtime-class.lisp) gained a second, optional"@@private-static"
marker after the existing"@@static"one: name/fn pairs following it are
routed through%js-class-private-field-seton the class object itself
(klass) instead of the ordinary(setf (gethash name klass) fn)public
path, soC.#secret()(thisbound toCfor any static method call)
resolves it via%js-class-private-field-getexactly like an instance's
private member does, andC.secretstays genuinely undefined.
%js-lower-class-static-args(already existing, previously only used for
public statics) is reused unchanged for the private-static group too — it
never cared about privacy, only about building(name fn ...)pairs; the
caller now decides which marker section they land in. Verified vianix build .#checks.aarch64-darwin.default: 1326 passed both before and after
this specific fix (the 2 new test cases joined the same existing batch —
failed against the pre-fix code with the identical "Undefined function:
:JS-UNDEFINED" error, confirmed via a real build), 0 failed. String.rawreturned cooked (escapes-processed) output instead of raw
(as-written) text — the deferred, "most user-visible" gap this memory of
the project had flagged as needing "threading a second raw text value
alongside the cooked value through the whole template lexer → parser AST →
tag-function-call codegen path." Fixed 2026-07-31, done properly (not
papered over), and it uncovered two more real bugs along the way.
js-lex-template-text-part(src/lexer-template.lisp) now returns both
COOKED (escapes-processed) and RAW (verbatim source slice) text for every
template literal text segment;js-lex-template's parts-list text
elements changed shape from a bare cooked string to(:text cooked raw).
%js-parse-tagged-template(src/parser-expr-unary.lisp) now lowers a
tagged template's first argument through a new
%js-make-tagged-template-strings(src/runtime-array-core.lisp), which
attaches the raw-strings array to the cooked-strings array's newraw
property (per the TC39 tagged-template protocol) instead of passing the
cooked array alone. Along the way, fixing this surfaced a genuine
second bug this codebase never had a test for:arr.foo = 1on a JS
array silently no-op'd. This runtime represents JS arrays as plain CL
adjustable vectors, which have no slot for arbitrary named properties —
%js-set-prop's vector branch had a bare(t nil)for any non-index,
non-"length" key. Since a tagged template'sstrings.rawis exactly this
same "array with an extra named property" shape, fixing.rawfor real
meant fixing this too, not routing around it: added
*js-array-extra-properties*, a lazily-populated EQ-keyed side table
(src/runtime-property.lisp), and wired it into%js-get-prop/
%js-set-prop/%js-in's vector branches (own extra property shadows an
inheritedArray.prototypemethod of the same name, matching real JS
precedence). A third, independent bug surfaced while verifying the fix
against a real build:%js-string-rawwas defined TWICE — once in
src/runtime-string.lisp(the one this fix edited first) and, unnoticed,
again insrc/runtime-builtins-globals.lisp, which loads LATER in
cl-cc-javascript.asd's:serial torder and silently redefined the
global function, shadowing the fix (and meaningt/runtime-string-number- test.lisp's pre-existingjs-rt-string-raw-tagunit test had, since
wheneverString.rawwas first wired up, always been exercising the
OTHER (duplicate, never-fixed, and now-deleted) definition, not the one
inruntime-string.lispits own file placement suggests). First build
after the lexer/property-system/lowering changes still failed 5 tests
with the OLD cooked-output behavior, which is what led to finding the
duplicate; deleted the stale copy inruntime-builtins-globals.lisp,
keeping the (now-correct) one inruntime-string.lisp. Verified vianix build .#checks.aarch64-darwin.default: 1327 passed, 0 failed (up from
1326 — new tests below), including a real 5-test regression the
duplicate-definition bug caused on the first attempt, caught and fixed
before declaring success.Array.prototype.sort/toSortedcomparedundefinedelements like any
other value instead of always sorting them to the end, per ECMA-262's
SortCompare — found and fixed 2026-07-31 (later session), via a "this
method has zero e2e/undefined-handling test coverage" check, not a
targeted hunt.%js-sort-comparator(src/runtime-array-transforms.lisp)
built either a lexicographic-string or a raw-JS-comparator predicate and
handed it straight tostable-sortwith no special case for
+js-undefined+— so the default comparator placedundefinedwherever
its%js-to-stringvalue ("undefined") happened to fall lexicographically
(confirmed with a real failing test:[undefined,"zebra","apple"].sort()
landedundefinedin the MIDDLE, not last, since "apple" < "undefined" <
"zebra"), and a custom comparator would have been CALLED withundefined
as an argument at all (the spec requires excluding it from comparison
entirely — a numeric comparator like(a,b)=>a-bwould see
%js-to-number(undefined)= NaN and misbehave). Both%js-array-sort
(mutating) and%js-array-to-sorted(ES2023 non-mutating) shared this bug
through the same comparator builder. Fixed with a new shared
%js-array-stable-sort-undefined-last: partitions out every+js-undefined+
element before sorting, stable-sorts only the defined elements, then
appends the undefined elements back at the end — same shape both callers
now use instead of callingstable-sortdirectly. Verified vianix build .#checks.aarch64-darwin.default: 1328 → 1331 passed (a real failing test
reproduced the bug before the fix, per the established discipline), 0
failed, including a case asserting a custom comparator is never even
CALLED withundefined(errors if it is) and real end-to-end.sort()/
.toSorted()JS-source coverage, both previously entirely absent.- Object literals with a non-string-literal key crashed outright — found and
fixed 2026-07-31 (later session), while auditing Object.keys ordering (the
investigation that led to it is documented in the entry directly below).
%js-make-object(src/runtime-object.lisp) coerced each key via CL's
bare(string k), which only accepts a string, symbol, or character and
signals a real type error on anything else — so{2: 'b'}(a perfectly
ordinary numeric-literal object key) crashed with "2 is not a string
designator" instead of building{"2": "b"}the way real JS's ToString
key coercion does, and a computed key whose expression evaluates to a
number/boolean/Symbol ({[1+1]: 'v'}) would have hit the identical crash.
Every OTHER property-key site in this codebase already goes through
%js-to-property-key(%js-get-prop/%js-set-prop, and the spread-merge
path for object literals via%js-object-spread-set→%js-set-prop) —
%js-make-object, used for the plain (non-spread) object-literal case,
was the one holdout still using the wrong coercion. Fixed by switching to
%js-to-property-key, the same one every other path already uses.
Confirmed as a real, reproducible crash with a failing test before writing
the fix. Verified vianix build .#checks.aarch64-darwin.default: 1333
passed, 0 failed, 0 errored (up from 1 errored before the fix). Object.keys/values/entries/for...in/Reflect.ownKeysnever
applied the ES2015+ [[OwnPropertyKeys]] ordering rule — canonical
array-index keys ("0","1","2", ... no leading zeros) must sort
NUMERICALLY ASCENDING ahead of every other own key, regardless of
insertion order. Found and fixed 2026-07-31 (later session), via the same
"does this extremely common, spec-subtle behavior have ANY test coverage"
check that found thesort/undefinedbug above — it had none.
%js-object-own-string-property-keys(src/runtime-object.lisp) just
returned whatever ordermaphashhappened to produce, with no ordering
logic at all — confirmed with a real failing test:
Object.keys({2:'b',foo:'bar',1:'a'})returned["2","foo","1"](raw
encounter order) instead of the spec-required["1","2","foo"]. Fixed by
partitioning the collected keys into a new%js-canonical-array-index-key-p
predicate's two groups, sorting the array-index group numerically, and
placing it first — every other key keeps its prior (encounter) order
after it. The rebuilt result had to stay the same adjustable,
fill-pointered vector shape the function always returned (not a fresh
CONCATENATEsimple-vector) —%js-object-own-property-keys(backing
Reflect.ownKeys)VECTOR-PUSH-EXTENDs Symbol keys onto this same return
value, which a non-adjustable vector can't support; caught this by SBCL's
own compile-time type-derivation warning turning into a real
COMPILE-FILE-ERRORon the first attempt, fixed before declaring success.
Non-array-index string-key insertion order itself was NOT touched (a
separate concern) — empirically confirmed already correct via a 5-key
test ({z:1,y:2,x:3,w:4,v:5}→z,y,x,w,v) added alongside this fix,
even though this runtime's plain CL hash tables have no portable
insertion-order guarantee; worth re-checking if this project's own
representation of objects ever changes. Verified vianix build .#checks.aarch64-darwin.default: 1333 → 1334 passed, 0 failed.for...inhad the identical missing-ordering bug asObject.keysabove,
in its own separate implementation, plus a second real bug: it silently
EXCLUDED getter/setter accessor properties entirely instead of enumerating
them under their real name.%js-for-in(src/runtime-control.lisp)
did its own raw, unorderedmaphashover OBJ's hash table, only
filtering internal keys — confirmed with a real failing test:
for (k in {2:'b',foo:'bar',1:'a'})produced2,foo,1instead of the
spec-required1,2,foo. Separately, real JS enumerates an accessor
property ({get foo(){}}) viafor...inexactly like a plain data
property (object-literal properties, accessors included, are enumerable
by default) — but%js-for-in's own internal-key filter treated the
__get_foo/__set_foostorage keys as opaque internals to skip
entirely, silently dropping "foo" from enumeration rather than
recognizing and translating it, unlikeObject.keys/values/entries
(which already correctly translate accessor storage keys to their real
property name via%js-object-accessor-property-name). Fixed by making
%js-for-inshare%js-object-own-string-property-keys— the same
ordered, accessor-aware key collectorObject.keysalready used —
instead of its own separate, less-correctmaphashloop. This also
fixed the accessor-property gap for free, as a consequence of sharing the
logic, not a separately-scoped change. Updated the one existing test that
had locked in the old (wrong) behavior,
js-rt-for-in-skips-accessor-keys→ renamed
js-rt-for-in-includes-accessor-keys-under-their-real-name, now
asserting "foo" IS enumerated. Verified vianix build .#checks.aarch64-darwin.default: 1334 → 1335 passed, 0 failed.
Deliberately NOT touched:for...in's own docstring already only
promised "each enumerable string key in OBJ" (own keys) — real JS
for...inalso walks the prototype chain, enumerating inherited
enumerable properties, which this implementation still does not do.
Scoped as a separate, likely lower-value gap: real JS class methods are
themselves non-enumerable on the prototype by spec, so the common
"class instance in a for...in loop" case is accidentally unaffected;
only manualObject.create(protoWithEnumerableOwnProps)-style
inheritance chains would actually differ. Seedocs/src/compatibility.md.JSON.stringifyhad the SAME two bugs asObject.keys/for...inabove,
in its own third separate implementation — found by checking whether this
sibling shared the pattern immediately after fixingfor...in.
%js-json-stringify-normalize's object branch (src/runtime-json.lisp)
rebuilt a fresh hash-table via a rawmaphash, filtering internal keys
via%js-internal-key-pand reading each value via the raw stored
GETHASHvalue — confirmed with real failing tests:
JSON.stringify({2:'b',foo:'bar',1:'a'})produced
'{"2":"b","foo":"bar","1":"a"}'instead of the spec-required
'{"1":"a","2":"b","foo":"bar"}'(array-index keys numerically ascending
first), andJSON.stringify({get foo(){return 42}})produced'{}'
instead of'{"foo":42}'— a getter's raw stored value is the accessor
FUNCTION under an internal__get_fookey, filtered out entirely rather
than recognized, invoked, and serialized under its real name. Real JS
reads each own enumerable key through[[Get]], which invokes a getter.
Fixed by replacing the rawmaphash+gethashpair with
%js-object-own-string-property-keys(ordered, accessor-aware — the
same collectorObject.keys/for...innow share) and%js-get-prop
(invokes a getter correctly, unlike a rawgethash) — one coherent fix
for both bugs, not two separate changes, the same shape as the
for...infix directly above. Verified vianix build .#checks.aarch64-darwin.default: 1335 → 1336 passed (ordering fix) →
1337 passed (getter-serialization test also added and passing), 0 failed
throughout, each bug confirmed with a real failing test before its fix.String.prototype.splitwith a regex separator never spliced captured
groups into the result array — a real, previously-undiscovered ES2015+
gap, found by checking whethersplithad any test coverage for the
capturing-group case (it had none; the one existing regex-split test
used a group-free pattern). Real JS:"2023-01-15".split(/(-)/)→
["2023","-","01","-","15"]— each capturing group's matched text (or
undefined, if that group didn't participate in the match) is spliced
into the result right after the field it separates.%js-string-split- regex(src/runtime-regex-api.lisp) called its compiled matcher with
nilfor the GROUPS argument during its separator scan — groups were
never even captured, let alone spliced in. Fixed by allocating a real
groups vector per scan attempt (the same(make-array num-groups :initial-element nil)pattern%js-regex-execalready uses) and, once a
separator match is found, iterating its captured groups into the result
((subseq str (car g) (cdr g))per participating group,+js-undefined+
for a non-participating one) before continuing the scan — bounded by the
same LIMIT argument that already bounded plain fields. Verified vianix build .#checks.aarch64-darwin.default: 1337 → 1339 passed (both new test
predictions matched exactly on the first attempt), 0 failed.Number.parseInt/Number.parseFloatwere wired to%js-to-number
(generic ToNumber coercion) instead of the actualparseInt/parseFloat
implementations — found by noticing a PRE-EXISTING test had locked in the
wrong behavior (Number.parseInt('42x')asserted"NaN", when real JS
returns42) rather than catching the bug. Per spec,Number.parseInt
andNumber.parseFloatare literally===the globalparseInt/
parseFloat(the same function object) —%js-to-number, by contrast,
requires the ENTIRE string to be a valid numeric literal and returns NaN
on any trailing junk, the opposite ofparseInt/parseFloat's
"parse-the-longest-valid-prefix" contract.runtime-builtins-table- specs.lisp's"Number.parseInt"/"Number.parseFloat"entries now point
at#'%js-parse-int/#'%js-parse-float, the same functions the global
"parseInt"/"parseFloat"entries already used. Fixed the pre-existing
test that had asserted the wrong values (js-e2e-number-static-methods,
t/e2e-advanced-builtins-test.lisp) to assert the correct ones.parseInt(and, via the fix directly above,Number.parseInt) never
auto-detected a\"0x\"/\"0X\"hex prefix when no radix is given — a
real, previously-undiscovered ES5+ gap, found by checking test coverage
for this specific, commonly-cited spec detail (there was none). Real
JS:parseInt(\"0x1F\")→31(auto-detects hex from the prefix and
switches radix to 16), butparseInt(\"0x1F\", 10)→0(an EXPLICIT
non-16 radix must NOT auto-detect the prefix — parsing stops at the
leading\"0\", since\"x\"isn't a valid base-10 digit).%js-parse- int(src/runtime-builtins-globals.lisp) always defaulted its radix to
10 with no prefix-sniffing logic at all, soparseInt(\"0x1F\")returned
0instead of31— confirmed as a real bug with failing tests before
the fix. Rewrote to manually detect an optional sign, then (only when the
radix is omitted/0 or explicitly 16) an immediately-following\"0x\"/
\"0X\"prefix, switching the effective radix to 16 and advancing past
it before handing the remainder toparse-integer— any OTHER explicit
radix skips the prefix-detection step entirely, matching spec. Verified
vianix build .#checks.aarch64-darwin.default: 1339 passed (test case
count grew within existingdeftest-js-run-isolated-batchbatches, which
report as one test each regardless of internal case count, so the total
count is unchanged — both batches confirmed passing by name), 0 failed.Array.from({length, 0, 1, ...})(the array-LIKE, non-iterable code
path — every existing test used a genuinely iterable source: an array, a
string, a Set) added tojs-e2e-array-from(t/e2e-advanced-builtins- test.lisp). Confirmed correct, not a bug — verified vianix build .#checks.aarch64-darwin.default: 1339 → 1340 passed, 0 failed. A real
coverage gap closed either way: this code path (%js-array-to-length+
a%js-get-prop-per-index loop,src/runtime-array-from.lisp) had never
been exercised by any test before this.- Two new cases in
js-e2e-runs-arrays-and-closures(t/e2e-core-test.lisp)
checkingArray.prototype.concat's one-level-only flattening (a nested
array inside an array argument stays nested; a non-array argument is
appended whole, never recursively spread) — previously untested beyond a
single flat-arrays example. Confirmed already correct, not a bug —
verified vianix build .#checks.aarch64-darwin.default: 1340 → 1342
passed, 0 failed. - Nine new cases in
js-e2e-date-constructor-and-methods(t/e2e-modern-test.lisp)
covering Invalid Date across every construction path, getters,setTime/
setFullYear,toString, andtoJSON— the cases that found the real crash and the
%js-to-string/Date bug documented in the### Fixedentry above. Also updated
js-rt-date-parse-string-error(t/runtime-date-test.lisp), a pre-existing unit test
that had asserted the OLD, wrong "parse failure returns an integer" behavior. %js-compile-pattern's recursive-descent regex compiler
(compile-atom/compile-seq/compile-alt,src/runtime-regex.lisp) had no
recursion-depth bound on group nesting — a pattern with thousands of nested groups
("((((((...))))))") could exhaust the control stack (CWE-674 denial of service)
duringnew RegExp(pattern)/a literal/pattern/instead of failing cleanly, the
same class of riskwith-js-parse-depth(parser.lisp) already guards against for
the JS statement/expression parser. Added the equivalent guard here:
*js-regex-max-compile-depth*(1000,src/runtime-regex-combinators.lisp) and a new
sharedcompile-group-bodyhelper every group-parsing branch (lookahead,
non-capturing, named-capturing, capturing) now routes through instead of calling
compile-altdirectly — past the limit, compilation signals a normal Lisp error,
which%js-make-regex's existinghandler-casealready turns into an uncompiled
(never-matching)RegExprather than propagating a crash. Verified vianix build .#checks.aarch64-darwin.default: 1330 → 1332 passed (one confirming ordinary nesting
well under the limit still compiles and captures correctly, one confirming a pattern
deliberately 1000 groups past the limit fails gracefully instead of hanging/crashing
the test process — which is exactly what it would have done before this fix), 0
failed. Alternation chaining (a|a|a|...) recurses through a similar unbounded path
incompile-altand was deliberately left unguarded this pass — a real, if
independent, residual risk not addressed here to keep this fix scoped to the more
severe group-nesting case.Promise.anyrejected with a plain object ({errors, message}) when every input
promise rejected, instead of a realAggregateError—err instanceof AggregateError
anderr instanceof Errorboth failed, unlike realPromise.any.%js-make- aggregate-error(src/runtime-class.lisp) already existed, fully correct and unit-
tested in isolation (js-rt-make-aggregate-error,js-rt-aggregate-error-make), but
%js-promise-any(src/runtime-promise.lisp) built its own plain object with
matching property names instead of calling it — found viaparedit refactor remove-unused-definitions, which (correctly) flagged%js-make-aggregate-erroras
unreferenced fromsrc/; tracing why surfaced this missing call site rather than
genuine dead code. Verified vianix build .#checks.aarch64-darwin.default: 1337
passed / 0 failed, withjs-rt-promise-any-all-rejectedstrengthened to assert
instanceof AggregateErrorso this can't silently regress back to a lookalike object.checks.formatting(nixfmt via treefmt) was failing againstflake.nix's own
checks.coverageattribute — a pre-existing drift from before this session, never
caught because nothing had run the fullnix flake check(onlychecks.default) in
a long enough while. Fixed with a plainnix fmt; the diff is whitespace-only, no
semantic change.nix flake check(every check: default/tests, docs, compile,
formatting, coverage) now passes cleanly end to end.TextDecoder.prototype.decodediscarded the entire output string on a single invalid
UTF-8 byte (%js-text-decode-octets,src/runtime-ops-encoding.lisp, caught any
decode error and returned"") — despite the decoder's own"fatal"property always
reportingfalse(non-fatal/lenient mode, matching JS's default), which promises
exactly the opposite: invalid bytes become individual U+FFFD replacement characters
and decoding continues around them.decode(new Uint8Array([65, 255, 66]))returned
""; now returns"A�B". Fixed by passing SBCL's own:replacementexternal-
format option tosb-ext:octets-to-stringinstead of catching the error after the
fact — no hand-rolled resync logic needed.- A computed class member name (
class C { [Symbol.iterator]() {} }, or a computed
field/getter/setter/static member) compiled to a method registered under a
meaningless gensym string, permanently unreachable from JS — silently wrong rather
than erroring, and uncovered by any existing test (only computed keys on plain object
literals were tested). The parser now parses the bracketed expression for real and
lowers it to a runtime%js-to-property-keycall, matching the normalization
obj[expr]reads already use, instead of discarding it into a gensym. docs/src/quick-start.mdclaimedparse-js-source's:strict-mode/:module-p
keyword arguments "change how source is read"; neither is actually consulted anywhere
(import/exportparses unconditionally regardless of:module-p, and no
strict-mode-only restriction is ever rejected). Corrected the documentation and
recorded the gap honestly indocs/src/compatibility.mdrather than leaving the false
claim in place.- Four dead functions removed (
%js-lex-peek-char, superseded by%js-lex-peek-char2;
js-try-consumeandjs-skip-semi, superseded byjs-skip-semis; an unused test
helper) after confirming via whole-repository grep — not just the local file — that
each had zero remaining callers and was not part of the package's:exportlist
(several other flagged candidates turned out to be exported runtime-bridge API used
by generated code, not dead code, and were left alone). %js-lex-peek-char2itself (the "superseding" function in the entry directly above)
turned out to have zero callers of its own — aparedit inspect unused-definitions
sweep ofsrc/*.lispcaught what a same-file-only grep would not. Removed, along with
the now-empty;;; Peek helperssection header it left behind insrc/lexer.lisp.\b/\Bword boundary (src/runtime-regex.lisp) was worse than unimplemented: its
match predicate resolved tonilunconditionally, making it an always-failing
atom — any pattern containing\bcould never match anything, silently, with no
error. Found while auditing the regex engine's header comment against what
compile-atomactually handles (prompted by(?=expr)lookahead, next entry, also
being claimed there but not implemented). Now a real zero-width boundary test
(%js-regex-word-boundary-p: exactly one of the characters adjacent to the tested
position is a word character).(?=expr)/(?!expr)lookahead didn't exist as a
parse case at all —(?=foo)fell through to the plain capturing-group branch,
which treated?=fooas four literal characters to match, so it could never behave
like an assertion. Now real zero-width lookahead/negative-lookahead, reusing the
existingcompile-altsub-parser and consuming no input either way. Both verified
vianix build .#checks.aarch64-darwin.default(1313 passed) plus new
t/runtime-regex-test.lispcases (js-rt-regex-word-boundary,
js-rt-regex-lookahead). The same audit found several more gaps the header comment
claimed — capturing-group extraction,$1/$2replacement placeholders, named
groups, lookbehind,{n,m}quantifiers — deliberately left unfixed and disclosed
instead indocs/src/compatibility.md; capturing-group extraction in particular is
a real, nontrivial feature, not a quick follow-up to this fix.
Removed
src/parser-pattern-lower.lisp(229 lines) and its 7 dedicated tests in
t/parser-stmt-pattern-internals-test.lisp— a whole parallel, superseded
destructuring-pattern-lowering implementation (js-lower-binding-pattern,
%js-build-pattern-let,%js-build-array-pattern-let,
%js-build-object-pattern-let,%js-lower-element,%js-lower-property,
%js-make-get-prop,%js-wrap-default) converting ajs-binding-pattern
struct (src/parser-pattern.lisp) intoast-lettrees. Real destructuring
(const {a,b}=obj,const [x,y]=arr) has always gone through a completely
separate, actively-used plist-based implementation instead
(%js-parse-binding-pattern/%js-emit-object-pattern-bindings/
%js-emit-array-pattern-bindings,src/parser-stmt-binding.lisp) —
confirmed via exact-symbol grep (not substring, which false-positives on
the similarly-named%-prefixed sibling functions) that the whole
struct-based path was reachable only from its own dedicated test file,
never from any real statement or expression parser. Found viaparedit refactor remove-unused-definitions src/*.lisp, which flagged
js-lower-binding-patternspecifically (the one symbol in this connected
component with zero incoming references from anywhere insrc/, not
even a sibling call) — the rest of the cluster call each other internally,
which is why the tool's simple per-symbol "is this referenced" check
didn't also catch them; tracing the one flagged symbol's actual callers
surfaced the whole dead component by hand.js-parse-binding-patternand
friends (the struct-CONSTRUCTION side, still inparser-pattern.lisp) are
equally unreachable but were NOT removed this pass — that file also
defines genuinely load-bearing token-stream primitives (%js-peek,
%js-consume,%js-expect, ...) the rest of the parser calls, so
finishing this cleanup needs relocating those first, a separate,
coordinated pass (see theproject_2026_refactor_goal/
project_known_gapsmemory notes for the full plan — deleting the whole
file the way this one could be would have broken the parser's compile).
Verified vianix build .#checks.aarch64-darwin.default: 1337 → 1330
passed (7 orphaned tests removed, none replaced — there was no remaining
behavior to test once the dead functions were gone), 0 failed.src/parser-pattern.lisp(384 lines) and its 15 dedicated tests in
t/parser-stmt-pattern-internals-test.lisp— the struct-CONSTRUCTION half
of the destructuring-pattern implementation left behind by the
parser-pattern-lower.lispremoval above, plus every token-stream helper
this file defined (%js-peek,%js-peek-type,%js-peek-value,
%js-consume,%js-expect,%js-tok-type,%js-tok-value,
%js-ident-sym,%js-skip-token!,%js-expect!). The prior entry deferred
removing this file because an earlier investigation believed those helpers
were "genuinely load-bearing... called from dozens of sites" elsewhere in
the parser. Re-checked with precise (non-substring) greps this pass and
found that belief was based on two false-positive matches:%js-consume
(this file) was being confused with%js-consume-expected
(src/parser-stmt-binding.lisp, a distinct, unrelated function whose name
merely contains%js-consumeas a substring), and this file's own
js-parse-binding-patterndispatcher was being confused with
%js-parse-binding-pattern(alsoparser-stmt-binding.lisp, the real,
live destructuring parser — same substring-of-a-longer-name confusion).
Once distinguished, every one of this file's 15 definitions — the
js-binding-patternstruct, all 10 token-stream helpers, and the 4
remaining pattern-parsing functions (js-parse-array-pattern,
js-parse-object-pattern,%js-parse-object-pattern-member,
%js-parse-property-key-string,%js-parse-default-expr,
%js-toks-to-ast,js-parse-binding-pattern) — had zero references from
outside this file except its own dedicated test file. The whole file was
fully dead, not partially dead as previously believed, so no relocation or
call-site-renaming surgery was needed — a straight deletion, same as
parser-pattern-lower.lisp. Also fixed two now-stale doc comments this
turned up:src/parser.lisp's header still listedparser-pattern.lisp
among the files implementing recursive parsers, and still described
parse-js-source's signature with the:strict-mode/:module-pkeywords
removed by the entry below this one.docs/src/architecture.md's file/line
counts and itscl-ccmonorepo file-diff comparison (re-run against the
actual pinned commit's source tree, not estimated) updated to match: 94→93
files,parser-*group 20→19, andparser-pattern.lisp/
parser-pattern-lower.lispnow correctly listed among the 3 files only the
monorepo copy still has, alongsideruntime-async.lisp. Verified vianix build .#checks.aarch64-darwin.default: 1332 → 1317 passed (15 orphaned
tests removed, none replaced), 0 failed.parse-js-source's:strict-mode/:module-pkeyword arguments and the
*js-strict-mode*/*js-module-mode*dynamic variables they set, along with
js-program-forms's pass-through copies of the same two keywords. All were
documented as intended scaffolding for real strict-mode/module validation
(see the "### Fixed" entry above, this file) but nothing ever read either
variable back — confirmed inert, not merely undertested. Pre-1.0 with no
compatibility promise (docs/src/compatibility.md), and an API surface that
silently does nothing when exercised is worse than not having it: a caller
passing:strict-mode texpecting real validation got none, with no error to
say so.parse-js-modulestays as a plain alias forparse-js-source— genuinely
useful as a self-documenting call site for "this is a module" even though it now
behaves identically, and it has real callers acrosst/parser-stmt-module-test.lisp
andt/e2e-ast-test.lisp. Implementing real ECMAScript strict-mode semantics
(octal literal rejection, duplicate parameter names,eval/argumentsassignment,
and so on) remains a genuine, undone feature — this change only removes the false
impression that flipping a keyword already did it.