@mnemonica/tactica 0.3.8 → 0.3.10

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:
@@ -467,7 +483,9 @@ The analyzer infers property types from constructor bodies and class members. Su
467
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 |
468
484
  | `this.x = data.field \|\| []` | type of the fallback expression |
469
485
  | `this.x = data.field ? a : b` | type of the truthy branch |
470
- | `Object.assign(this, data)` | all fields of `data`'s type annotation |
486
+ | `this.x = (<const>['a', 'b'])[0]` / `this.x = ([…] as const)[1]` / `this.x = trackedList[2]` | the element's literal type (`'a'`) — const-asserted literal arrays either spelling, or a tracked module const array (`const trackedList = <const>[…]`); non-numeric/out-of-range/spread/non-literal elements and general `<T>` assertions stay `unknown` |
487
+ | `Object.assign(this, data)` | all fields of `data`'s type annotation — inline literal or named alias/interface/class, resolved import-aware (F14 machinery) |
488
+ | `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 |
471
489
  | async / sync constructor functions | same rules |
472
490
 
473
491
  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.
@@ -476,7 +494,7 @@ When inference fails the property's type falls back to `unknown` (or `any` in so
476
494
 
477
495
  ### Referenced types in constructor signatures
478
496
 
479
- A constructor parameter that references a named type (`record: SharedShape`) resolves **through the importing file's own import statements** — relative and tsconfig-paths imports are followed to the declaration (chasing re-export barrels), and imported classes/interfaces/type aliases with extractable fields are expanded inline, **inherited members included**: `extends` chains are walked (depth-bounded, cycle-guarded) and parent fields merge with the declaration's own fields overriding on name clash. A field typed by a `typeof` query over a module-level const array (`status?: typeof statusList[number]`) expands to the element literal union (`status?: 'active' | 'closed'`); any other `typeof` source degrades that field to `unknown` — a bare `typeof name` is never emitted, since the generated file has no imports to resolve it against. An import is never shadowed by a same-named declaration elsewhere in the program. A reference that resolves not at all (a ghost name, declared nowhere) emits `unknown` — generated `.tactica/types.ts` carries no import statements of its own, so a bare unresolvable name is never emitted. A reference that resolves *ambiguously* — several of your project's files declare the name and no import chooses between them — is **fatal** like the mnemonica-graph identity law below: tactica prints the reference site and every declaration site, exits non-zero, and writes no output. An import anchoring the reference is never ambiguity (the import wins, duplicate declarations or not), and package-declared same-named types never count — a user-local declaration always wins over a node_modules `.d.ts`. Two same-named interfaces in one file are legal TypeScript merging, not ambiguity (see Known Limitations for the last-wins caveat).
497
+ A constructor parameter that references a named type (`record: SharedShape`) resolves **through the importing file's own import statements** — relative and tsconfig-paths imports are followed to the declaration (chasing re-export barrels), and imported classes/interfaces/type aliases with extractable fields are expanded inline, **inherited members included**: `extends` chains are walked (depth-bounded, cycle-guarded) and parent fields merge with the declaration's own fields overriding on name clash. A field typed by a `typeof` query over a module-level const array (`status?: typeof statusList[number]`) expands to the element literal union (`status?: 'active' | 'closed'`); the value-level twin — element access over a const-asserted literal array (`(<const>['active', 'not_active', 'hold'])[0]`, `([…] as const)[1]`, or a tracked module const used as `x[0]`) — infers the element's literal type the same way; any other `typeof` source degrades that field to `unknown` — a bare `typeof name` is never emitted, since the generated file has no imports to resolve it against. An import is never shadowed by a same-named declaration elsewhere in the program. A reference that resolves not at all (a ghost name, declared nowhere) emits `unknown` — generated `.tactica/types.ts` carries no import statements of its own, so a bare unresolvable name is never emitted. A reference that resolves *ambiguously* — several of your project's files declare the name and no import chooses between them — is **fatal** like the mnemonica-graph identity law below: tactica prints the reference site and every declaration site, exits non-zero, and writes no output. An import anchoring the reference is never ambiguity (the import wins, duplicate declarations or not), and package-declared same-named types never count — a user-local declaration always wins over a node_modules `.d.ts`. Two same-named interfaces in one file are legal TypeScript merging, not ambiguity (see Known Limitations for the last-wins caveat).
480
498
 
481
499
  **Ambiguous plain-TypeScript reference — fails, and how to fix it.** Two plain interfaces named `SharedShape` exist in different files, and a third file references a bare `SharedShape` with nothing anchoring it:
482
500
 
@@ -787,7 +805,7 @@ When enabled, tactica detects execution-flow patterns alongside type definitions
787
805
  - `scopeId` — the scopeId of the scope holding the wrap call site (fallback join).
788
806
  - `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).
