@mnemonica/tactica 0.3.7 → 0.3.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/README.md CHANGED
@@ -169,6 +169,8 @@ declare module 'mnemonica' {
169
169
 
170
170
  **Recommended for new projects.** Explicit imports, better tree-shaking, no global namespace pollution.
171
171
 
172
+ **Instance-type inheritance.** Nested instance types flatten the WHOLE ancestor chain: each nested type is `ProtoFlat<ParentInstance, Self>` (mnemonica's `ProtoFlat` keeps every parent key except the ones `Self` overrides), and the parent instance type is itself a `ProtoFlat` over its own parent — so the ROOT's own constructor-arg fields (`uuid` in `PaymentRoot`) are directly present on every descendant instance type, at any depth, alongside intermediate ancestors' fields. If a root field appears missing on a nested type, the usual cause is a stale `.tactica` — regenerate; the composition itself is transitive by construction.
173
+
172
174
  ### Legacy global-augmentation mode (`--module-augmentation`)
173
175
 
174
176
  Emits one `.tactica/index.d.ts` whose contents live inside `declare global { … }`. Reference it from your code with:
@@ -222,7 +224,9 @@ const AdminType = UserType.define('AdminType', function (this: { role: string })
222
224
  });
223
225
  ```
224
226
 
225
- Chained calls (`define('A').define('B')`) and nested calls via variable references (`const A = define('A', …); A.define('B', …)`) are both supported.
227
+ Chained calls (`define('A').define('B')`) and nested calls via variable references (`const A = define('A', …); A.define('B', …)`) are both supported. A const holding a multi-hop initializer binds the LAST hop — `define()` returns the defined type's constructor, so `const Branch = Root.define('A', …).define('B', …)` makes `Branch` denote `Root.A.B` and `Branch.define('C', …)` nests C under B, exactly as the runtime does.
228
+
229
+ **Call-site ergonomics (recommended pattern):** annotate the root constructor's `this` with the generated instance type alias — `define('Widget', function (this: TWidgetInstance, data: …) {…})`, with `TWidgetInstance` imported from the generated types file. The common form is an intersection — `type TWidgetInstance = TWidgetArgs & TGeneratedRootInstance`. Tactica tolerates the self-reference: the annotation is never emitted into the generated type (so no circularity can form), and it is **ergonomic-only** — its members are never expanded, so the root's fields must be carried by the constructor body: `Object.assign(this, args)` (the identifier form extracts every field from the named args type) or explicit `this.x = …` assignments. Property extraction falls back to the body / data parameter whenever the alias is not analyzable (it lives in the excluded `.tactica/`). With the generated `types.ts`/`registry.ts` in place, `new (lookup('Widget'))(args)` — and `new Widget(args)` where the const merges with the generated type — is directly assignable to the instance type at call sites, zero casts. Regenerate `.tactica` after adding the annotation.
226
230
 
227
231
  ### `lazy()` definitions
228
232
 
@@ -237,6 +241,18 @@ const AdminType = UserType.lazy(() => class AdminType {
237
241
 
238
242
  All forms are recognized: free `lazy('Name', getter)`, method `Type.lazy(...)`, and chained `define('A').lazy('B', getter)`. The getter is followed, and the returned constructor is analyzed like a direct `define()` handler — properties and constructor parameters are extracted and emitted in `types.ts` / `registry.ts` like any other type.
239
243
 
244
+ ### Construction shapes beyond plain `new`
245
+
246
+ These usage shapes are recognized and attributed within the existing output contract (the `instantiation` usage kind and value-scope bindings — no new output fields):
247
+
248
+ - **Chain construction** — `new Root(...).Child(...)` and the single-await form `await new Root(...).A(...).B(...)`: the chain-tip call records an `instantiation` of the tip type (`Root.A.B`), supplying the creation-graph anchor, and the result variable binds to the TIP's type, not the root's. `await` is transparent everywhere on this page — `await call(...)`, `await apply(...)`, `await entry.fork(...)`, and async `bind` recognition all work identically.
249
+ - **`instance.fork(...)` / `instance.clone()`** (call forms) and **`instance.clone`** (the property form — core types it `readonly clone: this`) — runtime returns `this`, so the result variable binds to the source instance's type. Fork re-runs construction (hooks fire, a distinct instance on a distinct line), so these record an **`instantiation` usage** at the site — byte-indistinguishable from `new` until the deferred mechanism-kind revision; the result binding and the generic `methodCall`/`propertyRead` flow entries stay.
250
+ - **`utils.merge(a, b, ...)`** (also the direct named import, and the curried `utils.fork(instance)(...)`) — the result binds to **arg 0's type**: the runtime answer is a's lineage over b's context, and a's fullPath is the honest approximation within the contract. Merge is `fork(a)` over b's context, so it records an `instantiation` for a's type too; the curried `utils.fork` records at the invocation site, and the inner `utils.fork(instance)` binding line records its own entry — one per distinct source line.
251
+ - **`call(entity, Ctor, ...)` / `apply(entity, Ctor, args)`** — the mnemonica construction exports (import-aware: only actual `'mnemonica'` imports or members of a tracked module-object alias match; userland `call`/`apply` never do). The Ctor argument (arg 1) records an `instantiation`, and the result variable binds to the Ctor's type (runtime `InstanceResult<Merge<E, T>>` approximated by T). **Decorated classes resolve as the Ctor** through the same graph tiers — `@decorate() class C` is a known type, so `call(parent, C)` records and binds; an undecorated plain class has no graph entry, so the call binds nothing and records no usage (never a bare name). The free-call `decorate(Class)` form is not tracked (tactica recognizes the `@decorate` decorator syntax only).
252
+ - **`bind(entity, Ctor)`** — records no usage (it constructs nothing); the bound variable binds to the Ctor's type. Invoking the bound function later (`f(...)`, including `await f(...)`) is not followed.
253
+
254
+ Two boundaries stay deliberate: bindings surface only for references lexically AFTER the binding statement (a binding made inside a constructor body is not yet visible to that same handler's property extraction), and the mechanism distinction (`new` vs fork vs call) is not carried in the outputs — fork/clone/merge record as plain `instantiation` entries, byte-indistinguishable from `new`, until the deferred mechanism-kind contract revision lands. Consumers counting constructions should expect fork sites among them.
255
+
240
256
  ### Builder pattern on the imported module object
241
257
 
242
258
  The analyzer also recognizes the chainable `mnemonica` module object and aliases of it:
@@ -464,11 +480,15 @@ The analyzer infers property types from constructor bodies and class members. Su
464
480
  | `this.x = a + b` (where `a, b: number`) | `number` |
465
481
  | `` this.x = `${a} ${b}` `` | `string` |
466
482
  | `this.x = data.field` | type of `data.field` from the parameter annotation |
483
+ | `this.x = data.field` where `data: SomeNamedType` (alias/interface/class) | type of the field from the resolved declaration — import-aware, inherited members included; a bare `this.x = data` keeps the full expanded shape |
467
484
  | `this.x = data.field \|\| []` | type of the fallback expression |
468
485
  | `this.x = data.field ? a : b` | type of the truthy branch |
469
- | `Object.assign(this, data)` | all fields of `data`'s type annotation |
486
+ | `Object.assign(this, data)` | all fields of `data`'s type annotation — inline literal or named alias/interface/class, resolved import-aware (F14 machinery) |
487
+ | `this.x = boundVar` where `boundVar` holds a construction result (`new`/`lookup()`/chain/fork/merge/call) | the bound type's instance type (value-scope binding) — bindings surface lexically AFTER the binding statement; within the same constructor body a fresh binding is not yet visible to the handler's own extraction |
470
488
  | async / sync constructor functions | same rules |
471
489
 
490
+ An existing annotation always beats a weaker inference: `unknown` (and `unknown`-bearing guesses like `Array<unknown>`) never overwrites a known type — `Record<string, unknown>` IS a known type — and an overwrite never drops an optionality modifier.
491
+
472
492
  When inference fails the property's type falls back to `unknown` (or `any` in some Topologica paths) — safe, and you can refine manually.
473
493
 
474
494
  ### Referenced types in constructor signatures
@@ -784,7 +804,7 @@ When enabled, tactica detects execution-flow patterns alongside type definitions
784
804
  - `scopeId` — the scopeId of the scope holding the wrap call site (fallback join).
785
805
  - `wrapsTypePath` — the mnemonica fullPath of the instance argument, resolved through the scope-variable chain (innermost binding wins; an untyped local shadows a typed outer one rather than being guessed).
786
806
  - `via` — the location of the enclosing wrap site when the call is nested inside another wrapped body (or returns a function): the wrappers-graph generation chain is built from it.
787
- - `scope` — the enclosing mnemonica type path. Lexical scoping (the owning `define()`/`lazy()` handler or `@decorate()`-ed class) wins; a wrap site outside any handler — the fire-and-forget-wrapper shape — is attributed through its instance/context argument (a tracked assignment like `const holder = new Holder(…)`, or the enclosing function's parameter annotation resolved through the graph law), and nested sites (function-valued returns, lexically nested wraps) inherit the causing wrap site's scope down the `via` chain. Sites attributable to nothing stay under the `unknown` key with `scope` absent.
807
+ - `scope` — the enclosing mnemonica type path. Lexical scoping (the owning `define()`/`lazy()` handler or `@decorate()`-ed class) wins; a wrap site outside any handler — the fire-and-forget-wrapper shape — is attributed through its instance/context argument (a tracked assignment like `const holder = new Holder(…)`, the enclosing function's parameter annotation resolved through the graph law, or a `let`/`var`/`const` declaration with an EXPLICIT type annotation like `let updateCommitted: LedgerUpdate;` — declaration-site typing only, no flow analysis), and nested sites (function-valued returns, lexically nested wraps) inherit the causing wrap site's scope down the `via` chain. An UNANNOTATED `let`/`var` still buckets under `unknown` (the analyzer does not track assignments flow-sensitively) — a `const` with an analyzable initializer stays the recommended discipline. Sites attributable to nothing stay under the `unknown` key with `scope` absent.
788
808
 
789
809
  ## Instrumentation Points
790
810
 
@@ -939,7 +959,7 @@ Mnemonica accepts the `exposeInstanceMethods` option at runtime, but tactica's `
939
959
 
940
960
  The analyzer does not use `ts.Program.getTypeChecker()` for resolution. Plain TypeScript references in constructor signatures resolve through the importing file's import statements first, then its local declarations, and only then a unique same-named declaration across the scanned files — so a reference whose name is genuinely absent (or lives behind unresolvable specifiers) resolves to `unknown`, while a name several of your own files declare with no import to disambiguate fails hard (see above). References to mnemonica graph types follow the path-aware lookup law described above and fail hard instead of degrading to `unknown`.
941
961
 
942
- Two expansion boundaries stay name-based and deliberate: **heritage** — `extends` chains are walked through the same import-aware machinery (depth-capped, cycle-guarded), but bases that resolve to an external/node_modules declaration or to a non-identifier expression (mixin calls like `extends mixin(X)`) are not followed — their inherited fields simply stay absent, same as before the walk existed. And **`typeof` fields** — only module-level const arrays with statically visible literal elements expand to the literal union (cross-file only when the const is imported by the declaring file); every other `typeof` source degrades the field to `unknown`.
962
+ Two expansion boundaries stay name-based and deliberate: **heritage** — `extends` chains are walked through the same import-aware machinery (depth-capped, cycle-guarded), but bases that resolve to an external/node_modules declaration or to a non-identifier expression (mixin calls like `extends mixin(X)`) are not followed — their inherited fields simply stay absent, same as before the walk existed. And **`typeof` fields** — only module-level const arrays with statically visible literal elements expand to the literal union, with the index suffix consumed entirely (`typeof statusList[number]` emits exactly `'active' | 'not_active' | 'hold'`, never `'hold'[number]`). Any assertion spelling is tracked (`[…] as const`, `satisfies`, `<const>[…]`), and signed numeric literals keep their sign (`-1 | 1`). Cross-file only when the const is imported by the declaring file. Every other `typeof` source — and any indexed-access target that fails to resolve — degrades the whole expression to `unknown`: an index suffix glued onto a fallback type (`unknown[number]`) is invalid TypeScript and is never emitted.
943
963
 
944
964
  ### Same-file interface merging is last-wins
945
965
 
package/lib/analyzer.d.ts CHANGED
@@ -22,6 +22,7 @@ export declare class MnemonicaAnalyzer {
22
22
  private wrapEntryByNode;
23
23
  private variableToTypeMap;
24
24
  private moduleObjectVariables;
25
+ private mnemonicaNamedImports;
25
26
  private createTypesCollectionVariables;
26
27
  private collectionVariables;
27
28
  private collectionInfo;
@@ -137,10 +138,11 @@ export declare class MnemonicaAnalyzer {
137
138
  private findReferencedConstArray;
138
139
  /**
139
140
  * Element literal types of a tracked const array: every element must be
140
- * a plain literal (optionally wrapped in `as const` / `satisfies`) —
141
- * string, numeric, boolean, or null. Spreads, identifiers, and nested
142
- * arrays mean the union is not statically visible and yield undefined,
143
- * so the caller degrades the field to `unknown` rather than guessing.
141
+ * a plain literal (optionally wrapped in `as const` / `satisfies` /
142
+ * `<const>` assertions) — string, numeric (unary `-`/`+` preserved),
143
+ * boolean, or null. Spreads, identifiers, and nested arrays mean the
144
+ * union is not statically visible and yield undefined, so the caller
145
+ * degrades the field to `unknown` rather than guessing.
144
146
  */
145
147
  private literalTypesOfArray;
146
148
  /**
@@ -467,6 +469,13 @@ export declare class MnemonicaAnalyzer {
467
469
  * For chained calls like const X = define('A').define('B'), we map X -> A (the root type)
468
470
  */
469
471
  private trackVariableAssignment;
472
+ /**
473
+ * A `.define(...)` hop wrapped by another `.define(...)` call is not
474
+ * the value its const ends up holding — the OUTERMOST hop of the
475
+ * initializer chain is (define() returns the defined type's
476
+ * constructor). Only the outermost hop may bind the variable.
477
+ */
478
+ private isDeeperDefineHop;
470
479
  /**
471
480
  * Mirror a variable -> mnemonica fullPath binding into the per-file
472
481
  * value-scope map (graph identity law: `typeof X` and bare references
@@ -483,6 +492,59 @@ export declare class MnemonicaAnalyzer {
483
492
  * e.g., const user = new UserType() maps "user" -> "UserType"
484
493
  */
485
494
  private trackNewAssignment;
495
+ /**
496
+ * Bind the nearest enclosing `const/let/var X = …` to a mnemonica
497
+ * fullPath — the shared result-variable walker behind new/lookup/
498
+ * chain/fork/merge/call tracking (value scope: downstream references
499
+ * and `this.x = x` assignments resolve through the same binding).
500
+ */
501
+ private bindResultVariable;
502
+ /**
503
+ * Record an `instantiation` usage for a construction-shape call
504
+ * (chain tip / call / apply / fork / clone / merge —
505
+ * byte-indistinguishable from `new` until the deferred
506
+ * mechanism-kind revision). `constructorText` defaults to the callee
507
+ * expression text so the site stays readable without new fields;
508
+ * call/apply override it with the Ctor argument text.
509
+ */
510
+ private recordConstructionUsage;
511
+ /**
512
+ * Resolve the type a construction-chain tip call constructs:
513
+ * `new R(...).A(...)` constructs R.A; `await new R(...).A(...).B(...)`
514
+ * constructs R.A.B. The receiver is the nested chain (NewExpression
515
+ * base, then tip calls); exact fullPath first, and only when the root
516
+ * itself is unknown does the prop-name fallback law apply (so plain
517
+ * method calls on fresh instances never record a construction).
518
+ */
519
+ private resolveChainTipTypePath;
520
+ /**
521
+ * True when `expr` denotes a construction function imported from
522
+ * 'mnemonica' — the named-import form (`import { call } from
523
+ * 'mnemonica'`, aliases included) or a member of a tracked
524
+ * module-object alias (`mnemonica.call`). Userland call/apply/bind
525
+ * functions never match.
526
+ */
527
+ private isMnemonicaConstructionFn;
528
+ /**
529
+ * mnemonica call/apply(entity, Ctor, ...) / bind(entity, Ctor):
530
+ * resolve the Ctor argument (arg 1) to a graph fullPath through the
531
+ * same tiers as the `new` branch (value scope for identifiers,
532
+ * chain resolution for property accesses).
533
+ */
534
+ private resolveConstructionFnTypePath;
535
+ /**
536
+ * instance.fork(...) / instance.clone(...) on a tracked variable —
537
+ * runtime returns `this`, so the result carries the source type.
538
+ */
539
+ private resolveForkLikeTypePath;
540
+ /**
541
+ * Free utils forms: utils.merge(a, b, ...) (also the direct named
542
+ * import `merge(a, b)`) and the curried utils.fork(instance)(...).
543
+ * The result binds to arg 0's type — runtime returns a's lineage over
544
+ * b's context; a's fullPath is the honest approximation within the
545
+ * output contract (documented in README).
546
+ */
547
+ private resolveUtilsFnTypePath;
486
548
  /**
487
549
  * Process a @decorate() decorator
488
550
  */
@@ -666,6 +728,24 @@ export declare class MnemonicaAnalyzer {
666
728
  * undefined.
667
729
  */
668
730
  private resolveParameterAnnotationTypePath;
731
+ /**
732
+ * F20 cheap tier: the wrap argument is an identifier declared with an
733
+ * EXPLICIT type annotation (`let updateCommitted: LedgerUpdate;`
734
+ * assigned later in a flow the analyzer does not track). The
735
+ * annotation resolves through the same graph tiers as parameter
736
+ * annotations. Deliberately NOT flow-sensitive: an UNANNOTATED
737
+ * let/var still buckets unknown, and a const with an analyzable
738
+ * initializer stays the recommended discipline. The lookup walks the
739
+ * enclosing statement containers innermost-out, so a shadowing inner
740
+ * declaration wins.
741
+ */
742
+ private resolveVariableAnnotationTypePath;
743
+ /**
744
+ * First variable declaration carrying an explicit bare-identifier type
745
+ * annotation for `name` in the given statement list, resolved through
746
+ * the graph law.
747
+ */
748
+ private findAnnotatedVariableTypePath;
669
749
  /**
670
750
  * Resolve a wrap() argument to its function node without the type
671
751
  * checker: direct function expressions/arrows, or same-file bindings