modality-ts 0.0.9 → 0.0.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.
Files changed (238) hide show
  1. package/README.md +11 -7
  2. package/dist/check/diagnostics/bounds.d.ts +2 -1
  3. package/dist/check/diagnostics/bounds.d.ts.map +1 -1
  4. package/dist/check/diagnostics/bounds.js +3 -3
  5. package/dist/check/diagnostics/bounds.js.map +1 -1
  6. package/dist/check/engine/check-model.d.ts +3 -0
  7. package/dist/check/engine/check-model.d.ts.map +1 -1
  8. package/dist/check/engine/check-model.js +408 -43
  9. package/dist/check/engine/check-model.js.map +1 -1
  10. package/dist/check/engine/model-api.d.ts.map +1 -1
  11. package/dist/check/engine/model-api.js +5 -3
  12. package/dist/check/engine/model-api.js.map +1 -1
  13. package/dist/check/engine/stabilize.d.ts +2 -1
  14. package/dist/check/engine/stabilize.d.ts.map +1 -1
  15. package/dist/check/engine/stabilize.js +31 -9
  16. package/dist/check/engine/stabilize.js.map +1 -1
  17. package/dist/check/engine/state-utils.d.ts +3 -2
  18. package/dist/check/engine/state-utils.d.ts.map +1 -1
  19. package/dist/check/engine/state-utils.js +20 -7
  20. package/dist/check/engine/state-utils.js.map +1 -1
  21. package/dist/check/engine/transitions.d.ts +9 -1
  22. package/dist/check/engine/transitions.d.ts.map +1 -1
  23. package/dist/check/engine/transitions.js +32 -5
  24. package/dist/check/engine/transitions.js.map +1 -1
  25. package/dist/check/index.d.ts +1 -1
  26. package/dist/check/index.d.ts.map +1 -1
  27. package/dist/check/index.js.map +1 -1
  28. package/dist/check/properties/finalize.d.ts +4 -3
  29. package/dist/check/properties/finalize.d.ts.map +1 -1
  30. package/dist/check/properties/finalize.js +45 -9
  31. package/dist/check/properties/finalize.js.map +1 -1
  32. package/dist/check/properties/leads-to.d.ts.map +1 -1
  33. package/dist/check/properties/leads-to.js +4 -3
  34. package/dist/check/properties/leads-to.js.map +1 -1
  35. package/dist/check/properties/observe.d.ts +4 -3
  36. package/dist/check/properties/observe.d.ts.map +1 -1
  37. package/dist/check/properties/observe.js +5 -5
  38. package/dist/check/properties/observe.js.map +1 -1
  39. package/dist/check/properties/reachable-from.d.ts +4 -2
  40. package/dist/check/properties/reachable-from.d.ts.map +1 -1
  41. package/dist/check/properties/reachable-from.js +2 -2
  42. package/dist/check/properties/reachable-from.js.map +1 -1
  43. package/dist/check/slicing/slice-model.d.ts +1 -0
  44. package/dist/check/slicing/slice-model.d.ts.map +1 -1
  45. package/dist/check/slicing/slice-model.js +38 -12
  46. package/dist/check/slicing/slice-model.js.map +1 -1
  47. package/dist/check/traces/trace.d.ts +7 -2
  48. package/dist/check/traces/trace.d.ts.map +1 -1
  49. package/dist/check/traces/trace.js +9 -4
  50. package/dist/check/traces/trace.js.map +1 -1
  51. package/dist/check/types.d.ts +83 -3
  52. package/dist/check/types.d.ts.map +1 -1
  53. package/dist/cli/cli.js +134 -42
  54. package/dist/cli/cli.js.map +1 -1
  55. package/dist/cli/defaults.d.ts +13 -0
  56. package/dist/cli/defaults.d.ts.map +1 -0
  57. package/dist/cli/defaults.js +57 -0
  58. package/dist/cli/defaults.js.map +1 -0
  59. package/dist/cli/features/check/command.d.ts +7 -0
  60. package/dist/cli/features/check/command.d.ts.map +1 -1
  61. package/dist/cli/features/check/command.js +90 -13
  62. package/dist/cli/features/check/command.js.map +1 -1
  63. package/dist/cli/features/conform/command.d.ts.map +1 -1
  64. package/dist/cli/features/conform/command.js +3 -1
  65. package/dist/cli/features/conform/command.js.map +1 -1
  66. package/dist/cli/features/export/command.js +12 -4
  67. package/dist/cli/features/export/command.js.map +1 -1
  68. package/dist/cli/features/extract/command.d.ts +2 -1
  69. package/dist/cli/features/extract/command.d.ts.map +1 -1
  70. package/dist/cli/features/extract/command.js +375 -94
  71. package/dist/cli/features/extract/command.js.map +1 -1
  72. package/dist/cli/features/init/command.d.ts +9 -0
  73. package/dist/cli/features/init/command.d.ts.map +1 -0
  74. package/dist/cli/features/init/command.js +23 -0
  75. package/dist/cli/features/init/command.js.map +1 -0
  76. package/dist/cli/features/init/index.d.ts +3 -0
  77. package/dist/cli/features/init/index.d.ts.map +1 -0
  78. package/dist/cli/features/init/index.js +2 -0
  79. package/dist/cli/features/init/index.js.map +1 -0
  80. package/dist/cli/harness/index.d.ts.map +1 -1
  81. package/dist/cli/harness/index.js +17 -4
  82. package/dist/cli/harness/index.js.map +1 -1
  83. package/dist/cli/registry/index.d.ts +4 -4
  84. package/dist/cli/registry/index.d.ts.map +1 -1
  85. package/dist/cli/registry/index.js +6 -4
  86. package/dist/cli/registry/index.js.map +1 -1
  87. package/dist/core/ir/domains.d.ts +2 -0
  88. package/dist/core/ir/domains.d.ts.map +1 -1
  89. package/dist/core/ir/domains.js +71 -0
  90. package/dist/core/ir/domains.js.map +1 -1
  91. package/dist/core/ir/validator.d.ts +4 -1
  92. package/dist/core/ir/validator.d.ts.map +1 -1
  93. package/dist/core/ir/validator.js +45 -12
  94. package/dist/core/ir/validator.js.map +1 -1
  95. package/dist/core/props/index.d.ts.map +1 -1
  96. package/dist/core/props/index.js.map +1 -1
  97. package/dist/core/report/types.d.ts +68 -0
  98. package/dist/core/report/types.d.ts.map +1 -1
  99. package/dist/extract/engine/pipeline/index.d.ts +3 -1
  100. package/dist/extract/engine/pipeline/index.d.ts.map +1 -1
  101. package/dist/extract/engine/pipeline/index.js +10 -23
  102. package/dist/extract/engine/pipeline/index.js.map +1 -1
  103. package/dist/extract/engine/spi/index.d.ts +38 -7
  104. package/dist/extract/engine/spi/index.d.ts.map +1 -1
  105. package/dist/extract/engine/spi/index.js +1 -1
  106. package/dist/extract/engine/spi/index.js.map +1 -1
  107. package/dist/extract/engine/ts/domains.d.ts +1 -1
  108. package/dist/extract/engine/ts/domains.d.ts.map +1 -1
  109. package/dist/extract/engine/ts/domains.js +25 -17
  110. package/dist/extract/engine/ts/domains.js.map +1 -1
  111. package/dist/extract/engine/ts/react-source-transitions.d.ts +2 -1
  112. package/dist/extract/engine/ts/react-source-transitions.d.ts.map +1 -1
  113. package/dist/extract/engine/ts/react-source-transitions.js +17 -7
  114. package/dist/extract/engine/ts/react-source-transitions.js.map +1 -1
  115. package/dist/extract/engine/ts/routes.d.ts +2 -2
  116. package/dist/extract/engine/ts/routes.d.ts.map +1 -1
  117. package/dist/extract/engine/ts/routes.js +5 -24
  118. package/dist/extract/engine/ts/routes.js.map +1 -1
  119. package/dist/extract/engine/ts/static-navigation.d.ts +2 -1
  120. package/dist/extract/engine/ts/static-navigation.d.ts.map +1 -1
  121. package/dist/extract/engine/ts/static-navigation.js +30 -20
  122. package/dist/extract/engine/ts/static-navigation.js.map +1 -1
  123. package/dist/extract/engine/ts/transition/async.d.ts +3 -10
  124. package/dist/extract/engine/ts/transition/async.d.ts.map +1 -1
  125. package/dist/extract/engine/ts/transition/async.js +60 -59
  126. package/dist/extract/engine/ts/transition/async.js.map +1 -1
  127. package/dist/extract/engine/ts/transition/effects.d.ts +2 -23
  128. package/dist/extract/engine/ts/transition/effects.d.ts.map +1 -1
  129. package/dist/extract/engine/ts/transition/effects.js +10 -171
  130. package/dist/extract/engine/ts/transition/effects.js.map +1 -1
  131. package/dist/extract/engine/ts/transition/expressions.d.ts +1 -0
  132. package/dist/extract/engine/ts/transition/expressions.d.ts.map +1 -1
  133. package/dist/extract/engine/ts/transition/expressions.js +24 -1
  134. package/dist/extract/engine/ts/transition/expressions.js.map +1 -1
  135. package/dist/extract/engine/ts/transition/guards.d.ts.map +1 -1
  136. package/dist/extract/engine/ts/transition/guards.js +11 -2
  137. package/dist/extract/engine/ts/transition/guards.js.map +1 -1
  138. package/dist/extract/engine/ts/transition/handlers.d.ts +2 -23
  139. package/dist/extract/engine/ts/transition/handlers.d.ts.map +1 -1
  140. package/dist/extract/engine/ts/transition/handlers.js +46 -188
  141. package/dist/extract/engine/ts/transition/handlers.js.map +1 -1
  142. package/dist/extract/engine/ts/transition/locals.d.ts +4 -0
  143. package/dist/extract/engine/ts/transition/locals.d.ts.map +1 -1
  144. package/dist/extract/engine/ts/transition/locals.js +27 -16
  145. package/dist/extract/engine/ts/transition/locals.js.map +1 -1
  146. package/dist/extract/engine/ts/transition/navigation.d.ts +7 -5
  147. package/dist/extract/engine/ts/transition/navigation.d.ts.map +1 -1
  148. package/dist/extract/engine/ts/transition/navigation.js +85 -41
  149. package/dist/extract/engine/ts/transition/navigation.js.map +1 -1
  150. package/dist/extract/engine/ts/transition/plugin-calls.d.ts.map +1 -1
  151. package/dist/extract/engine/ts/transition/plugin-calls.js +1 -1
  152. package/dist/extract/engine/ts/transition/plugin-calls.js.map +1 -1
  153. package/dist/extract/engine/ts/transition/statement-summary.d.ts +34 -0
  154. package/dist/extract/engine/ts/transition/statement-summary.d.ts.map +1 -0
  155. package/dist/extract/engine/ts/transition/statement-summary.js +502 -0
  156. package/dist/extract/engine/ts/transition/statement-summary.js.map +1 -0
  157. package/dist/extract/engine/ts/transition/timers.d.ts.map +1 -1
  158. package/dist/extract/engine/ts/transition/timers.js +7 -17
  159. package/dist/extract/engine/ts/transition/timers.js.map +1 -1
  160. package/dist/extract/sources/jotai/domains.d.ts +2 -1
  161. package/dist/extract/sources/jotai/domains.d.ts.map +1 -1
  162. package/dist/extract/sources/jotai/domains.js +2 -164
  163. package/dist/extract/sources/jotai/domains.js.map +1 -1
  164. package/dist/extract/sources/jotai/harness.d.ts.map +1 -1
  165. package/dist/extract/sources/jotai/harness.js +5 -2
  166. package/dist/extract/sources/jotai/harness.js.map +1 -1
  167. package/dist/extract/sources/jotai/index.d.ts +3 -3
  168. package/dist/extract/sources/jotai/index.d.ts.map +1 -1
  169. package/dist/extract/sources/jotai/index.js +2 -18
  170. package/dist/extract/sources/jotai/index.js.map +1 -1
  171. package/dist/extract/sources/jotai/plugin.d.ts +4 -0
  172. package/dist/extract/sources/jotai/plugin.d.ts.map +1 -0
  173. package/dist/extract/sources/jotai/plugin.js +19 -0
  174. package/dist/extract/sources/jotai/plugin.js.map +1 -0
  175. package/dist/extract/sources/jotai/transitions.d.ts +15 -0
  176. package/dist/extract/sources/jotai/transitions.d.ts.map +1 -0
  177. package/dist/extract/sources/jotai/transitions.js +41 -0
  178. package/dist/extract/sources/jotai/transitions.js.map +1 -0
  179. package/dist/extract/sources/router/discover.d.ts +9 -0
  180. package/dist/extract/sources/router/discover.d.ts.map +1 -0
  181. package/dist/extract/sources/router/discover.js +135 -0
  182. package/dist/extract/sources/router/discover.js.map +1 -0
  183. package/dist/extract/sources/router/index.d.ts +7 -3
  184. package/dist/extract/sources/router/index.d.ts.map +1 -1
  185. package/dist/extract/sources/router/index.js +21 -12
  186. package/dist/extract/sources/router/index.js.map +1 -1
  187. package/dist/extract/sources/router/navigation.d.ts +3 -4
  188. package/dist/extract/sources/router/navigation.d.ts.map +1 -1
  189. package/dist/extract/sources/router/navigation.js +33 -1
  190. package/dist/extract/sources/router/navigation.js.map +1 -1
  191. package/dist/extract/sources/router/redirects.d.ts +4 -0
  192. package/dist/extract/sources/router/redirects.d.ts.map +1 -0
  193. package/dist/extract/sources/router/redirects.js +37 -0
  194. package/dist/extract/sources/router/redirects.js.map +1 -0
  195. package/dist/extract/sources/router/routes.d.ts +2 -2
  196. package/dist/extract/sources/router/routes.d.ts.map +1 -1
  197. package/dist/extract/sources/router/routes.js +24 -6
  198. package/dist/extract/sources/router/routes.js.map +1 -1
  199. package/dist/extract/sources/shared/react-transition-extract.d.ts +14 -0
  200. package/dist/extract/sources/shared/react-transition-extract.d.ts.map +1 -0
  201. package/dist/extract/sources/shared/react-transition-extract.js +23 -0
  202. package/dist/extract/sources/shared/react-transition-extract.js.map +1 -0
  203. package/dist/extract/sources/swr/domains.d.ts +3 -2
  204. package/dist/extract/sources/swr/domains.d.ts.map +1 -1
  205. package/dist/extract/sources/swr/domains.js +3 -96
  206. package/dist/extract/sources/swr/domains.js.map +1 -1
  207. package/dist/extract/sources/swr/harness.d.ts.map +1 -1
  208. package/dist/extract/sources/swr/harness.js +13 -6
  209. package/dist/extract/sources/swr/harness.js.map +1 -1
  210. package/dist/extract/sources/swr/index.d.ts +3 -3
  211. package/dist/extract/sources/swr/index.d.ts.map +1 -1
  212. package/dist/extract/sources/swr/index.js +2 -20
  213. package/dist/extract/sources/swr/index.js.map +1 -1
  214. package/dist/extract/sources/swr/plugin.d.ts +4 -0
  215. package/dist/extract/sources/swr/plugin.d.ts.map +1 -0
  216. package/dist/extract/sources/swr/plugin.js +21 -0
  217. package/dist/extract/sources/swr/plugin.js.map +1 -0
  218. package/dist/extract/sources/swr/template.js +1 -1
  219. package/dist/extract/sources/swr/template.js.map +1 -1
  220. package/dist/extract/sources/swr/transitions.d.ts +15 -0
  221. package/dist/extract/sources/swr/transitions.d.ts.map +1 -0
  222. package/dist/extract/sources/swr/transitions.js +43 -0
  223. package/dist/extract/sources/swr/transitions.js.map +1 -0
  224. package/dist/extract/sources/swr/writes.d.ts.map +1 -1
  225. package/dist/extract/sources/swr/writes.js +1 -1
  226. package/dist/extract/sources/swr/writes.js.map +1 -1
  227. package/dist/extract/sources/use-state/harness.d.ts.map +1 -1
  228. package/dist/extract/sources/use-state/harness.js +5 -2
  229. package/dist/extract/sources/use-state/harness.js.map +1 -1
  230. package/dist/extract/sources/use-state/index.d.ts.map +1 -1
  231. package/dist/extract/sources/use-state/index.js +1 -166
  232. package/dist/extract/sources/use-state/index.js.map +1 -1
  233. package/dist/extract/sources/use-state/transitions.d.ts.map +1 -1
  234. package/dist/extract/sources/use-state/transitions.js +17 -9
  235. package/dist/extract/sources/use-state/transitions.js.map +1 -1
  236. package/dist/extract/sources/use-state/types.d.ts +1 -0
  237. package/dist/extract/sources/use-state/types.d.ts.map +1 -1
  238. package/package.json +2 -2
