claude-mcp-workflow 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +4 -2
- package/README.md +37 -0
- package/build/engine.d.ts +13 -1
- package/build/engine.d.ts.map +1 -1
- package/build/engine.js +41 -2
- package/build/engine.js.map +1 -1
- package/build/types.d.ts +20 -20
- package/build/types.js +1 -1
- package/build/types.js.map +1 -1
- package/hooks/hooks.json +4 -2
- package/hooks/workflow-cleanup.sh +8 -4
- package/hooks/workflow-start.sh +60 -32
- package/package.json +1 -1
- package/templates/bug-fix.yaml +98 -16
- package/templates/code-review.yaml +30 -12
- package/templates/coding.yaml +49 -4
- package/templates/debugging.yaml +39 -14
- package/templates/explore.yaml +48 -14
- package/templates/file-code.yaml +13 -4
- package/templates/file-review.yaml +25 -6
- package/templates/github-init.yaml +24 -8
- package/templates/investigate.yaml +13 -4
- package/templates/master.yaml +16 -13
- package/templates/new-feature.yaml +24 -26
- package/templates/planning.yaml +34 -7
- package/templates/reflection.yaml +11 -4
- package/templates/review-push.yaml +26 -7
- package/templates/skills/architecture/SKILL.md +131 -1
- package/templates/skills/aws-lambda/SKILL.md +9 -9
- package/templates/skills/browser-verify/SKILL.md +58 -0
- package/templates/skills/build-cmake/SKILL.md +24 -8
- package/templates/skills/ci-github-actions/SKILL.md +34 -18
- package/templates/skills/claude-code-config/SKILL.md +41 -19
- package/templates/skills/cleanup/SKILL.md +57 -0
- package/templates/skills/coding-skill-selector/SKILL.md +2 -1
- package/templates/skills/debug-bridge-scaffold/SKILL.md +98 -0
- package/templates/skills/domain-gamedev/SKILL.md +56 -6
- package/templates/skills/domain-pixi/SKILL.md +256 -7
- package/templates/skills/domain-reid/SKILL.md +39 -5
- package/templates/skills/domain-yolo/SKILL.md +18 -7
- package/templates/skills/ide-zed/SKILL.md +23 -0
- package/templates/skills/lang-as3/SKILL.md +7 -5
- package/templates/skills/lang-haxe/SKILL.md +538 -19
- package/templates/skills/lang-python/SKILL.md +6 -2
- package/templates/skills/math/SKILL.md +64 -2
- package/templates/skills/mcp-setup/SKILL.md +14 -2
- package/templates/skills/preferences/SKILL.md +10 -10
- package/templates/skills/skill-manager/SKILL.md +264 -0
- package/templates/skills/target-openfl-native/SKILL.md +52 -17
- package/templates/skills/target-openfl-native/references/build-and-versions.md +7 -4
- package/templates/skills/task-delegation/SKILL.md +76 -4
- package/templates/skills/web-reading/SKILL.md +33 -25
- package/templates/skills/workflow-authoring/SKILL.md +47 -12
- package/templates/subagent.yaml +29 -8
- package/templates/testing.yaml +64 -10
- package/templates/web-research.yaml +23 -13
|
@@ -20,6 +20,19 @@ var plain = "No $interpolation here"; // ✗ Literal text, no parsing
|
|
|
20
20
|
- `${expr}` only for expressions (field access, math, function calls)
|
|
21
21
|
- `'${_name}'` is wrong → `'$_name'`
|
|
22
22
|
|
|
23
|
+
### Escaping a literal `$`: use `$$`, NEVER `\$`
|
|
24
|
+
|
|
25
|
+
To put a literal `$` in a single-quoted string, double it: `$$`. `\$` is **not a valid escape sequence** — `haxe` rejects it with `Invalid escape sequence \$` (the recognized escapes are `\t \n \r \" \' \\ \xXX \uXXXX`; `\$` is not among them).
|
|
26
|
+
|
|
27
|
+
```haxe
|
|
28
|
+
var a = 'price is $$5'; // ✓ → "price is $5"
|
|
29
|
+
var b = 'unterminated $${ here'; // ✓ → "unterminated ${ here" (escape the $, the { is literal)
|
|
30
|
+
var c = 'cost \$5'; // ✗ COMPILE ERROR: Invalid escape sequence \$
|
|
31
|
+
var d = "cost $5"; // ✓ double quotes never interpolate → literal "cost $5"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
So for a literal `$` (or a literal `${`) the two correct forms are **`$$` inside `'...'`** or **just use `"..."`** (no interpolation at all). Reach for double quotes when the string is mostly literal `$`/`${`; use `$$` when you also want interpolation elsewhere in the same string.
|
|
35
|
+
|
|
23
36
|
## String.replace() Requires `using StringTools`
|
|
24
37
|
|
|
25
38
|
`String` in Haxe has NO built-in `.replace()` method. Calling `.replace()` without `using StringTools` fails with `String has no field replace`.
|
|
@@ -96,7 +109,7 @@ This is distinct from `(default, set)` where the setter IS accessible from subcl
|
|
|
96
109
|
Fix: pass narrowed value through a helper with non-null param, or use `.bind()` at call site for value types.
|
|
97
110
|
|
|
98
111
|
### Strict (`@:nullSafety(Strict)`)
|
|
99
|
-
Fields
|
|
112
|
+
Fields DO narrow right after a null check, but the narrowing is fragile: ANY intervening statement that could mutate state (a call, a write to any field) resets it — the compiler assumes another thread/callback could modify the field. In practice a field is only usable as non-null on the line directly after its check. Local variables narrow normally and stay narrowed.
|
|
100
113
|
|
|
101
114
|
**Pattern: capture field into local immediately after null check:**
|
|
102
115
|
```haxe
|
|
@@ -135,6 +148,15 @@ logo.alpha = 0.3; // ✓
|
|
|
135
148
|
|
|
136
149
|
**Key rule:** in Strict mode, ANY intervening statement (even writing to a *different* field) can invalidate narrowing of a nullable field. Always capture to local **immediately** after the null check.
|
|
137
150
|
|
|
151
|
+
**Null safety with `?.` on abstract method returns:**
|
|
152
|
+
```haxe
|
|
153
|
+
// WRONG — && narrowing fails for method calls on nullable
|
|
154
|
+
final scrolling:Bool = (_axis != null && _axis.isScrolling());
|
|
155
|
+
|
|
156
|
+
// RIGHT — safe navigation + explicit comparison
|
|
157
|
+
final scrolling:Bool = (_axis?.isScrolling() == true);
|
|
158
|
+
```
|
|
159
|
+
|
|
138
160
|
## Map Key-Value Iteration
|
|
139
161
|
|
|
140
162
|
Use `for (key => value in map)` — cleaner than iterating keys and looking up values separately. Value is guaranteed non-null.
|
|
@@ -195,6 +217,42 @@ if (bmd != null) bmd.image.version++;
|
|
|
195
217
|
|
|
196
218
|
Same applies to `--`, `+=`, `-=` on the end of a `?.` chain.
|
|
197
219
|
|
|
220
|
+
## `Sys.stdin().readAll()` Throws `Error.Blocked` on hxnodejs When Stdin Is a Pipe
|
|
221
|
+
|
|
222
|
+
`Sys.stdin().readAll()` works on neko / eval, but on hxnodejs (`-lib hxnodejs`, `-js`) it raises `haxe.io.Error.Blocked` the moment stdin is a pipe — `echo … | cmd`, heredoc, `cmd <<< …`, process substitution. The hxnodejs sync stdin binding wraps a non-blocking `process.stdin._handle.read()` call and re-throws `EAGAIN` as `Blocked` instead of waiting for EOF. TTY mode happens to work because input arrives in one synchronous chunk; the moment the producer is anything other than a TTY (`/dev/tty`), the call fails before any bytes are consumed.
|
|
223
|
+
|
|
224
|
+
```haxe
|
|
225
|
+
// WRONG on hxnodejs — `echo 'class C {}' | node bin/cli.js` throws Blocked
|
|
226
|
+
final src:String = Sys.stdin().readAll().toString();
|
|
227
|
+
|
|
228
|
+
// RIGHT — read via Node's native fs.readFileSync(0), which IS blocking
|
|
229
|
+
// on a pipe and returns all bytes through EOF.
|
|
230
|
+
private static function readStdin():String {
|
|
231
|
+
#if nodejs
|
|
232
|
+
final fs:Dynamic = js.Lib.require('fs');
|
|
233
|
+
final buf:Dynamic = fs.readFileSync(0);
|
|
234
|
+
return (buf : Dynamic).toString('utf8');
|
|
235
|
+
#elseif sys
|
|
236
|
+
return Sys.stdin().readAll().toString();
|
|
237
|
+
#else
|
|
238
|
+
throw 'stdin requires a sys target';
|
|
239
|
+
#end
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The two-branch shape is required: neko / eval do not have `js.Lib.require`, and hxnodejs is the only target on which `Sys.stdin().readAll()` raises Blocked. The Node path uses untyped `Dynamic` because `js.Lib.require('fs')` returns the raw Node module surface — `fs.readFileSync(0)` returns a Node `Buffer` whose `.toString('utf8')` decodes the bytes.
|
|
244
|
+
|
|
245
|
+
Symptom in test output:
|
|
246
|
+
```
|
|
247
|
+
haxe_ValueException [Error]: Blocked
|
|
248
|
+
at haxe_Exception.thrown (...:50993:12)
|
|
249
|
+
at _$Sys_FileInput.readAll (...:285:27)
|
|
250
|
+
at <yourReadStdin> (...:46538:33)
|
|
251
|
+
value: { _hx_name: 'Blocked', _hx_index: 0, __enum__: 'haxe.io.Error', ... }
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
`Error.Blocked` from a `Sys.stdin().readAll()` frame ≡ this gotcha. Verified on Node 20.18 + Haxe 4.3.7 + hxnodejs 12.x.
|
|
255
|
+
|
|
198
256
|
## `String.charCodeAt` Returns `Null<Int>` — Use `StringTools.fastCodeAt` Under Strict Null Safety
|
|
199
257
|
|
|
200
258
|
Under `@:nullSafety(Strict)`, `String.charCodeAt(i)` is typed `Null<Int>`. Assigning it to `final c:Int` fails even inside a bounds-checked loop.
|
|
@@ -213,6 +271,42 @@ for (i in 0...s.length) {
|
|
|
213
271
|
|
|
214
272
|
Use `charCodeAt` only when the caller genuinely may pass an out-of-bounds index and wants `null` back. For loops over `0...s.length`, always use `fastCodeAt`.
|
|
215
273
|
|
|
274
|
+
Sister pattern: `Array.pop()` / `Array.shift()` have the same root cause — see below.
|
|
275
|
+
|
|
276
|
+
## Array.pop() / shift() Return Null<T> — Strict Refuses to Narrow Despite Length Guard
|
|
277
|
+
|
|
278
|
+
Under `@:nullSafety(Strict)`, `Array.pop()` and `Array.shift()` are typed `Null<T>`. The classic length-guarded stack-walker pattern fails to compile with `Cannot assign nullable value here` — the compiler does not narrow based on the runtime invariant proved by `stack.length > 0`.
|
|
279
|
+
|
|
280
|
+
```haxe
|
|
281
|
+
// WRONG — Null<T> cannot be assigned to T under Strict, even with the length guard
|
|
282
|
+
while (stack.length > 0) {
|
|
283
|
+
final node:T = stack.pop(); // Null safety error
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Three valid fixes:
|
|
288
|
+
|
|
289
|
+
**Option 1 — cast at assignment** (guard already proved correctness):
|
|
290
|
+
```haxe
|
|
291
|
+
final node:T = (cast stack.pop() : T);
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
**Option 2 — capture as `Null<T>` and break on null**:
|
|
295
|
+
```haxe
|
|
296
|
+
final node:Null<T> = stack.pop();
|
|
297
|
+
if (node == null) break;
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**Option 3 — drop `@:nullSafety(Strict)` from the walker class** (lowest friction when the file's only nullability concern is this stdlib accessor):
|
|
301
|
+
```haxe
|
|
302
|
+
@:nullSafety // Basic, not Strict
|
|
303
|
+
class MyWalker { ... }
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Same root cause as `String.charCodeAt` returning `Null<Int>`: stdlib accessor is typed nullable for the out-of-bounds case; Strict has no mechanism to narrow on a runtime-proven invariant. Option 3 is the right default when Strict doesn't otherwise benefit the class; use option 1 or 2 when Strict genuinely matters for the rest of the file.
|
|
307
|
+
|
|
308
|
+
Verified on Haxe 4.3.7.
|
|
309
|
+
|
|
216
310
|
## Enum Abstract for Simple Enums
|
|
217
311
|
|
|
218
312
|
For marker enums without associated data, use `enum abstract(Int)` — zero-cost at runtime, compiles to a primitive. Use `final` instead of `var` for values (immutable by intent).
|
|
@@ -299,6 +393,33 @@ super(items, 30, 30, -1, 1, 0, "end"); // ✗ Hardcoding someone else's default
|
|
|
299
393
|
|
|
300
394
|
**DANGER: Float constant passed to Int parameter silently skips it.** An `inline final X:Float = 180` looks like an integer but is typed Float. When passed to `new Row(children, X, 16, ...)` where `boxWidth:Int`, the Float skips `boxWidth` and lands on the next Float parameter (e.g. `bgAlpha`). Fix: use `Int` type for constants passed to Int parameters, or cast with `Std.int()`.
|
|
301
395
|
|
|
396
|
+
## Function Default Parameter Values Must Be Compile-Time Constants — Enum Ctors With Args Excluded
|
|
397
|
+
|
|
398
|
+
Default values in ordinary function signatures (`param:T = default`) must be compile-time constants. Zero-arity enum constructors (e.g. `Empty`) qualify and DO work. Enum constructors that take arguments do NOT — even when every argument is itself a literal constant. The compiler rejects with `Default argument value should be constant`.
|
|
399
|
+
|
|
400
|
+
```haxe
|
|
401
|
+
enum Foo { A; B(s:String); }
|
|
402
|
+
|
|
403
|
+
// WRONG — B('hi') is not a compile-time constant despite the literal arg
|
|
404
|
+
function test(x:Foo = B('hi')):Foo return x;
|
|
405
|
+
// ^^^^^^^ Default argument value should be constant
|
|
406
|
+
|
|
407
|
+
// WRONG — same root cause, Line('\n') / Text("x") rejected
|
|
408
|
+
function emit(?items:Array<Doc>, trailBreak:Doc = Line('\n')):Doc { ... }
|
|
409
|
+
|
|
410
|
+
// RIGHT — make the param nullable, unwrap inside the body with `??`
|
|
411
|
+
function emit(?items:Array<Doc>, ?trailBreak:Doc):Doc {
|
|
412
|
+
final trailBreakDoc:Doc = trailBreak ?? Line('\n');
|
|
413
|
+
...
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// RIGHT — zero-arity ctor IS allowed as default
|
|
417
|
+
enum Mode { Empty; Filled(s:String); }
|
|
418
|
+
function f(m:Mode = Empty):Void { ... } // ✓
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Verified on Haxe 4.3.7.
|
|
422
|
+
|
|
302
423
|
## Macro `Context.error` Halts the Macro — No Defensive `continue` Needed
|
|
303
424
|
|
|
304
425
|
`haxe.macro.Context.error(msg, pos)` throws — execution does NOT fall through. The return type is `Dynamic` only because the function never returns normally. Subsequent statements in the same macro invocation are dead code.
|
|
@@ -329,7 +450,7 @@ for (field in fields) {
|
|
|
329
450
|
}
|
|
330
451
|
```
|
|
331
452
|
|
|
332
|
-
Downside of the minimal form: the first invalid field halts the whole build, so the user sees one error per compile cycle instead of a batch.
|
|
453
|
+
Downside of the minimal form: the first invalid field halts the whole build, so the user sees one error per compile cycle instead of a batch. For **batched** errors across multiple fields, the right API is `Context.reportError(msg, pos)` — it records the error and continues (compilation still fails at the end), so every invalid field is reported in one compile cycle. Follow with a post-loop `Context.error` / `Context.fatalError` only if you must abort before further processing.
|
|
333
454
|
|
|
334
455
|
## Macro `FunctionArg`: `opt: true` Widens Type To `Null<T>` Even With a Default
|
|
335
456
|
|
|
@@ -488,6 +609,109 @@ switch (value) { ... }
|
|
|
488
609
|
switch value { ... }
|
|
489
610
|
```
|
|
490
611
|
|
|
612
|
+
## Abstract Types in Catch Clauses: Runtime Type Is the Underlying Type
|
|
613
|
+
|
|
614
|
+
`catch` accepts abstract types. The runtime check is against the abstract's **underlying type**, not its compile-time conversions. `from` declarations do NOT widen the runtime catch behavior.
|
|
615
|
+
|
|
616
|
+
- `abstract Foo(Dynamic) from Dynamic` — catches **everything** (class instances, anonymous structs, plain strings) because the underlying runtime check is on `Dynamic`.
|
|
617
|
+
- `abstract Foo(SomeClass) from SomeClass from Dynamic` — catches **only** runtime instances of `SomeClass`. The compile-time `from Dynamic` does not widen what is caught at runtime — anonymous structs and unrelated values fall through.
|
|
618
|
+
|
|
619
|
+
```haxe
|
|
620
|
+
abstract MyErr(Dynamic) from Dynamic {
|
|
621
|
+
public var name(get, never):String;
|
|
622
|
+
inline function get_name():String return this.name;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
class Test {
|
|
626
|
+
static function main():Void {
|
|
627
|
+
try { throw { name: 'IOError' }; }
|
|
628
|
+
catch (e: MyErr) { trace('caught: ' + e.name); } // works — anon struct caught
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
Practical pattern: `abstract MyError(Dynamic) from Dynamic` as a typed unified catch surface that catches every thrown shape, with typed accessors via getters.
|
|
634
|
+
|
|
635
|
+
Verified on Haxe 4.x — `--interp` and `-js` targets.
|
|
636
|
+
|
|
637
|
+
## `this` Inside an Abstract Body IS the Underlying Type
|
|
638
|
+
|
|
639
|
+
Inside abstract methods and getters, `this` is already typed as the **underlying type**. No cast is needed.
|
|
640
|
+
|
|
641
|
+
```haxe
|
|
642
|
+
// WRONG — redundant cast; this is already Dynamic
|
|
643
|
+
abstract MyAbs(Dynamic) from Dynamic {
|
|
644
|
+
public var name(get, never):String;
|
|
645
|
+
inline function get_name():String return (this:Dynamic).name; // noise
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// RIGHT — direct field access; this IS Dynamic
|
|
649
|
+
abstract MyAbs(Dynamic) from Dynamic {
|
|
650
|
+
public var name(get, never):String;
|
|
651
|
+
inline function get_name():String return this.name;
|
|
652
|
+
}
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
Same for any underlying type: `abstract Foo(SomeClass)` makes `this` typed as `SomeClass` inside the body — field access goes direct.
|
|
656
|
+
|
|
657
|
+
**Bonus: `to UnderlyingType` is implicit.** `abstract Foo(Bar) to Bar` — the `to Bar` direction is redundant; the compiler provides it automatically. Drop it.
|
|
658
|
+
|
|
659
|
+
```haxe
|
|
660
|
+
// REDUNDANT
|
|
661
|
+
abstract MyAbs(Dynamic) from Dynamic to Dynamic { ... }
|
|
662
|
+
|
|
663
|
+
// EQUIVALENT — less noise
|
|
664
|
+
abstract MyAbs(Dynamic) from Dynamic { ... }
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
`from Dynamic` IS load-bearing (allows implicit conversion into the abstract from any value). `to Dynamic` is automatic.
|
|
668
|
+
|
|
669
|
+
## Abstract over Dynamic Does NOT Auto-Convert to Typedef Without Explicit `to`
|
|
670
|
+
|
|
671
|
+
`abstract Foo(Dynamic) from Dynamic` accepts any value as input (catch-all), but does NOT automatically convert to a `typedef` of an anonymous struct on output. The compiler treats abstract → typedef as a distinct conversion that must be declared with `to TypedefName`.
|
|
672
|
+
|
|
673
|
+
```haxe
|
|
674
|
+
// typedef in an extern web-API library
|
|
675
|
+
typedef WebAPIError = { code: Float; name: String; message: String; };
|
|
676
|
+
|
|
677
|
+
abstract MyAbs(Dynamic) from Dynamic { ... }
|
|
678
|
+
|
|
679
|
+
function logError(e: WebAPIError): Void { /* uses e.code, e.name, e.message */ }
|
|
680
|
+
|
|
681
|
+
final m: MyAbs = ...;
|
|
682
|
+
logError(m); // ❌ Compile error:
|
|
683
|
+
// pkg.MyAbs should be webapi.WebAPIError
|
|
684
|
+
// For function argument 'e'
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
The fix is to declare `to WebAPIError` on the abstract:
|
|
688
|
+
|
|
689
|
+
```haxe
|
|
690
|
+
abstract MyAbs(Dynamic) from Dynamic to WebAPIError { ... } // ✓ now flows
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
This is asymmetric with the `from Dynamic` direction — Dynamic → typedef works at value level (structural matching), but abstract → typedef requires an explicit declaration. Failing to add `to` triggers a "viral retyping" cascade: every helper that took the typedef seemingly needs to accept the abstract, which is the wrong fix. The right fix is the one-line `to`.
|
|
694
|
+
|
|
695
|
+
## Don't Propagate the Abstract Into Signatures That Only Need the Underlying Structural Shape
|
|
696
|
+
|
|
697
|
+
When adding an abstract over an existing typedef (e.g. `DeviceError` over `WebAPIError`), the temptation is to retype every existing helper to accept the abstract. Resist.
|
|
698
|
+
|
|
699
|
+
If a function only uses fields like `.code`, `.name`, `.message` — fields the typedef already has — its signature should stay on the typedef. The abstract auto-converts at call sites (with `to` declared per the section above). Propagating the abstract into structural-only helpers couples them to the unified-type concept needlessly.
|
|
700
|
+
|
|
701
|
+
```haxe
|
|
702
|
+
// WRONG — abstract propagated into a pure formatter that only needs {code, name, message}
|
|
703
|
+
private function deviceError(err: DeviceError): Void {
|
|
704
|
+
error('${err.code} ${err.name} ${err.message}');
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// RIGHT — typedef captures the structural need; DeviceError -> WebAPIError via `to`
|
|
708
|
+
private function deviceError(err: WebAPIError): Void {
|
|
709
|
+
error('${err.code} ${err.name} ${err.message}');
|
|
710
|
+
}
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
Rule of thumb: type the parameter at the level of structural need, not at the level of the most-typed source. The abstract's job is auto-conversion at the boundary — it should NOT bleed into every internal helper.
|
|
714
|
+
|
|
491
715
|
## Type Checking: `is` Operator and `Std.downcast`
|
|
492
716
|
|
|
493
717
|
`is` is syntactic sugar for `Std.isOfType` — cleaner syntax for type checks:
|
|
@@ -599,15 +823,6 @@ else
|
|
|
599
823
|
|
|
600
824
|
**General rule:** in any context where the branch body starts with `macro if(...)`, prefer `?:` over `if/else` to avoid ambiguity.
|
|
601
825
|
|
|
602
|
-
**Null safety with `?.` on abstract method returns:**
|
|
603
|
-
```haxe
|
|
604
|
-
// WRONG — && narrowing fails for method calls on nullable
|
|
605
|
-
final scrolling:Bool = (_axis != null && _axis.isScrolling());
|
|
606
|
-
|
|
607
|
-
// RIGHT — safe navigation + explicit comparison
|
|
608
|
-
final scrolling:Bool = (_axis?.isScrolling() == true);
|
|
609
|
-
```
|
|
610
|
-
|
|
611
826
|
## Private Access: `@:access` Over `@:privateAccess`
|
|
612
827
|
|
|
613
828
|
Prefer `@:access(package.ClassName)` over `@:privateAccess` blocks. `@:access` is scoped to a specific class's internals — precise and doesn't clutter method bodies. `@:privateAccess` unlocks ALL private fields of ALL types — overly permissive.
|
|
@@ -739,9 +954,39 @@ Dollar;
|
|
|
739
954
|
|
|
740
955
|
**Rule:** Always use double-quoted strings for metadata arguments containing `$`. This applies to all `@:` metadata, not just specific tags.
|
|
741
956
|
|
|
742
|
-
##
|
|
957
|
+
## Test Fixture Strings With `$identifier` Content Must Use Double Quotes
|
|
958
|
+
|
|
959
|
+
Single-quoted strings in test files are subject to Haxe interpolation — any `$name` or `${...}` in a parser fixture input or an `Assert.equals` expected literal is treated as an identifier reference, not verbatim text. The compiler error is a **compile-time `Unknown identifier : <name>`** pointing at the call argument, which can mislead: the error looks like a scope or import problem, not a quoting mistake.
|
|
960
|
+
|
|
961
|
+
```haxe
|
|
962
|
+
// WRONG — single quotes: Haxe interpolates $name as an identifier
|
|
963
|
+
final decl = parseSingleVarDecl('class Foo { var x:Int = a.$name; }');
|
|
964
|
+
Assert.equals('$name', (f : String));
|
|
965
|
+
// Error: Unknown identifier : name
|
|
966
|
+
// ... For function argument 'source'
|
|
967
|
+
// Error: Unknown identifier : name
|
|
968
|
+
// ... For function argument 'expected'
|
|
969
|
+
|
|
970
|
+
// RIGHT — double quotes: $ is literal text
|
|
971
|
+
final decl = parseSingleVarDecl("class Foo { var x:Int = a.$name; }");
|
|
972
|
+
Assert.equals("$name", (f : String));
|
|
973
|
+
```
|
|
974
|
+
|
|
975
|
+
Verbatim compiler error for reference:
|
|
976
|
+
```
|
|
977
|
+
HxPostfixSliceTest.hx:92: characters 73-77 : Unknown identifier : name
|
|
978
|
+
HxPostfixSliceTest.hx:92: characters 73-77 : ... For function argument 'source'
|
|
979
|
+
HxPostfixSliceTest.hx:96: characters 21-25 : Unknown identifier : name
|
|
980
|
+
HxPostfixSliceTest.hx:96: characters 21-25 : ... For function argument 'expected'
|
|
981
|
+
```
|
|
982
|
+
|
|
983
|
+
The failure bites BOTH the fixture input string and the expected-value literal in `Assert.equals`. It survives self-review because `$name` in a string LOOKS intentional — it is reliably caught only by the compiler.
|
|
984
|
+
|
|
985
|
+
**Prevention rule:** before building, scan newly-added or edited `.hx` test code for single-quoted string literals whose content contains `$` followed by a letter, underscore, or `{`; each is a bug unless interpolation is actually intended. The scan must cover **every** test file touched in the same change, not only the file currently in focus — a recurrence happened where the rule was applied correctly in one new test file but missed in a sibling file edited in the same change, caught only by file-review.
|
|
743
986
|
|
|
744
|
-
|
|
987
|
+
## Block Comments Reject Embedded `*/` — Even Inside Backticks
|
|
988
|
+
|
|
989
|
+
Inside any `/* ... */` block comment (doc comments `/** */` included) the lexer treats the first `*/` it sees as the closing delimiter — block comments do not nest, and backtick-wrapping for markdown code-span formatting does not escape it. Everything after the premature close is invalid syntax.
|
|
745
990
|
|
|
746
991
|
```haxe
|
|
747
992
|
// WRONG — the inner */ closes the doc comment; ` after it is an "Invalid character"
|
|
@@ -757,7 +1002,7 @@ class Foo {} // Error: Invalid character '`'
|
|
|
757
1002
|
class Foo {}
|
|
758
1003
|
```
|
|
759
1004
|
|
|
760
|
-
The gotcha applies
|
|
1005
|
+
The gotcha applies to ALL block comments — plain `/* */` and `/** */` doc comments alike; the lexer closes the comment at the first `*/` regardless of surrounding backticks. Simple backticks (`` `foo` ``, `` `//` ``) inside comments are fine — the problem is specifically the `*/` character sequence appearing by any means inside a block comment.
|
|
761
1006
|
|
|
762
1007
|
Verified on Haxe 4.3.7.
|
|
763
1008
|
|
|
@@ -791,21 +1036,85 @@ params.push(macro $i{dep.paramName}); // Unknown identifier: _di_storage
|
|
|
791
1036
|
params.push({expr: EConst(CIdent(dep.paramName)), pos: Context.currentPos()});
|
|
792
1037
|
```
|
|
793
1038
|
|
|
794
|
-
##
|
|
1039
|
+
## Single Combined `EVars` Splices Into Outer Scope (Workaround for `$b{}` Isolation)
|
|
1040
|
+
|
|
1041
|
+
When a macro helper must declare N vars that the caller's sibling code can read, returning `Array<Expr>` of separate `EVars` and splicing via `$b{}` doesn't help — the block still isolates. The fix: fold all `Var` entries into ONE `EVars(Array<Var>)` node and splice it as a single `$expr`.
|
|
1042
|
+
|
|
1043
|
+
```haxe
|
|
1044
|
+
// WRONG — block wraps vars in new scope, caller siblings can't see them
|
|
1045
|
+
function initVars():Expr {
|
|
1046
|
+
return macro { var _prev0:Int = 0; var _prev1:Int = 0; };
|
|
1047
|
+
}
|
|
1048
|
+
// caller: ${initVars()}; — _prev0/_prev1 NOT visible to siblings
|
|
1049
|
+
|
|
1050
|
+
// RIGHT — single EVars node, vars declared at caller's EBlock level
|
|
1051
|
+
function initVars(names:Array<String>):Expr {
|
|
1052
|
+
final vars:Array<haxe.macro.Expr.Var> = [
|
|
1053
|
+
for (n in names) {name: n, type: macro:Int, expr: macro 0}
|
|
1054
|
+
];
|
|
1055
|
+
return vars.length > 0
|
|
1056
|
+
? {expr: EVars(vars), pos: Context.currentPos()}
|
|
1057
|
+
: (macro {});
|
|
1058
|
+
}
|
|
1059
|
+
// caller:
|
|
1060
|
+
final initPrev:Expr = initVars(['_prev0', '_prev1']);
|
|
1061
|
+
return macro { $initPrev; /* _prev0 / _prev1 VISIBLE here */ };
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
**Why it works:** a single `EVars` spliced into a parent `EBlock` IS the same form as writing `var a = 0; var b = 0;` as sibling statements — no extra nesting. `EBlock` always creates a new scope; `EVars` is just a multi-declaration statement.
|
|
1065
|
+
|
|
1066
|
+
**Decision rule:** N var declarations that siblings must read → fold to one `EVars(Array<Var>)`; assignments/mutations of already-declared vars → `EBlock` is fine (nested scope reads outer vars normally). Empty list → `macro {}`.
|
|
1067
|
+
|
|
1068
|
+
## Macro `$a{}` Is Context-Dependent: Splices Call Arguments, Builds an Array Literal Standalone
|
|
795
1069
|
|
|
796
|
-
In
|
|
1070
|
+
In a call-argument position, `$a{arr}` splices array elements as separate arguments, NOT as an array literal: `f($a{[x, y, z]})` becomes `f(x, y, z)` — three separate arguments. In a standalone expression position, `$a{arr}` builds an array literal: `macro $a{parts}` yields `[x, y, z]` (an `EArrayDecl`).
|
|
797
1071
|
|
|
798
1072
|
```haxe
|
|
799
1073
|
// WRONG — splices as separate args: _dc(doc1, doc2, doc3)
|
|
800
1074
|
final parts:Array<Expr> = [exprA, exprB, exprC];
|
|
801
1075
|
macro _dc($a{parts})
|
|
802
1076
|
|
|
803
|
-
// RIGHT — build array
|
|
804
|
-
final arr:Expr = {
|
|
1077
|
+
// RIGHT — build the array literal first (standalone $a{} = array literal), then pass it
|
|
1078
|
+
final arr:Expr = macro $a{parts}; // [doc1, doc2, doc3]
|
|
805
1079
|
macro _dc($arr) // generates: _dc([doc1, doc2, doc3])
|
|
1080
|
+
|
|
1081
|
+
// EQUIVALENT — construct EArrayDecl manually
|
|
1082
|
+
final arr:Expr = {expr: EArrayDecl(parts), pos: Context.currentPos()};
|
|
1083
|
+
macro _dc($arr)
|
|
806
1084
|
```
|
|
807
1085
|
|
|
808
|
-
|
|
1086
|
+
When you need a macro-time `Array<Expr>` as a single runtime array argument, wrap it into an array-literal `Expr` first — inside a call's argument list, `$a{}` always splices.
|
|
1087
|
+
|
|
1088
|
+
## Macro `Array<Expr>` Literal — Parenthesise Each `macro …` Element
|
|
1089
|
+
|
|
1090
|
+
Inside an `Array<Expr>` literal (for `EBlock` / `ECall` / `EArrayDecl` construction in build macros), bare `macro …` reifications must be wrapped in parentheses. Without them, the Haxe parser treats `macro` as an identifier for the next array slot and fails with:
|
|
1091
|
+
|
|
1092
|
+
```
|
|
1093
|
+
Keyword macro cannot be used as variable name
|
|
1094
|
+
```
|
|
1095
|
+
|
|
1096
|
+
Plain `Expr` variables (already-built values) do NOT need parens — only `macro …` reifications.
|
|
1097
|
+
|
|
1098
|
+
```haxe
|
|
1099
|
+
// WRONG — bare macro reification as array element
|
|
1100
|
+
final block:Array<Expr> = [
|
|
1101
|
+
macro final _wo = _copyOpt(opt),
|
|
1102
|
+
macro { var _f:Bool = false; $probeBody; },
|
|
1103
|
+
baseRawWriteCall,
|
|
1104
|
+
];
|
|
1105
|
+
// Error: Keyword macro cannot be used as variable name
|
|
1106
|
+
|
|
1107
|
+
// RIGHT — each macro reification parenthesised
|
|
1108
|
+
final block:Array<Expr> = [
|
|
1109
|
+
(macro final _wo = _copyOpt(opt)),
|
|
1110
|
+
(macro { var _f:Bool = false; $probeBody; }),
|
|
1111
|
+
baseRawWriteCall, // plain Expr variable — no parens needed
|
|
1112
|
+
];
|
|
1113
|
+
```
|
|
1114
|
+
|
|
1115
|
+
Ternary positions like `cond ? macro X : macro Y` bind correctly without parens — the issue is specific to comma-separated array literal elements.
|
|
1116
|
+
|
|
1117
|
+
Verified on Haxe 4.3.7.
|
|
809
1118
|
|
|
810
1119
|
## Enum Constructor Calls in `macro {}` Trigger Type Checking
|
|
811
1120
|
|
|
@@ -856,6 +1165,39 @@ private static function extractInt(texpr:TypedExpr):Int {
|
|
|
856
1165
|
|
|
857
1166
|
`FEnum` only works for real `enum` types. For `enum abstract(Int)`, the compiler resolves values at typing time — the typed AST contains the raw Int, not a reference to the abstract's field.
|
|
858
1167
|
|
|
1168
|
+
## Regex Alternation: `^A|B` Is `(^A)|B`, NOT `^(?:A|B)` — Wrap Both Alts in a Non-Capturing Group
|
|
1169
|
+
|
|
1170
|
+
Regex alternation has lower precedence than every other operator including anchors. `^A|B` parses as `(^A)|B` — the `^` anchor binds ONLY to the first alternative. The second alt is unanchored and scans the rest of input for an arbitrary match anywhere — silently consuming mid-buffer bytes that the parser thought it was inspecting at the cursor.
|
|
1171
|
+
|
|
1172
|
+
```haxe
|
|
1173
|
+
// WRONG — second alt scans mid-buffer
|
|
1174
|
+
var re = new EReg('^[0-9]+\\.[0-9]+|[0-9]+\\.(?![\\w.])', '');
|
|
1175
|
+
re.match('UI.get() ? 1. : 2.;');
|
|
1176
|
+
// re.matched(0) == '1.' (matched at offset 11, NOT at start)
|
|
1177
|
+
// re.matchedPos().pos == 11 (NOT 0)
|
|
1178
|
+
// Consumer that does `ctx.pos += re.matched(0).length` advances by 2
|
|
1179
|
+
// but the matched bytes started 11 positions away — overwriting the
|
|
1180
|
+
// ident at the parser's actual cursor with the mid-buffer slice.
|
|
1181
|
+
|
|
1182
|
+
// RIGHT — both alts inside a non-capturing group, so `^` binds to both
|
|
1183
|
+
var re = new EReg('^(?:[0-9]+\\.[0-9]+|[0-9]+\\.(?![\\w.]))', '');
|
|
1184
|
+
re.match('UI.get() ? 1. : 2.;');
|
|
1185
|
+
// re.match returns false — `U` is not a digit, regex fails to match at start.
|
|
1186
|
+
```
|
|
1187
|
+
|
|
1188
|
+
**Defensive runtime check** for any code that builds regexes from `|`-alternations dynamically (or accepts user-supplied patterns): after `re.match(rest)`, verify `re.matchedPos().pos == 0`. If not, treat as no-match — the regex matched something but NOT at the cursor position.
|
|
1189
|
+
|
|
1190
|
+
```haxe
|
|
1191
|
+
if (!re.match(_rest) || re.matchedPos().pos != 0) {
|
|
1192
|
+
// either no match, or mid-buffer match — both are "not here"
|
|
1193
|
+
throw new ParseError(...);
|
|
1194
|
+
}
|
|
1195
|
+
```
|
|
1196
|
+
|
|
1197
|
+
**Symptom**: parser produces an AST where two nodes have identical source spans (`@from-to` ranges) at positions where one ident and one literal should sit. The mid-buffer match overwrites the cursor position, the cursor advances by the wrong amount, and the next parse step sees corrupted input. Reproducible only when the second alt's pattern HAPPENS to match somewhere later in the input — easy to miss in narrow unit tests.
|
|
1198
|
+
|
|
1199
|
+
Verified on Haxe 4.3.7, JS / EReg path.
|
|
1200
|
+
|
|
859
1201
|
## `EReg.escape` Is Broken on `--interp` Target
|
|
860
1202
|
|
|
861
1203
|
`EReg.escape` on Haxe `--interp` does NOT escape closing parens/brackets (likely more chars). Feeding the result into `new EReg(...)` produces a PCRE error `at N: unmatched closing parenthesis` at regex construction — before any match runs. Works correctly on `neko` and `js`.
|
|
@@ -1008,6 +1350,32 @@ return { name: _r_name }; // ok
|
|
|
1008
1350
|
|
|
1009
1351
|
Emit one `final _r_X:T = _f_X` re-bind per required struct field in macro-generated parse functions that build anonymous struct literals from nullable accumulator variables.
|
|
1010
1352
|
|
|
1353
|
+
## Macro Dead-Code `$v{flag}` Branches Still Type-Check — `cast` Required for Reflective Calls
|
|
1354
|
+
|
|
1355
|
+
A `$v{flag}` compile-time bool short-circuits at **runtime**, but the compiler type-checks the whole expression **before** dead-code elimination. A reflective call like `Type.enumParameters(x)` inside a `$v{forceInlineSep}`-gated `else if` fails the build whenever the macro inlines the call across consumers whose element type is a struct, not `EnumValue`:
|
|
1356
|
+
|
|
1357
|
+
```
|
|
1358
|
+
WriterLowering.hx:8365: characters 30-48 : pkg.grammar.haxe.trivia.HxMemberDeclT should be EnumValue
|
|
1359
|
+
WriterLowering.hx:8365: characters 30-48 : ... For function argument 'e'
|
|
1360
|
+
```
|
|
1361
|
+
|
|
1362
|
+
```haxe
|
|
1363
|
+
// WRONG — type-check fails on struct-shaped element types even though
|
|
1364
|
+
// the branch is never executed when forceInlineSep == false
|
|
1365
|
+
} else if (_si > 0 && $v{forceInlineSep}
|
|
1366
|
+
&& Type.enumParameters(_arr[_si - 1].node).length == 0
|
|
1367
|
+
&& Type.enumParameters(_t.node).length == 0) {
|
|
1368
|
+
|
|
1369
|
+
// RIGHT — cast suppresses compile-time type-check; runtime is gated by $v{flag}
|
|
1370
|
+
} else if (_si > 0 && $v{forceInlineSep}
|
|
1371
|
+
&& Type.enumParameters(cast _arr[_si - 1].node).length == 0
|
|
1372
|
+
&& Type.enumParameters(cast _t.node).length == 0) {
|
|
1373
|
+
```
|
|
1374
|
+
|
|
1375
|
+
**Rule of thumb:** in macro-generated code, any reflective call (`Type.enumParameters`, `Type.getClass`, `Reflect.field`, …) inside a `$v{...}`-gated branch needs `cast` on the argument when the surrounding engine inlines the call across consumers with different static types. The flag short-circuit is a runtime gate, not a compile-time one.
|
|
1376
|
+
|
|
1377
|
+
Verified on Haxe 4.3.7.
|
|
1378
|
+
|
|
1011
1379
|
## Helper Signatures Cannot Reference `Context.defineModule`-Synth Sub-Module Types
|
|
1012
1380
|
|
|
1013
1381
|
A test/consumer class trying to "force" a `@:build`-generated synth module via the `private static final _force:Class<MarkerClass> = MarkerClass;` pattern works for FQN references in METHOD BODIES, but NOT for FQN references in HELPER METHOD SIGNATURES of the same class. Method-signature typing precedes static-initializer execution, so the synth module isn't registered when the helper's parameter / return types are resolved.
|
|
@@ -1041,6 +1409,24 @@ This is a stricter cousin of the existing "macro-synth sub-module imports don't
|
|
|
1041
1409
|
|
|
1042
1410
|
Verified on Haxe 4.3.7.
|
|
1043
1411
|
|
|
1412
|
+
## Inline Local Functions Cannot Be Passed as Arguments
|
|
1413
|
+
|
|
1414
|
+
`inline function` on a local variable is a direct-call optimization only. The compiler cannot materialize a closure object for an inline-tagged local, so passing it as an argument to another function fails with `Cannot create closure on inline closure`.
|
|
1415
|
+
|
|
1416
|
+
```haxe
|
|
1417
|
+
// WRONG — Cannot create closure on inline closure
|
|
1418
|
+
inline function evalAt(x:Int):Int { return x * 2; }
|
|
1419
|
+
helper(evalAt); // Error: Cannot create closure on inline closure
|
|
1420
|
+
|
|
1421
|
+
// RIGHT — drop `inline`; direct calls at the same site are still inlined by the optimizer
|
|
1422
|
+
function evalAt(x:Int):Int { return x * 2; }
|
|
1423
|
+
helper(evalAt);
|
|
1424
|
+
```
|
|
1425
|
+
|
|
1426
|
+
This surfaces when factoring local helpers (e.g. a recursive `buildXTree`-style function that accepts a callback) and the helper was initially tagged `inline` per project style. `inline` is correct for locals called directly; remove it when the local needs to cross a function boundary as a parameter.
|
|
1427
|
+
|
|
1428
|
+
Verified on Haxe 4.3.7.
|
|
1429
|
+
|
|
1044
1430
|
## `for (x:Type in array)` — Type Annotations Are NOT Allowed In For Loops
|
|
1045
1431
|
|
|
1046
1432
|
Haxe's `for` loop iteration variable cannot carry an explicit type annotation. Unlike `var x:T = ...` or function parameters, the loop variable's type is always inferred from the iterable. Adding `:Type` produces `Expected )`.
|
|
@@ -1062,3 +1448,136 @@ for (member in iface.members) {
|
|
|
1062
1448
|
|
|
1063
1449
|
Same restriction applies to array-comprehension `[for (x in xs) ...]`. The "always specify types explicitly" rule (when applied as a project preference) does not apply to for-loop iteration variables — there is no syntax for it.
|
|
1064
1450
|
|
|
1451
|
+
## Adding Enum Ctor: Grep Sister Ctor Literal Across Project
|
|
1452
|
+
|
|
1453
|
+
Haxe's exhaustive switch detection fires `Unmatched patterns: <NewCtor>` at every switch missing an arm — but ONLY when that switch is reached during compile. Pre-flight grepping by function name (e.g. "find all walker functions") misses switches with non-obvious names. The reliable audit: grep on a sister-ctor literal that is already exhaustively handled.
|
|
1454
|
+
|
|
1455
|
+
```haxe
|
|
1456
|
+
// Adding `IfLineExceeds` to Doc enum.
|
|
1457
|
+
// WRONG audit: "find all flat-walking helpers"
|
|
1458
|
+
// → finds 6 sites, missed 3 hardline-checking helpers in same file
|
|
1459
|
+
// → build breaks with "Unmatched patterns: IfLineExceeds"
|
|
1460
|
+
|
|
1461
|
+
// RIGHT audit: grep on existing sister ctor
|
|
1462
|
+
// $ grep -rn 'case IfWidthExceeds' src/
|
|
1463
|
+
// → enumerates ALL 11 exhaustive switch sites uniformly
|
|
1464
|
+
```
|
|
1465
|
+
|
|
1466
|
+
**Rule**: when adding a new ctor to an enum used in multiple files, grep `case <SisterCtor>` on an existing exhaustively-handled ctor — this catches every switch regardless of function/variable name. Do this BEFORE committing the ctor; otherwise compile errors fire only when callers reach the unmatched switch and may surface late in the test cycle.
|
|
1467
|
+
|
|
1468
|
+
Verified on Haxe 4.3.7.
|
|
1469
|
+
|
|
1470
|
+
## Enum Constructor Parameters Cannot Have Metadata — Use a Typedef Instead
|
|
1471
|
+
|
|
1472
|
+
Haxe does NOT allow field-level metadata on individual enum constructor parameters. Attempting `Ctor(a:Int, @:lead("{") b:String)` is a parse-time error — `Unexpected @` — before any macro or typing runs.
|
|
1473
|
+
|
|
1474
|
+
```haxe
|
|
1475
|
+
// WRONG — parse error: Unexpected @ at the @ before the param
|
|
1476
|
+
enum E {
|
|
1477
|
+
Ctor(a:Int, @:lead("{") b:String);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// RIGHT — wrap params in a typedef; metadata is legal on typedef/anon-struct var fields
|
|
1481
|
+
typedef CtorBody = {
|
|
1482
|
+
var a:Int;
|
|
1483
|
+
@:lead("{") var b:String;
|
|
1484
|
+
};
|
|
1485
|
+
enum E {
|
|
1486
|
+
Ctor(v:CtorBody);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// ALSO RIGHT — metadata IS allowed on the constructor itself (not its params)
|
|
1490
|
+
enum E {
|
|
1491
|
+
@:deprecated Ctor(a:Int, b:String);
|
|
1492
|
+
}
|
|
1493
|
+
```
|
|
1494
|
+
|
|
1495
|
+
The restriction bites `@:build` / PEG-style DSLs that drive codegen from per-field metadata: the fix is the **ctor-wraps-typedef** pattern (one struct typedef per constructor), which also keeps per-field metadata working for macro inspection.
|
|
1496
|
+
|
|
1497
|
+
Verified on Haxe 4.3.x.
|
|
1498
|
+
|
|
1499
|
+
## `#if sys` Is FALSE on hxnodejs Builds — Use `#if (sys || nodejs)`
|
|
1500
|
+
|
|
1501
|
+
`-lib hxnodejs` does NOT define the `sys` conditional flag. Its `extraParams.hxml` uses `--macro allowPackage('sys')` (so `sys.io.File` / `sys.FileSystem` imports compile) plus `--macro define('nodejs')` (a separate flag). Code gated on `#if sys` runs ONLY on neko / hxcpp / eval / etc. — the JS/Node build silently takes the `#else` branch.
|
|
1502
|
+
|
|
1503
|
+
```haxe
|
|
1504
|
+
// WRONG — silently no-op on hxnodejs build despite sys.io.File being importable
|
|
1505
|
+
#if sys
|
|
1506
|
+
private static function stageProbeSource(s:String):Null<String> {
|
|
1507
|
+
sys.io.File.saveContent('/tmp/scratch.hx', s);
|
|
1508
|
+
return s;
|
|
1509
|
+
}
|
|
1510
|
+
#else
|
|
1511
|
+
private static function stageProbeSource(_:String):Null<String> return null;
|
|
1512
|
+
#end
|
|
1513
|
+
|
|
1514
|
+
// RIGHT — both system targets AND hxnodejs reach the implementation
|
|
1515
|
+
#if (sys || nodejs)
|
|
1516
|
+
private static function stageProbeSource(s:String):Null<String> {
|
|
1517
|
+
sys.io.File.saveContent('/tmp/scratch.hx', s);
|
|
1518
|
+
return s;
|
|
1519
|
+
}
|
|
1520
|
+
#else
|
|
1521
|
+
private static function stageProbeSource(_:String):Null<String> return null;
|
|
1522
|
+
#end
|
|
1523
|
+
```
|
|
1524
|
+
|
|
1525
|
+
**Symptom**: code that uses `sys.io.File` / `sys.FileSystem` compiles cleanly under `-lib hxnodejs` AND links into `bin/output.js`, but every `#if sys`-guarded code path is dead — the runtime takes `#else` every time. End-to-end smoke tests (run the binary, observe the side effect) catch it; unit tests using `Cli.run([...])` + exit-code-only assertions DON'T (exit stays 0 because the `#else` branch is well-behaved).
|
|
1526
|
+
|
|
1527
|
+
**Detection rule**: before writing a new `#if sys` block in a multi-target project, grep the file's existing import block for the conditional pattern. If imports use `#if (sys || nodejs)` (or `#if (sys || js)` etc.), match that pattern in every new block — single-source the target enumeration to the import guard.
|
|
1528
|
+
|
|
1529
|
+
Verified on Haxe 4.3.7 + hxnodejs 12.x.
|
|
1530
|
+
|
|
1531
|
+
## Throwing `Error`s in Hot Paths Eagerly Captures a V8 Stack Trace — Catastrophic in Exception-Based Control Flow
|
|
1532
|
+
|
|
1533
|
+
A value that extends `haxe.Exception` compiles to a native `js.lib.Error` on the JS target. **V8 captures a stack trace eagerly at `Error` construction** (up to `Error.stackTraceLimit` frames) — even when `.stack` is never read. In exception-based control flow that throws constantly — PEG parser backtracking, recursive-descent ordered-choice, deep retry loops — this per-throw stack capture DOMINATES runtime.
|
|
1534
|
+
|
|
1535
|
+
**Symptom**: code is ~1ms per line/item, orders of magnitude slower than the actual work. `node --prof` + `node --prof-process` reports a huge **"Unaccounted" fraction (~96%)** — the cost is in V8's native stack-collection machinery, attributed to no JS function. A `caught`/exception frame shows up in the tick list.
|
|
1536
|
+
|
|
1537
|
+
**Confirm in one step**: re-run with `node --stack-trace-limit=0`. A dramatic speedup (measured: 14.0s → 3.9s on a 10891-line parse) proves eager stack capture is the cost.
|
|
1538
|
+
|
|
1539
|
+
**Fix** — throw a single **pre-allocated stackless sentinel** instead of `new ParseError(...)` per throw. Allocated once → its stack is captured once (negligible) → reused on every throw with zero per-throw capture or allocation. Correct when the thrown payload is a pure control-flow signal (the real error is reconstructed elsewhere, e.g. from a "farthest failure position" tracker):
|
|
1540
|
+
|
|
1541
|
+
```haxe
|
|
1542
|
+
class ParseError extends haxe.Exception {
|
|
1543
|
+
// shared backtracking signal: one instance, stack captured once, never mutated
|
|
1544
|
+
public static final backtrack:ParseError = new ParseError(...);
|
|
1545
|
+
}
|
|
1546
|
+
// hot path:
|
|
1547
|
+
throw ParseError.backtrack; // reused — no capture
|
|
1548
|
+
// NOT: throw new ParseError(span, msg) // captures a stack on every throw
|
|
1549
|
+
```
|
|
1550
|
+
|
|
1551
|
+
Do **not** reach for a global `Error.stackTraceLimit = 0`: it is global mutable state, JS-only, and silently strips stacks from genuine errors. The shared-sentinel approach is local, target-agnostic, and thread-safe.
|
|
1552
|
+
|
|
1553
|
+
**Caveat — keep the sentinel immutable.** If it has a mutable field (e.g. a `source` an error-decorator writes), make sure no path mutates it while it is in flight — otherwise you reintroduce global mutable state / a cross-parse data race. When you argue "this comparison always selects the rebuild branch over the sentinel", verify it at the **init/boundary values**: a `maxFailPos > sentinel.span.from` check fails (`-1 > -1`) if the sentinel's span equals the tracker's `-1` init value. (Fix used a `(-2,-2)` sentinel span, strictly below the `-1` floor, so the check is always true.)
|
|
1554
|
+
|
|
1555
|
+
Verified on Haxe 4.3.7 + Node (a PEG parser): a 10891-line parse threw 603,361 `new ParseError` (~55/line), each capturing a stack → 14.0s; the stackless sentinel cut it to 3.5s (4×), allocations 603,361 → 1, with byte-identical surfaced errors.
|
|
1556
|
+
|
|
1557
|
+
## `untyped` Is a Reserved Keyword — Cannot Be a Variable Name
|
|
1558
|
+
|
|
1559
|
+
`untyped` is a Haxe keyword (the `untyped expr` escape hatch that disables type-checking), so it cannot be used as an identifier — a `var`/`final` named `untyped` fails at compile with `Keyword untyped cannot be used as variable name`. It reads like an ordinary descriptive word (e.g. a `final untyped:Array<Bool>` flag array), which is exactly why it slips in.
|
|
1560
|
+
|
|
1561
|
+
```haxe
|
|
1562
|
+
// WRONG — compile error: Keyword untyped cannot be used as variable name
|
|
1563
|
+
final untyped:Array<Bool> = [for (c in clauses) isUntypedCatch(c)];
|
|
1564
|
+
|
|
1565
|
+
// RIGHT — rename
|
|
1566
|
+
final untypedFlags:Array<Bool> = [for (c in clauses) isUntypedCatch(c)];
|
|
1567
|
+
```
|
|
1568
|
+
|
|
1569
|
+
Other sneaky-looking reserved words in the same trap (read like normal nouns/verbs but are keywords): `cast`, `dynamic`, `inline`, `extern`, `macro`, `operator`, `overload`, `using`, `abstract`. When a local name collides, rename it (`untypedFlags`, `castNode`, …). Verified on Haxe 4.3.7.
|
|
1570
|
+
|
|
1571
|
+
## Safe Cast `cast(v, T)` Returns `null` for a `null` Value — Only Throws on a Non-Null Mismatch
|
|
1572
|
+
|
|
1573
|
+
The runtime-checked cast `cast(value, Type)` does NOT unconditionally throw when the value isn't a `Type`. The generated check (Haxe `Boot.__cast`) is `value == null || isOfType(value, T) ? value : throw`. So:
|
|
1574
|
+
- `cast(null, SomeClass)` → returns `null` (no exception).
|
|
1575
|
+
- `cast(nonNullValueOfWrongType, SomeClass)` → throws.
|
|
1576
|
+
|
|
1577
|
+
```haxe
|
|
1578
|
+
var s:Null<String> = null;
|
|
1579
|
+
var x = cast(s, Sys); // returns null — does NOT throw
|
|
1580
|
+
var y = cast("hi", Sys); // throws — "hi" is a non-null String, not a Sys
|
|
1581
|
+
```
|
|
1582
|
+
|
|
1583
|
+
Implication when reasoning/messaging about an impossible cast (`cast(x, T)` where `x`'s type and `T` are unrelated): "always throws" / "guaranteed exception" is wrong for a value that may be `null`. The accurate framing is "the cast can never yield a usable `T`" (it throws for any non-null value, yields `null` for a null one). Distinct from `(v : T)` (compile-time ascription — a wrong type is a COMPILE error, never a runtime cast) and from the unchecked single-arg `cast v` (no runtime test at all). Verified on Haxe 4.3.7 / js.
|