@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 +4 -3
- package/docs/lookup-pattern.md +109 -0
- package/lib/analyzer.d.ts +67 -0
- package/lib/analyzer.js +314 -51
- package/package.json +2 -1
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
|
|
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,
|