@mnemonica/tactica 0.3.9 → 0.3.11

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
@@ -483,6 +483,7 @@ The analyzer infers property types from constructor bodies and class members. Su
483
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 |
484
484
  | `this.x = data.field \|\| []` | type of the fallback expression |
485
485
  | `this.x = data.field ? a : b` | type of the truthy branch |
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` |
486
487
  | `Object.assign(this, data)` | all fields of `data`'s type annotation — inline literal or named alias/interface/class, resolved import-aware (F14 machinery) |
487
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 |
488
489
  | async / sync constructor functions | same rules |
@@ -493,7 +494,7 @@ When inference fails the property's type falls back to `unknown` (or `any` in so
493
494
 
494
495
  ### Referenced types in constructor signatures
495
496
 
496
- 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).
497
498
 
498
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:
499
500
 
@@ -804,7 +805,7 @@ When enabled, tactica detects execution-flow patterns alongside type definitions
804
805
  - `scopeId` — the scopeId of the scope holding the wrap call site (fallback join).
805
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).
806
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.
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.
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; the annotation may be the type name or the generated nested-type alias (`UpdatePay_SomeTerminal`, mapped by the underscore→dotted naming law)); the wrap CALLEE identifier also follows a `let`/`var` binding to its first statically-visible in-scope assignment — the catch-guard pattern (`let fn; try { fn = new T(…) } catch { return } wrap(fn, …)`) attributes as `T`, while closure-only assignments and never-assigned bindings stay `unknown` (function/class boundaries are not crossed). One discipline covers both wrap arguments: assignment evidence beats an explicit annotation claim (`let fn: T`, or a wrapper arriving as an annotated parameter) beats `unknown` — the annotation pins the type only when the assignment is not resolvable (a userland call return, say). 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.
808
809
 
809
810
  ## Instrumentation Points
810
811
 
@@ -959,7 +960,7 @@ Mnemonica accepts the `exposeInstanceMethods` option at runtime, but tactica's `
959
960
 