789
807
  - `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.
790
- - `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.
808
+ - `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.
791
809
 
792
810
  ## Instrumentation Points
793
811
 
@@ -942,7 +960,7 @@ Mnemonica accepts the `exposeInstanceMethods` option at runtime, but tactica's `
942
960
 
943
961
  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`.
944
962
 
945
- 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`.
963
+ 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]`). The value-level twin — element access over a const-asserted literal array (`(<const>[…])[0]`, `([…] as const)[1]`, tracked `x[0]`) — infers the element literal under the same rules; non-numeric, out-of-range, spread, or non-literal elements and general `<T>` assertions (as opposed to `<const>`) stay `unknown`. 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.
946
964
 
947
965
  ### Same-file interface merging is last-wins
948
966
 
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,12 +138,35 @@ 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;
148
+ /**
149
+ * The literal type of one array element: a plain literal (optionally
150
+ * wrapped in `as const` / `satisfies` / assertion expressions) —
151
+ * string, numeric (unary `-`/`+` preserved), boolean, or null.
152
+ * Anything else yields undefined.
153
+ */
154
+ private literalTypeOfExpression;
155
+ /**
156
+ * F22: the const-assertion check shared by the value-level and
157
+ * declaration-level paths — `expr as const` and `<const>expr` parse
158
+ * identically (a TypeReferenceNode named 'const'). General `<T>expr`
159
+ * assertions never match.
160
+ */
161
+ private isConstAssertionType;
162
+ /**
163
+ * The array literal behind a value-level element access: inline
164
+ * (`(<const>[…])[0]`, `([…] as const)[1]`), parenthesized, or a
165
+ * tracked module const array (`const x = <const>[…]` / `x[0]`, F17
166
+ * tracking). Only const assertions are unwrapped — general
167
+ * assertions stay unknown (F22 scope boundary).
168
+ */
169
+ private constArrayLiteralOf;
146
170
  /**
147
171
  * Emit-type for `typeof name` when `name` is a tracked const array: the
148
172
  * union of its element literal types (`'active' | 'closed'`). Every
@@ -467,6 +491,13 @@ export declare class MnemonicaAnalyzer {
467
491
  * For chained calls like const X = define('A').define('B'), we map X -> A (the root type)
468
492
  */
469
493
  private trackVariableAssignment;
494
+ /**
495
+ * A `.define(...)` hop wrapped by another `.define(...)` call is not
496
+ * the value its const ends up holding — the OUTERMOST hop of the
497
+ * initializer chain is (define() returns the defined type's
498
+ * constructor). Only the outermost hop may bind the variable.
499
+ */
500
+ private isDeeperDefineHop;
470
501
  /**
471
502
  * Mirror a variable -> mnemonica fullPath binding into the per-file
472
503
  * value-scope map (graph identity law: `typeof X` and bare references
@@ -483,6 +514,59 @@ export declare class MnemonicaAnalyzer {
483
514
  * e.g., const user = new UserType() maps "user" -> "UserType"
484
515
  */
485
516
  private trackNewAssignment;
517
+ /**
518
+ * Bind the nearest enclosing `const/let/var X = …` to a mnemonica
519
+ * fullPath — the shared result-variable walker behind new/lookup/
520
+ * chain/fork/merge/call tracking (value scope: downstream references
521
+ * and `this.x = x` assignments resolve through the same binding).
522
+ */
523
+ private bindResultVariable;
524
+ /**
525
+ * Record an `instantiation` usage for a construction-shape call
526
+ * (chain tip / call / apply / fork / clone / merge —
527
+ * byte-indistinguishable from `new` until the deferred
528
+ * mechanism-kind revision). `constructorText` defaults to the callee
529
+ * expression text so the site stays readable without new fields;
530
+ * call/apply override it with the Ctor argument text.
531
+ */
532
+ private recordConstructionUsage;
533
+ /**
534
+ * Resolve the type a construction-chain tip call constructs:
535
+ * `new R(...).A(...)` constructs R.A; `await new R(...).A(...).B(...)`
536
+ * constructs R.A.B. The receiver is the nested chain (NewExpression
537
+ * base, then tip calls); exact fullPath first, and only when the root
538
+ * itself is unknown does the prop-name fallback law apply (so plain
539
+ * method calls on fresh instances never record a construction).
540
+ */
541
+ private resolveChainTipTypePath;
542
+ /**
543
+ * True when `expr` denotes a construction function imported from
544
+ * 'mnemonica' — the named-import form (`import { call } from
545
+ * 'mnemonica'`, aliases included) or a member of a tracked
546
+ * module-object alias (`mnemonica.call`). Userland call/apply/bind
547
+ * functions never match.
548
+ */
549
+ private isMnemonicaConstructionFn;
550
+ /**
551
+ * mnemonica call/apply(entity, Ctor, ...) / bind(entity, Ctor):
552
+ * resolve the Ctor argument (arg 1) to a graph fullPath through the
553
+ * same tiers as the `new` branch (value scope for identifiers,
554
+ * chain resolution for property accesses).
555
+ */
556
+ private resolveConstructionFnTypePath;
557
+ /**
558
+ * instance.fork(...) / instance.clone(...) on a tracked variable —
559
+ * runtime returns `this`, so the result carries the source type.
560
+ */
561
+ private resolveForkLikeTypePath;
562
+ /**
563
+ * Free utils forms: utils.merge(a, b, ...) (also the direct named
564
+ * import `merge(a, b)`) and the curried utils.fork(instance)(...).
565
+ * The result binds to arg 0's type — runtime returns a's lineage over
566
+ * b's context; a's fullPath is the honest approximation within the
567
+ * output contract (documented in README).
568
+ */
569
+ private resolveUtilsFnTypePath;
486
570
  /**
487
571
  * Process a @decorate() decorator
488
572
  */
@@ -666,6 +750,24 @@ export declare class MnemonicaAnalyzer {
666
750
  * undefined.
667
751
  */
668
752
  private resolveParameterAnnotationTypePath;
753
+ /**
754
+ * F20 cheap tier: the wrap argument is an identifier declared with an
755
+ * EXPLICIT type annotation (`let updateCommitted: LedgerUpdate;`
756
+ * assigned later in a flow the analyzer does not track). The
757
+ * annotation resolves through the same graph tiers as parameter
758
+ * annotations. Deliberately NOT flow-sensitive: an UNANNOTATED
759
+ * let/var still buckets unknown, and a const with an analyzable
760
+ * initializer stays the recommended discipline. The lookup walks the
761
+ * enclosing statement containers innermost-out, so a shadowing inner
762
+ * declaration wins.
763
+ */
764
+ private resolveVariableAnnotationTypePath;
765
+ /**
766
+ * First variable declaration carrying an explicit bare-identifier type
767
+ * annotation for `name` in the given statement list, resolved through
768
+ * the graph law.
769
+ */
770
+ private findAnnotatedVariableTypePath;
669
771
  /**
670
772
  * Resolve a wrap() argument to its function node without the type
671
773
  * checker: direct function expressions/arrows, or same-file bindings