@@ -1,22 +1,23 @@
1
1
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { createHash } from "node:crypto";
3
- import { dirname, join, parse, resolve } from "node:path";
3
+ import { dirname, extname, join, parse, resolve } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import * as ts from "typescript";
6
6
  import { runExtractionPipeline } from "modality-ts/extract";
7
- import { canonicalJson, parseModelArtifact, } from "modality-ts/core";
8
- import { routerSource } from "modality-ts/extract/sources/router";
7
+ import { canonicalJson, collectTokenDomainPaths, domainCardinality, parseModelArtifact, } from "modality-ts/core";
8
+ import { parseReactRouterRoutes, routerSource, } from "modality-ts/extract/sources/router";
9
9
  import { emitAppModel } from "../../codegen/model.js";
10
10
  import { loadAndApplyOverlay, loadOverlaySpec } from "../../overlay.js";
11
11
  import { createBuiltinModalityRegistry } from "../../registry/index.js";
12
12
  export async function runExtractCommand(options) {
13
- const project = await loadExtractionProject(options.sourcePath);
14
- const config = await loadModalityConfig(options.configPath ?? (await findNearestConfig(project.configStartDir)));
13
+ const sourcePaths = normalizedSourcePaths(options);
14
+ const projectBase = await loadExtractionProject(sourcePaths);
15
+ const config = await loadModalityConfig(options.configPath ?? (await findNearestConfig(projectBase.configStartDir)));
15
16
  const route = options.route ?? config.route ?? "/";
16
17
  const appModelPath = options.appModelPath ?? `${dirname(options.modelPath)}/app.model.ts`;
17
18
  const packageJsonPath = options.packageJsonPath ??
18
19
  config.packageJsonPath ??
19
- (await findNearestPackageJson(project.configStartDir));
20
+ (await findNearestPackageJson(projectBase.configStartDir));
20
21
  const dependencies = await readPackageDependencies(packageJsonPath);
21
22
  const registry = createBuiltinModalityRegistry({
22
23
  dependencies,
@@ -30,6 +31,9 @@ export async function runExtractCommand(options) {
30
31
  ],
31
32
  routerPlugin: options.routerPlugin ?? config.routerPlugin,
32
33
  });
34
+ const routerAdapter = registry.routerPlugin ?? routerSource();
35
+ const project = await attachRouteInventory(projectBase, routerAdapter);
36
+ const routePatterns = project.inventory.routes.map((node) => node.pattern);
33
37
  const effectApis = uniqueStrings([
34
38
  ...(config.effectApis ?? []),
35
39
  ...(options.effectApis ?? []),
@@ -46,27 +50,15 @@ export async function runExtractCommand(options) {
46
50
  sourceText: project.sourceText,
47
51
  fileName: project.entryFile,
48
52
  route,
49
- routePatterns: project.routes,
53
+ routePatterns,
50
54
  effectApis,
51
55
  sourcePlugins: registry.sourcePlugins,
52
56
  routerPlugin: registry.routerPlugin,
57
+ inventory: project.inventory,
53
58
  });
54
59
  const transitions = [...pipeline.transitions];
55
- const discoveredRoutes = uniqueStrings([
56
- route,
57
- ...project.routes,
58
- ...transitionNavigatedRoutes(transitions),
59
- ]);
60
- const defaultRouter = routerSource();
61
- const routeVars = registry.routerPlugin
62
- ? registry.routerPlugin.routeVars(discoveredRoutes, {
63
- route,
64
- bounds: { maxHistory: 4 },
65
- })
66
- : defaultRouter.routeVars(discoveredRoutes, {
67
- route,
68
- bounds: { maxHistory: 4 },
69
- });
60
+ const lowering = buildLocationLowering(transitions, routerAdapter, project.inventory);
61
+ const routeVars = routerAdapter.locationVars(project.inventory, { route, bounds: { maxHistory: 4 } }, lowering);
70
62
  const templateVars = pipeline.templateFragments.flatMap((fragment) => fragment.vars);
71
63
  const stateVars = refineAssignedLiteralDomains([...pipeline.stateVars, ...templateVars], transitions);
72
64
  const extractedModel = {
@@ -110,7 +102,7 @@ export async function runExtractCommand(options) {
110
102
  extractionCaveats,
111
103
  },
112
104
  };
113
- const report = createExtractionReport(project.sourceFiles, model, warnings, overlay.ignoredVars, options.now ?? new Date());
105
+ const report = createExtractionReport(project.sourceFiles, model, warnings, overlay.ignoredVars, options.now ?? new Date(), project.inventory);
114
106
  await mkdir(dirname(options.modelPath), { recursive: true });
115
107
  await writeFile(options.modelPath, `${canonicalJson(model)}\n`, "utf8");
116
108
  await mkdir(dirname(appModelPath), { recursive: true });
@@ -122,11 +114,42 @@ export async function runExtractCommand(options) {
122
114
  if (options.expectModelPath) {
123
115
  await assertMatchesExpectedModel(model, options.expectModelPath);
124
116
  }
117
+ const stateSpaceLine = (() => {
118
+ const contributors = report.stateContributors;
119
+ if (!contributors)
120
+ return undefined;
121
+ const { totalBits, topVars } = contributors;
122
+ const top = topVars
123
+ .slice(0, 3)
124
+ .map((v) => `${v.varId}(${v.bits.toFixed(1)})`)
125
+ .join(",");
126
+ return `state-space≈${totalBits.toFixed(1)}bits top:${top}`;
127
+ })();
128
+ const coarseDomainsLine = (() => {
129
+ const entries = report.coarseDomains ?? [];
130
+ if (entries.length === 0)
131
+ return undefined;
132
+ const count = entries.reduce((sum, entry) => sum + entry.paths.length, 0);
133
+ const first = entries[0];
134
+ if (!first)
135
+ return undefined;
136
+ const examplePath = first.paths[0];
137
+ return `coarse-domains=${count} e.g. ${first.varId}[${examplePath ?? ""}]`;
138
+ })();
139
+ const routeCoverageLine = (() => {
140
+ const coverage = report.routeCoverage;
141
+ if (!coverage || coverage.configured === 0)
142
+ return undefined;
143
+ return formatRouteCoverageLine(coverage);
144
+ })();
125
145
  return {
126
146
  model,
127
147
  report,
128
148
  lines: [
129
149
  `extracted vars=${pipeline.stateVars.length + pipeline.templateFragments.flatMap((fragment) => fragment.vars).length} transitions=${transitions.length}`,
150
+ ...(stateSpaceLine ? [stateSpaceLine] : []),
151
+ ...(routeCoverageLine ? [routeCoverageLine] : []),
152
+ ...(coarseDomainsLine ? [coarseDomainsLine] : []),
130
153
  `plugins=${registry.plugins.map((plugin) => `${plugin.kind}:${plugin.id}@${plugin.version}`).join(",") || "none"}`,
131
154
  `model=${options.modelPath}`,
132
155
  `appModel=${appModelPath}`,
@@ -144,8 +167,22 @@ export async function runExtractCommand(options) {
144
167
  ],
145
168
  };
146
169
  }
147
- async function loadExtractionProject(sourcePath) {
148
- const resolved = resolve(sourcePath);
170
+ function normalizedSourcePaths(options) {
171
+ const sourcePaths = options.sourcePaths ?? [];
172
+ const paths = [
173
+ ...sourcePaths,
174
+ ...(options.sourcePath ? [options.sourcePath] : []),
175
+ ];
176
+ if (paths.length === 0)
177
+ throw new Error("Missing source.tsx path");
178
+ return uniqueStrings(paths.map((path) => resolve(path)));
179
+ }
180
+ async function loadExtractionProject(sourcePaths) {
181
+ if (sourcePaths.length > 1)
182
+ return loadMultiFileExtractionProject(sourcePaths);
183
+ const resolved = sourcePaths[0];
184
+ if (!resolved)
185
+ throw new Error("extract requires at least one source path");
149
186
  const info = await stat(resolved);
150
187
  if (!info.isDirectory()) {
151
188
  const source = await readFile(resolved, "utf8");
@@ -156,7 +193,7 @@ async function loadExtractionProject(sourcePath) {
156
193
  sourceText: imported.sources.map((entry) => entry.text).join("\n"),
157
194
  sourceFiles: imported.sources.map((entry) => entry.path),
158
195
  sources: imported.sources,
159
- routes: [],
196
+ inventory: { routes: [] },
160
197
  effectApis: fetchEffectApis(imported.sources.map((entry) => entry.text).join("\n")),
161
198
  configStartDir: dirname(resolved),
162
199
  };
@@ -166,6 +203,7 @@ async function loadExtractionProject(sourcePath) {
166
203
  const rootPath = join(resolved, "app", "root.tsx");
167
204
  const roots = await existingFiles([rootPath]);
168
205
  const entries = [
206
+ { path: routesPath, text: await readFile(routesPath, "utf8") },
169
207
  ...(await Promise.all(roots.map(async (path) => ({ path, text: await readFile(path, "utf8") })))),
170
208
  ...(await Promise.all(routeEntries.map(async (entry) => ({
171
209
  path: resolve(dirname(routesPath), entry.file),
@@ -180,17 +218,79 @@ async function loadExtractionProject(sourcePath) {
180
218
  sourceText,
181
219
  sourceFiles: imported.sources.map((entry) => entry.path),
182
220
  sources: imported.sources,
183
- routes: uniqueStrings(routeEntries.map((entry) => entry.pattern)),
221
+ inventory: { routes: [] },
184
222
  effectApis: fetchEffectApis(sourceText),
185
223
  configStartDir: resolved,
186
224
  };
187
225
  }
226
+ async function loadMultiFileExtractionProject(sourcePaths) {
227
+ const projects = await Promise.all(sourcePaths.map((sourcePath) => loadExtractionProject([sourcePath])));
228
+ const sourcesByPath = new Map();
229
+ for (const project of projects) {
230
+ for (const source of project.sources) {
231
+ sourcesByPath.set(source.path, source);
232
+ }
233
+ }
234
+ const sources = [...sourcesByPath.values()].sort((left, right) => left.path.localeCompare(right.path));
235
+ const sourceText = sources.map((entry) => entry.text).join("\n");
236
+ return {
237
+ entryFile: projects.map((project) => project.entryFile).join(","),
238
+ sourceText,
239
+ sourceFiles: sources.map((entry) => entry.path),
240
+ sources,
241
+ inventory: { routes: [] },
242
+ effectApis: uniqueStrings([
243
+ ...projects.flatMap((project) => project.effectApis),
244
+ ...fetchEffectApis(sourceText),
245
+ ]),
246
+ configStartDir: commonAncestor(projects.map((project) => project.configStartDir)),
247
+ };
248
+ }
249
+ async function attachRouteInventory(project, adapter) {
250
+ const files = [...project.sources];
251
+ const manifestPath = files.find((file) => file.path.endsWith("routes.ts"))?.path ??
252
+ (await findNearestRoutesManifest(project.configStartDir));
253
+ if (manifestPath &&
254
+ !files.some((file) => resolve(file.path) === resolve(manifestPath))) {
255
+ files.push({
256
+ path: manifestPath,
257
+ text: await readFile(manifestPath, "utf8"),
258
+ });
259
+ }
260
+ const inventory = await adapter.discoverRoutes({
261
+ rootDir: project.configStartDir,
262
+ files,
263
+ readFile: (path) => readFile(path, "utf8"),
264
+ });
265
+ return { ...project, inventory };
266
+ }
267
+ async function findNearestRoutesManifest(startDir) {
268
+ let current = resolve(startDir);
269
+ for (let depth = 0; depth < 8; depth += 1) {
270
+ const candidate = join(current, "app", "routes.ts");
271
+ try {
272
+ const info = await stat(candidate);
273
+ if (info.isFile())
274
+ return candidate;
275
+ }
276
+ catch {
277
+ // keep walking upward
278
+ }
279
+ const parent = dirname(current);
280
+ if (parent === current)
281
+ break;
282
+ current = parent;
283
+ }
284
+ return undefined;
285
+ }
188
286
  async function sourceWithLocalImports(entries, tsconfig) {
189
287
  const seen = new Set();
190
288
  const sources = [];
191
289
  const queue = [...entries];
192
290
  while (queue.length > 0) {
193
291
  const next = queue.shift();
292
+ if (!next)
293
+ break;
194
294
  const canonical = resolve(next.path);
195
295
  if (seen.has(canonical))
196
296
  continue;
@@ -336,33 +436,6 @@ async function existingFiles(paths) {
336
436
  }
337
437
  return found;
338
438
  }
339
- function parseReactRouterRoutes(source) {
340
- const routes = [];
341
- const parsed = tsCreateSourceFile(source);
342
- const visit = (node) => {
343
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
344
- if (node.expression.text === "index" &&
345
- ts.isStringLiteral(node.arguments[0])) {
346
- routes.push({ pattern: "/", file: node.arguments[0].text });
347
- }
348
- if (node.expression.text === "route" &&
349
- ts.isStringLiteral(node.arguments[0]) &&
350
- ts.isStringLiteral(node.arguments[1])) {
351
- routes.push({
352
- pattern: reactRouterPathPattern(node.arguments[0].text),
353
- file: node.arguments[1].text,
354
- });
355
- }
356
- }
357
- tsForEachChild(node, visit);
358
- };
359
- visit(parsed);
360
- return routes;
361
- }
362
- function reactRouterPathPattern(pattern) {
363
- const normalized = pattern.startsWith("/") ? pattern : `/${pattern}`;
364
- return normalized.replace(/\$([A-Za-z0-9_]+)/g, ":$1").replace(/\*$/, "*");
365
- }
366
439
  function sourceHashes(sources) {
367
440
  return Object.fromEntries(sources.map((source) => [source.path, sha256(source.text)]));
368
441
  }
@@ -434,10 +507,26 @@ async function readOverlaySpec(model, overlayPath) {
434
507
  async function loadModalityConfig(configPath) {
435
508
  if (!configPath)
436
509
  return {};
437
- const module = (await import(`${pathToFileURL(configPath).href}?t=${Date.now()}`));
510
+ const module = (await importConfigModule(configPath));
438
511
  const exported = module.default ?? module.config ?? {};
439
512
  return typeof exported === "function" ? await exported() : exported;
440
513
  }
514
+ async function importConfigModule(configPath) {
515
+ if (extname(configPath) === ".ts" || extname(configPath) === ".mts") {
516
+ const source = await readFile(configPath, "utf8");
517
+ const transpiled = ts.transpileModule(source, {
518
+ compilerOptions: {
519
+ module: ts.ModuleKind.ESNext,
520
+ target: ts.ScriptTarget.ES2022,
521
+ importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove,
522
+ },
523
+ }).outputText;
524
+ const encoded = Buffer.from(transpiled).toString("base64");
525
+ const url = `data:text/javascript;base64,${encoded}`;
526
+ return import(url);
527
+ }
528
+ return import(`${pathToFileURL(configPath).href}?t=${Date.now()}`);
529
+ }
441
530
  async function findNearestConfig(startDir) {
442
531
  const names = [
443
532
  "modality.config.ts",
@@ -467,6 +556,29 @@ async function findNearestConfig(startDir) {
467
556
  function uniqueStrings(values) {
468
557
  return [...new Set(values)].sort();
469
558
  }
559
+ function commonAncestor(paths) {
560
+ if (paths.length === 0)
561
+ return process.cwd();
562
+ const [first, ...rest] = paths.map((path) => resolve(path).split(/[\\/]+/));
563
+ if (!first)
564
+ return process.cwd();
565
+ let length = first.length;
566
+ for (const parts of rest) {
567
+ length = Math.min(length, parts.length);
568
+ for (let index = 0; index < length; index += 1) {
569
+ if (first[index] !== parts[index]) {
570
+ length = index;
571
+ break;
572
+ }
573
+ }
574
+ }
575
+ const prefix = first.slice(0, length).join("/");
576
+ if (prefix === "") {
577
+ const firstPath = paths[0];
578
+ return firstPath ? parse(firstPath).root : process.cwd();
579
+ }
580
+ return prefix;
581
+ }
470
582
  async function findNearestPackageJson(startDir) {
471
583
  let dir = startDir;
472
584
  while (true) {
@@ -506,21 +618,50 @@ async function assertMatchesExpectedModel(model, expectedModelPath) {
506
618
  throw new Error(`Extracted model differs from expected snapshot ${expectedModelPath}`);
507
619
  }
508
620
  }
509
- function createExtractionReport(sourceFiles, model, warnings, ignoredVars, now) {
621
+ function round2(n) {
622
+ return Math.round(n * 100) / 100;
623
+ }
624
+ function buildStateContributors(model, limit = 20) {
625
+ const contributors = model.vars.map((decl) => {
626
+ const cardinality = domainCardinality(decl.domain);
627
+ const bits = cardinality < 1 ? 0 : round2(Math.log2(cardinality));
628
+ const scope = decl.scope.kind === "global" ? "global" : decl.scope.route;
629
+ const origin = typeof decl.origin === "string" ? decl.origin : decl.origin.file;
630
+ return {
631
+ varId: decl.id,
632
+ domainKind: decl.domain.kind,
633
+ bits,
634
+ scope,
635
+ origin,
636
+ };
637
+ });
638
+ const totalBits = round2(contributors.reduce((sum, c) => sum + c.bits, 0));
639
+ const topVars = [...contributors]
640
+ .sort((a, b) => b.bits - a.bits || a.varId.localeCompare(b.varId))
641
+ .slice(0, limit);
642
+ const bySourceMap = new Map();
643
+ for (const c of contributors) {
644
+ bySourceMap.set(c.origin, round2((bySourceMap.get(c.origin) ?? 0) + c.bits));
645
+ }
646
+ const bySource = [...bySourceMap.entries()]
647
+ .map(([source, bits]) => ({ source, bits }))
648
+ .sort((a, b) => b.bits - a.bits || a.source.localeCompare(b.source));
649
+ return { totalBits, topVars, bySource };
650
+ }
651
+ function createExtractionReport(sourceFiles, model, warnings, ignoredVars, now, inventory) {
510
652
  const caveats = model.metadata?.extractionCaveats ?? emptyExtractionCaveats();
653
+ const varDomains = new Map(model.vars.map((decl) => [decl.id, decl.domain]));
511
654
  const transitionHandlers = model.transitions.map((transition) => ({
512
655
  id: transition.id,
513
656
  classification: transition.confidence === "manual"
514
657
  ? "overlay"
515
658
  : transition.confidence,
516
659
  reasons: transition.confidence === "over-approx"
517
- ? overApproxReasons(transition)
660
+ ? overApproxReasons(transition, varDomains)
518
661
  : [],
519
662
  }));
520
663
  const transitionIds = new Set(transitionHandlers.map((handler) => handler.id));
521
- const unextractableHandlers = warnings
522
- .map(unextractableHandlerFromWarning)
523
- .filter((handler) => Boolean(handler))
664
+ const unextractableHandlers = dedupeUnextractableHandlers(warnings)
524
665
  .filter((handler) => !transitionIds.has(handler.id))
525
666
  .map((handler) => ({
526
667
  id: handler.id,
@@ -531,6 +672,14 @@ function createExtractionReport(sourceFiles, model, warnings, ignoredVars, now)
531
672
  const exactOrOverlay = handlers.filter((handler) => handler.classification === "exact" ||
532
673
  handler.classification === "overlay").length;
533
674
  const unextractable = handlers.filter((handler) => handler.classification === "unextractable").length;
675
+ const coarseDomains = model.vars
676
+ .map((decl) => ({
677
+ varId: decl.id,
678
+ paths: collectTokenDomainPaths(decl.domain),
679
+ }))
680
+ .filter((entry) => entry.paths.length > 0)
681
+ .sort((a, b) => a.varId.localeCompare(b.varId));
682
+ const routeCoverage = buildRouteCoverage(inventory, model);
534
683
  return {
535
684
  schemaVersion: 1,
536
685
  kind: "extraction-report",
@@ -553,6 +702,9 @@ function createExtractionReport(sourceFiles, model, warnings, ignoredVars, now)
553
702
  ? "default-token"
554
703
  : "type-derived"),
555
704
  })),
705
+ ...(coarseDomains.length > 0 ? { coarseDomains } : {}),
706
+ stateContributors: buildStateContributors(model),
707
+ ...(routeCoverage ? { routeCoverage } : {}),
556
708
  coverage: {
557
709
  handlersTotal: handlers.length,
558
710
  exactOrOverlay,
@@ -563,6 +715,106 @@ function createExtractionReport(sourceFiles, model, warnings, ignoredVars, now)
563
715
  warnings,
564
716
  };
565
717
  }
718
+ function buildRouteCoverage(inventory, model) {
719
+ if (!inventory || inventory.routes.length === 0)
720
+ return undefined;
721
+ const routeVar = model.vars.find((decl) => decl.id === "sys:route");
722
+ const modeledValues = new Set(routeVar?.domain.kind === "enum" ? routeVar.domain.values : []);
723
+ const routes = inventory.routes
724
+ .map((node) => {
725
+ const modeled = modeledValues.has(node.pattern);
726
+ if (modeled)
727
+ return { pattern: node.pattern, modeled: true };
728
+ let classification;
729
+ let reason;
730
+ if (node.kind === "resource") {
731
+ classification = "api";
732
+ reason = "API/resource route excluded from client state";
733
+ }
734
+ else if (node.redirectTo) {
735
+ classification = "redirect-only";
736
+ reason = "Redirect-only route excluded from client state";
737
+ }
738
+ else if (node.pattern.includes("*")) {
739
+ classification = "unsupported";
740
+ reason = "Splat/wildcard route pattern not modeled";
741
+ }
742
+ else {
743
+ classification = "no-client-state";
744
+ reason = "No client-side state modeled for this route";
745
+ }
746
+ return { pattern: node.pattern, modeled: false, classification, reason };
747
+ })
748
+ .sort((left, right) => left.pattern.localeCompare(right.pattern));
749
+ const modeled = routes.filter((entry) => entry.modeled).length;
750
+ return { configured: inventory.routes.length, modeled, routes };
751
+ }
752
+ function formatRouteCoverageLine(coverage) {
753
+ const omitted = coverage.configured - coverage.modeled;
754
+ const counts = new Map();
755
+ for (const entry of coverage.routes) {
756
+ if (entry.modeled || !entry.classification)
757
+ continue;
758
+ counts.set(entry.classification, (counts.get(entry.classification) ?? 0) + 1);
759
+ }
760
+ const parts = [...counts.entries()]
761
+ .sort(([left], [right]) => left.localeCompare(right))
762
+ .map(([classification, count]) => `${classification}=${count}`);
763
+ const suffix = parts.length > 0 ? ` [${parts.join(",")}]` : "";
764
+ return `routes configured=${coverage.configured} modeled=${coverage.modeled} omitted=${omitted}${suffix}`;
765
+ }
766
+ function buildLocationLowering(transitions, adapter, inventory) {
767
+ const pushTargets = new Set();
768
+ const pushOrigins = new Set();
769
+ let hasUnboundPush = false;
770
+ for (const transition of transitions) {
771
+ if (transition.id.startsWith("route:"))
772
+ continue;
773
+ const navigations = collectPushReplaceNavigations(transition.effect);
774
+ if (navigations.length === 0)
775
+ continue;
776
+ const component = transition.id.split(".")[0] ?? "";
777
+ const origin = adapter.routeForComponent?.(component, inventory);
778
+ for (const navigation of navigations) {
779
+ if (navigation.to)
780
+ pushTargets.add(navigation.to);
781
+ if (!origin)
782
+ hasUnboundPush = true;
783
+ else
784
+ pushOrigins.add(origin);
785
+ }
786
+ }
787
+ return {
788
+ pushTargets: [...pushTargets].sort(),
789
+ pushOrigins: [...pushOrigins].sort(),
790
+ hasUnboundPush,
791
+ };
792
+ }
793
+ function collectPushReplaceNavigations(effect) {
794
+ const navigations = [];
795
+ const visit = (current) => {
796
+ if (current.kind === "navigate" &&
797
+ (current.mode === "push" || current.mode === "replace")) {
798
+ const to = current.to?.kind === "lit" && typeof current.to.value === "string"
799
+ ? current.to.value
800
+ : undefined;
801
+ navigations.push({
802
+ mode: current.mode,
803
+ ...(to !== undefined ? { to } : {}),
804
+ });
805
+ }
806
+ if (current.kind === "seq") {
807
+ for (const child of current.effects)
808
+ visit(child);
809
+ }
810
+ if (current.kind === "if") {
811
+ visit(current.then);
812
+ visit(current.else);
813
+ }
814
+ };
815
+ visit(effect);
816
+ return navigations;
817
+ }
566
818
  function emptyExtractionCaveats() {
567
819
  return {
568
820
  globalTaints: [],
@@ -585,10 +837,7 @@ function createExtractionCaveats(warnings) {
585
837
  .map(unhandledRejectionFromWarning)
586
838
  .filter(isCaveat)
587
839
  .sort(compareCaveats),
588
- unextractableHandlers: warnings
589
- .map(unextractableHandlerFromWarning)
590
- .filter(isCaveat)
591
- .sort(compareCaveats),
840
+ unextractableHandlers: dedupeUnextractableHandlers(warnings),
592
841
  };
593
842
  }
594
843
  function globalTaintFromWarning(warning) {
@@ -652,32 +901,15 @@ function firstSemverMajor(range) {
652
901
  function pluginProvenance(plugins) {
653
902
  return [...plugins.sources, ...(plugins.router ? [plugins.router] : [])].sort((left, right) => left.kind.localeCompare(right.kind) || left.id.localeCompare(right.id));
654
903
  }
655
- function transitionNavigatedRoutes(transitions) {
656
- const routes = new Set();
657
- const visit = (effect) => {
658
- if (effect.kind === "navigate" &&
659
- effect.to?.kind === "lit" &&
660
- typeof effect.to.value === "string")
661
- routes.add(effect.to.value);
662
- if (effect.kind === "seq") {
663
- for (const child of effect.effects)
664
- visit(child);
665
- }
666
- if (effect.kind === "if") {
667
- visit(effect.then);
668
- visit(effect.else);
669
- }
670
- };
671
- for (const transition of transitions)
672
- visit(transition.effect);
673
- return [...routes].sort();
674
- }
675
- function overApproxReasons(transition) {
904
+ function overApproxReasons(transition, varDomains = new Map()) {
676
905
  const reasons = new Set();
677
906
  if (transition.id.endsWith(".escaped"))
678
907
  reasons.add("setter escaped to unanalyzed call");
679
- for (const variable of havocWrites(transition.effect))
680
- reasons.add(`havoc write to ${variable}`);
908
+ for (const variable of havocWrites(transition.effect)) {
909
+ const domain = varDomains.get(variable);
910
+ const prefix = domain?.kind === "bool" ? "safe local toggle" : "domain-wide havoc";
911
+ reasons.add(`${prefix}: havoc write to ${variable}`);
912
+ }
681
913
  if (reasons.size === 0)
682
914
  reasons.add("transition confidence is over-approx");
683
915
  return [...reasons].sort();
@@ -728,17 +960,28 @@ function normalizeId(id) {
728
960
  function editDistance(left, right) {
729
961
  const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
730
962
  for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
731
- let diagonal = previous[0];
963
+ const startDiagonal = previous[0];
964
+ if (startDiagonal === undefined)
965
+ break;
966
+ let diagonal = startDiagonal;
732
967
  previous[0] = leftIndex;
733
968
  for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
734
- const up = previous[rightIndex] + 1;
735
- const leftCost = previous[rightIndex - 1] + 1;
969
+ const upCell = previous[rightIndex];
970
+ const leftCell = previous[rightIndex - 1];
971
+ if (upCell === undefined || leftCell === undefined)
972
+ break;
973
+ const up = upCell + 1;
974
+ const leftCost = leftCell + 1;
736
975
  const subst = diagonal + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1);
737
- diagonal = previous[rightIndex];
976
+ const corner = previous[rightIndex];
977
+ if (corner === undefined)
978
+ break;
979
+ diagonal = corner;
738
980
  previous[rightIndex] = Math.min(up, leftCost, subst);
739
981
  }
740
982
  }
741
- return previous[right.length];
983
+ const distance = previous[right.length];
984
+ return distance ?? 0;
742
985
  }
743
986
  function havocWrites(effect) {
744
987
  if (effect.kind === "havoc")
@@ -749,9 +992,47 @@ function havocWrites(effect) {
749
992
  return [...havocWrites(effect.then), ...havocWrites(effect.else)];
750
993
  return [];
751
994
  }
995
+ const GENERIC_UNEXTRACTABLE_CATEGORIES = new Set([
996
+ "no-extractable-effect",
997
+ "unextractable",
998
+ ]);
999
+ function dedupeUnextractableHandlers(warnings) {
1000
+ const parsed = warnings.map(unextractableHandlerFromWarning).filter((handler) => Boolean(handler));
1001
+ const byId = new Map();
1002
+ for (const handler of parsed) {
1003
+ const existing = byId.get(handler.id);
1004
+ if (!existing) {
1005
+ byId.set(handler.id, handler);
1006
+ continue;
1007
+ }
1008
+ const existingIsGeneric = GENERIC_UNEXTRACTABLE_CATEGORIES.has(existing.category);
1009
+ const incomingIsGeneric = GENERIC_UNEXTRACTABLE_CATEGORIES.has(handler.category);
1010
+ if (existingIsGeneric && !incomingIsGeneric)
1011
+ byId.set(handler.id, handler);
1012
+ }
1013
+ return [...byId.values()]
1014
+ .map(({ id, reason, source }) => source ? { id, reason, source } : { id, reason })
1015
+ .sort(compareCaveats);
1016
+ }
752
1017
  function unextractableHandlerFromWarning(warning) {
753
- const match = /^Unextractable handler (.+)$/.exec(warning);
754
- return match?.[1] ? { id: match[1], reason: warning } : undefined;
1018
+ const rich = /^Unextractable handler (\S+) \[([^\]]+)\] \((.+)\)$/.exec(warning);
1019
+ if (rich) {
1020
+ const id = rich[1];
1021
+ const category = rich[2];
1022
+ const source = rich[3];
1023
+ if (!id || !category || !source)
1024
+ return undefined;
1025
+ return {
1026
+ id,
1027
+ category,
1028
+ reason: `${category} at ${source}`,
1029
+ source,
1030
+ };
1031
+ }
1032
+ const bare = /^Unextractable handler (\S+)$/.exec(warning);
1033
+ return bare?.[1]
1034
+ ? { id: bare[1], category: "unextractable", reason: bare[0] }
1035
+ : undefined;
755
1036
  }
756
1037
  function pendingVars(effectApis, transitions = [], vars = [], maxPending = 3) {
757
1038
  const enqueues = transitions.flatMap((transition) => enqueueOps(transition.effect));