960
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`.
961
962
 
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.
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.
963
964
 
964
965
  ### Same-file interface merging is last-wins
965
966
 
@@ -0,0 +1,109 @@
1
+ # Consuming tactica Output: The lookup Pattern
2
+
3
+ > **For application developers using tactica-generated types.**
4
+ > If you generated `.tactica/types.ts` and `.tactica/registry.ts` but still write `as unknown as` casts, read this.
5
+
6
+ ---
7
+
8
+ ## The Mistake Everyone Makes
9
+
10
+ You run tactica. It generates the files. You import the types. You still cast.
11
+
12
+ ```typescript
13
+ // ❌ WRONG — you generated the types but didn't use them properly
14
+ import { RequestData } from './collections/requestTypes.js';
15
+ import type { RequestData as RequestDataT } from '../../.tactica/types.js';
16
+
17
+ const requestData = new RequestData({ ... }) as unknown as RequestDataT;
18
+ ```
19
+
20
+ This is the most common failure mode. The types exist. The registry exists. But you're bridging them with a cast instead of using `lookup`.
21
+
22
+ ---
23
+
24
+ ## The Correct Way
25
+
26
+ ```typescript
27
+ // ✅ CORRECT — lookup uses the TypeRegistry augmentation
28
+ import { lookup } from 'mnemonica';
29
+
30
+ const RequestData = lookup('RequestData');
31
+ const requestData = new RequestData({ ... });
32
+ const routeData = new requestData.RouteData({ ... });
33
+ ```
34
+
35
+ `lookup('RequestData')` returns the constructor **typed through the registry**. TypeScript knows:
36
+ - The constructor signature
37
+ - The instance properties
38
+ - That the instance has `.RouteData`, `.PageData`, etc.
39
+
40
+ No cast needed. The registry does the work.
41
+
42
+ ---
43
+
44
+ ## Why Importing the Type Is Not Enough
45
+
46
+ `tactica` generates two files:
47
+
48
+ 1. `.tactica/types.ts` — exports like `export type RequestData = {...}`
49
+ 2. `.tactica/registry.ts` — augments `mnemonica.TypeRegistry`
50
+
51
+ Importing from `types.ts` gives you the **instance shape**. It does NOT give you the **constructor type** with sub-constructors.
52
+
53
+ The registry augmentation is what teaches TypeScript that `lookup('RequestData')` returns a constructor whose instances have `.RouteData`. Without `lookup`, you're just importing shapes and casting between them.
54
+
55
+ ---
56
+
57
+ ## Module-Level Usage
58
+
59
+ `lookup` is a runtime lookup, but it's safe to call at module level:
60
+
61
+ ```typescript
62
+ // ✅ Safe and correct
63
+ const RequestData = lookup('RequestData');
64
+ const EngineRequest = lookup('EngineRequest');
65
+
66
+ app.get('/', async () => {
67
+ const requestData = new RequestData({ ... });
68
+ });
69
+ ```
70
+
71
+ The returned constructor is the same object every time. There is no per-request overhead.
72
+
73
+ ---
74
+
75
+ ## For Decoration (Fastify, Express, etc.)
76
+
77
+ Use the same `lookup` result for decoration:
78
+
79
+ ```typescript
80
+ const RequestData = lookup('RequestData');
81
+
82
+ app.decorate('RequestData', RequestData);
83
+
84
+ // Later in a route handler:
85
+ const requestData = new RequestData({ ... });
86
+ ```
87
+
88
+ No need to import the raw constructor separately. `lookup` gives you the runtime object AND the type.
89
+
90
+ ---
91
+
92
+ ## Checklist
93
+
94
+ After running `tactica`:
95
+
96
+ - [ ] `.tactica/` directory exists with `types.ts` and `registry.ts`
97
+ - [ ] `tsconfig.json` includes `.tactica/**/*` in `include`
98
+ - [ ] You use `lookup('TypeName')` instead of direct imports
99
+ - [ ] You do NOT write `as unknown as` with mnemonica types
100
+ - [ ] You run `tsc --noEmit` to verify types resolve
101
+
102
+ If any box is unchecked, the integration is incomplete.
103
+
104
+ ---
105
+
106
+ ## See Also
107
+
108
+ - **Canonical typing guide:** [`core/docs/typed-lookup.md`](https://github.com/wentout/mnemonica/blob/master/docs/typed-lookup.md) — builder mode, the one-line `RegistryOf` bridge, and the augmented mode this repo generates for
109
+ - **Library landing:** [`core/README.md`](https://github.com/wentout/mnemonica) — the thesis, the four data mistakes, the three type-system paths
package/lib/analyzer.d.ts CHANGED
@@ -145,6 +145,28 @@ export declare class MnemonicaAnalyzer {
145
145
  * degrades the field to `unknown` rather than guessing.
146
146
  */
147
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;
148
170
  /**
149
171
  * Emit-type for `typeof name` when `name` is a tracked const array: the
150
172
  * union of its element literal types (`'active' | 'closed'`). Every
@@ -703,6 +725,40 @@ export declare class MnemonicaAnalyzer {
703
725
  * Resolve type from EDS call argument (best effort)
704
726
  */
705
727
  private resolveEDSArgumentType;
728
+ /**
729
+ * let-in-try: find the RIGHT-HAND SIDE of the first statically-visible
730
+ * assignment to `name` in the scope that declares it. The declaring
731
+ * container is found innermost-out (blocks, case clauses, the source
732
+ * file — the F20 walk); the scan recurses into nested blocks (try/
733
+ * catch/finally, if/else, loops, switch cases) but NEVER crosses
734
+ * function or class boundaries — an assignment inside a closure does
735
+ * not attribute. Returns undefined when the binding is declared but
736
+ * never assigned in scope (and stops there: an inner declaration
737
+ * shadows any outer binding).
738
+ */
739
+ private followScopeAssignment;
740
+ /**
741
+ * True when the statement list contains a `let`/`var`/`const`
742
+ * declaration for `name` (any initializer form).
743
+ */
744
+ private statementsDeclareVariable;
745
+ /**
746
+ * First `name = rhs` assignment in the statement list, recursing
747
+ * into nested in-scope blocks. Function and class bodies are
748
+ * boundaries and are not entered.
749
+ */
750
+ private findAssignmentRhsInStatements;
751
+ /**
752
+ * `name = rhs` as a direct expression statement.
753
+ */
754
+ private directAssignmentRhs;
755
+ /**
756
+ * Statement lists of the nested blocks that stay INSIDE the current
757
+ * scope — try/catch/finally, if/else, loops, switch cases, nested
758
+ * blocks, labeled statements. Function-like and class bodies are
759
+ * scope boundaries and yield nothing.
760
+ */
761
+ private nestedScopeBlocks;
706
762
  /**
707
763
  * Resolve the enclosing mnemonica scope of an EDS call site by walking
708
764
  * up the parent chain: nearest define()/lazy() call whose handler holds
@@ -720,6 +776,17 @@ export declare class MnemonicaAnalyzer {
720
776
  * metadata heuristic, not the identity-law surface.
721
777
  */
722
778
  private resolveWrapInstanceTypePath;
779
+ /**
780
+ * F24: resolve a bare-identifier annotation to a graph fullPath. The
781
+ * annotation may name the type directly (`LedgerUpdate`) or carry
782
+ * the GENERATED instance alias of a nested type
783
+ * (`UpdatePay_SomeTerminal`, imported from the generated types file
784
+ * via tsconfig paths) — not a graph node NAME. The name is tried
785
+ * as-is first, then its underscore→dotted form (the generated alias
786
+ * naming law; the same mapping scopes.json uses for annotations).
787
+ * Ambiguity and absence yield undefined.
788
+ */
789
+ private resolveAnnotationTypePath;
723
790
  /**
724
791
  * Resolve a bare-identifier type annotation of the nearest enclosing
725
792
  * function's parameter through the mnemonica-graph tiers (value scope,