@mnemonica/tactica 0.3.10 → 0.3.12

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
@@ -490,6 +490,8 @@ The analyzer infers property types from constructor bodies and class members. Su
490
490
 
491
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.
492
492
 
493
+ **Method/member return annotations** follow an emission law (0.2.0 behavior, restored after the F10 bare-name ban overshot): an annotation — or any type argument inside it — that resolves to a mnemonica graph type emits the generated alias (`SomeEntry[]` → `Array<EDSRoot_SomeEntry>`; generated-alias spellings resolve via the underscore→dotted law). `InstanceType<typeof X>` — directly or through a local alias (`XInstance = InstanceType<typeof X>`) — drops the wrapper and emits the generated alias (`Definitions_DefinitionEntry`), since the alias already IS the instance type; when X does not resolve to a graph type the WHOLE expression degrades to `unknown` — `InstanceType<unknown>` is invalid TypeScript (TS2344) and is never emitted. An outer generic that is a non-graph **ambient/lib** construct (`MapIterator`, lib helpers — not declared in any analyzed project file) is emitted **verbatim** with inner graph aliases resolved, since it needs no import in the generated file. A **project-local** non-graph name stays `unknown` — the self-contained `types.ts` can carry neither the bare name nor an import (ambiguity between same-named project declarations is the fatal class, as elsewhere). This law covers method/member return annotations; constructor-signature parameters keep their accepted mirror policy.
494
+
493
495
  When inference fails the property's type falls back to `unknown` (or `any` in some Topologica paths) — safe, and you can refine manually.
494
496
 
495
497
  ### Referenced types in constructor signatures
@@ -805,7 +807,7 @@ When enabled, tactica detects execution-flow patterns alongside type definitions
805
807
  - `scopeId` — the scopeId of the scope holding the wrap call site (fallback join).
806
808
  - `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).
807
809
  - `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.
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.
810
+ - `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.
809
811
 
810
812
  ## Instrumentation Points
811
813
 
@@ -960,7 +962,7 @@ Mnemonica accepts the `exposeInstanceMethods` option at runtime, but tactica's `
960
962
 
961
963
  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`.
962
964
 
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.
965
+ 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. A sibling emission law governs method/member return annotations (0.2.0 behavior restored after the F10 bare-name ban overshot): inner type arguments resolving to graph types emit the generated aliases; `InstanceType<typeof X>` (directly or via a local alias) drops the wrapper and emits the alias — X unresolvable degrades the WHOLE expression to `unknown`, never `InstanceType<unknown>` (invalid TS, TS2344); a non-graph **ambient/lib** outer generic (`MapIterator`, lib helpers — not declared in any analyzed project file) emits verbatim with inner aliases resolved; a **project-local** non-graph name stays `unknown` (no imports in the generated file; same-named ambiguity is the fatal class). Constructor-signature parameters keep their accepted mirror policy and are not covered.
964
966
 
965
967
  ### Same-file interface merging is last-wins
966
968
 
@@ -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
@@ -725,6 +725,40 @@ export declare class MnemonicaAnalyzer {
725
725
  * Resolve type from EDS call argument (best effort)
726
726
  */
727
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;
728
762
  /**
729
763
  * Resolve the enclosing mnemonica scope of an EDS call site by walking
730
764
  * up the parent chain: nearest define()/lazy() call whose handler holds
@@ -742,6 +776,25 @@ export declare class MnemonicaAnalyzer {
742
776
  * metadata heuristic, not the identity-law surface.
743
777
  */
744
778
  private resolveWrapInstanceTypePath;
779
+ /**
780
+ * Emission-law helper (0.2.0 restoration): is `name` declared in any
781
+ * ANALYZED PROJECT file? External/ambient files (.d.ts, node_modules)
782
+ * do not count. A name with no project declaration is an ambient/lib
783
+ * construct — safe to emit verbatim into the self-contained types.ts;
784
+ * a project-local name is not (no imports in the generated file).
785
+ */
786
+ private isProjectDeclaredTypeName;
787
+ /**
788
+ * F24: resolve a bare-identifier annotation to a graph fullPath. The
789
+ * annotation may name the type directly (`LedgerUpdate`) or carry
790
+ * the GENERATED instance alias of a nested type
791
+ * (`UpdatePay_SomeTerminal`, imported from the generated types file
792
+ * via tsconfig paths) — not a graph node NAME. The name is tried
793
+ * as-is first, then its underscore→dotted form (the generated alias
794
+ * naming law; the same mapping scopes.json uses for annotations).
795
+ * Ambiguity and absence yield undefined.
796
+ */
797
+ private resolveAnnotationTypePath;
745
798
  /**
746
799
  * Resolve a bare-identifier type annotation of the nearest enclosing
747
800
  * function's parameter through the mnemonica-graph tiers (value scope,